diff --git a/main/routes/index.ts b/main/routes/index.ts index 0441b3d..b8bb3da 100644 --- a/main/routes/index.ts +++ b/main/routes/index.ts @@ -147,9 +147,13 @@ export function registerRoutes(app: Express): void { .all(orderId) as any[]; let subtotal = 0; let totalTax = 0; + let exclusiveTax = 0; for (const i of activeItems) { subtotal += i.subtotal || 0; totalTax += i.tax_amount || 0; + if (i.tax_type !== 'inclusive') { + exclusiveTax += i.tax_amount || 0; + } } // BUG #13 FIX: Preserve order-level discount (scale percentage proportionally) @@ -165,13 +169,15 @@ export function registerRoutes(app: Express): void { const discountedSubtotal = Math.max(0, subtotal - newDiscountAmount); let newTaxAmount = totalTax; + let newExclusiveTax = exclusiveTax; if (newDiscountAmount > 0 && subtotal > 0) { const taxRatio = discountedSubtotal / subtotal; newTaxAmount = Math.round(totalTax * taxRatio * 100) / 100; + newExclusiveTax = Math.round(exclusiveTax * taxRatio * 100) / 100; } // BUG #5 FIX: Correct round-off formula; BUG #24 FIX: include delivery_charge (was missing, causing total mismatch with bill generation) - const preRoundTotal = discountedSubtotal + newTaxAmount + (order.delivery_charge || 0) + (order.packaging_charge || 0); + const preRoundTotal = discountedSubtotal + newExclusiveTax + (order.delivery_charge || 0) + (order.packaging_charge || 0); const roundOff = Math.round(preRoundTotal) - preRoundTotal; const total = Math.round(preRoundTotal); @@ -248,9 +254,13 @@ export function registerRoutes(app: Express): void { .all(orderId) as any[]; let subtotal = 0; let totalTax = 0; + let exclusiveTax = 0; for (const i of activeItems) { subtotal += i.subtotal || 0; totalTax += i.tax_amount || 0; + if (i.tax_type !== 'inclusive') { + exclusiveTax += i.tax_amount || 0; + } } // BUG #13 FIX: Preserve order-level discount (scale percentage proportionally) @@ -266,13 +276,15 @@ export function registerRoutes(app: Express): void { const discountedSubtotal = Math.max(0, subtotal - newDiscountAmount); let newTaxAmount = totalTax; + let newExclusiveTax = exclusiveTax; if (newDiscountAmount > 0 && subtotal > 0) { const taxRatio = discountedSubtotal / subtotal; newTaxAmount = Math.round(totalTax * taxRatio * 100) / 100; + newExclusiveTax = Math.round(exclusiveTax * taxRatio * 100) / 100; } // BUG #5 FIX: Correct round-off formula; BUG #24 FIX: include delivery_charge (was missing, causing total mismatch with bill generation) - const preRoundTotal = discountedSubtotal + newTaxAmount + (order.delivery_charge || 0) + (order.packaging_charge || 0); + const preRoundTotal = discountedSubtotal + newExclusiveTax + (order.delivery_charge || 0) + (order.packaging_charge || 0); const roundOff = Math.round(preRoundTotal) - preRoundTotal; const total = Math.round(preRoundTotal); diff --git a/main/routes/orders.ts b/main/routes/orders.ts index c8490ab..b4e2cfa 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -165,6 +165,7 @@ router.post('/', requireRole('owner', 'manager', 'cashier', 'waiter'), (req: Req let subtotal = 0; let totalTax = 0; + let exclusiveTax = 0; const allTaxBreakdowns: any[] = []; const insertItem = db.prepare(` @@ -208,11 +209,14 @@ router.post('/', requireRole('owner', 'manager', 'cashier', 'waiter'), (req: Req const taxResult = calculateItemTax(tenantInfo, product, itemSubtotal, customer); totalTax += taxResult.tax_amount; + if (taxResult.tax_type !== 'inclusive') { + exclusiveTax += taxResult.tax_amount; + } if (taxResult.tax_breakdown) { allTaxBreakdowns.push(taxResult.tax_breakdown); } - const itemTotal = itemSubtotal + taxResult.tax_amount; + const itemTotal = itemSubtotal + (taxResult.tax_type === 'inclusive' ? 0 : taxResult.tax_amount); subtotal += itemSubtotal; insertItem.run( @@ -231,7 +235,7 @@ router.post('/', requireRole('owner', 'manager', 'cashier', 'waiter'), (req: Req } } - const preRoundTotal = subtotal + totalTax + (delivery_charge || 0) + (packaging_charge || 0); + const preRoundTotal = subtotal + exclusiveTax + (delivery_charge || 0) + (packaging_charge || 0); const total = Math.round(preRoundTotal); const roundOff = total - preRoundTotal; @@ -348,7 +352,7 @@ router.post('/:id/items', requireRole('owner', 'manager', 'cashier', 'waiter'), itemSubtotal = Math.max(0, itemSubtotal - itemDiscount); const taxResult = calculateItemTax(tenantInfo, product, itemSubtotal, customer); - const itemTotal = itemSubtotal + taxResult.tax_amount; + const itemTotal = itemSubtotal + (taxResult.tax_type === 'inclusive' ? 0 : taxResult.tax_amount); insertItem.run( req.params.id, product.id, product.name, product.sku, unitPrice, quantity, @@ -370,15 +374,19 @@ router.post('/:id/items', requireRole('owner', 'manager', 'cashier', 'waiter'), const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status != 'cancelled'").all(req.params.id) as any[]; let subtotal = 0; let totalTax = 0; + let exclusiveTax = 0; const allTaxBreakdowns: any[] = []; for (const item of activeItems) { subtotal += item.subtotal; totalTax += item.tax_amount; + if (item.tax_type !== 'inclusive') { + exclusiveTax += item.tax_amount; + } if (item.tax_breakdown) { try { const breakdown = JSON.parse(item.tax_breakdown); if (Array.isArray(breakdown)) allTaxBreakdowns.push(breakdown); - } catch {} + } catch { } } } @@ -395,12 +403,14 @@ router.post('/:id/items', requireRole('owner', 'manager', 'cashier', 'waiter'), const discountedSubtotal = Math.max(0, subtotal - newDiscountAmount); let newTaxAmount = totalTax; + let newExclusiveTax = exclusiveTax; if (newDiscountAmount > 0 && subtotal > 0) { const taxRatio = discountedSubtotal / subtotal; newTaxAmount = Math.round(totalTax * taxRatio * 100) / 100; + newExclusiveTax = Math.round(exclusiveTax * taxRatio * 100) / 100; } - const preRoundTotal = discountedSubtotal + newTaxAmount + ((order as any).delivery_charge || 0) + ((order as any).packaging_charge || 0); + const preRoundTotal = discountedSubtotal + newExclusiveTax + ((order as any).delivery_charge || 0) + ((order as any).packaging_charge || 0); const total = Math.round(preRoundTotal); const roundOff = total - preRoundTotal; @@ -689,18 +699,24 @@ router.patch('/:id/discount', requireRole('owner', 'manager'), (req: Request, re // reduction each time this endpoint is called. const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status != 'cancelled'").all(req.params.id) as any[]; let freshTax = 0; + let exclusiveTax = 0; for (const item of activeItems) { freshTax += item.tax_amount || 0; + if (item.tax_type !== 'inclusive') { + exclusiveTax += item.tax_amount || 0; + } } let newTaxAmount = freshTax; + let newExclusiveTax = exclusiveTax; if (discountAmount > 0 && order.subtotal > 0) { const discountedSubtotal = Math.max(0, order.subtotal - discountAmount); const taxRatio = discountedSubtotal / order.subtotal; newTaxAmount = Math.round(freshTax * taxRatio * 100) / 100; + newExclusiveTax = Math.round(exclusiveTax * taxRatio * 100) / 100; } const discountedSubtotal = Math.max(0, order.subtotal - discountAmount); - const preRoundTotal = discountedSubtotal + newTaxAmount + (order.packaging_charge || 0) + (order.delivery_charge || 0); + const preRoundTotal = discountedSubtotal + newExclusiveTax + (order.packaging_charge || 0) + (order.delivery_charge || 0); const newTotal = Math.round(preRoundTotal); const roundOff = newTotal - preRoundTotal; @@ -826,7 +842,7 @@ router.patch('/:id/items/:itemId/discount', requireRole('owner', 'manager'), (re addonTotal += (addon.price || 0) * item.quantity; } } - } catch {} + } catch { } } const itemBaseTotal = item.unit_price * item.quantity + addonTotal; @@ -855,7 +871,7 @@ router.patch('/:id/items/:itemId/discount', requireRole('owner', 'manager'), (re const newTaxAmount = taxResult.tax_amount; const newTaxBreakdown = taxResult.tax_breakdown; - const newTotal = newSubtotal + newTaxAmount; + const newTotal = newSubtotal + (taxResult.tax_type === 'inclusive' ? 0 : newTaxAmount); // Update item with recalculated tax db.prepare(` @@ -867,9 +883,13 @@ router.patch('/:id/items/:itemId/discount', requireRole('owner', 'manager'), (re const allItems = db.prepare('SELECT * FROM order_items WHERE order_id = ?').all(req.params.id) as any[]; let orderSubtotal = 0; let orderTax = 0; + let exclusiveOrderTax = 0; for (const i of allItems) { orderSubtotal += i.subtotal; orderTax += i.tax_amount; + if (i.tax_type !== 'inclusive') { + exclusiveOrderTax += i.tax_amount; + } } // Recalculate order-level discount proportionally on new subtotal @@ -883,12 +903,14 @@ router.patch('/:id/items/:itemId/discount', requireRole('owner', 'manager'), (re // Recalculate tax on discounted subtotal const discountedSubtotal = Math.max(0, orderSubtotal - newOrderDiscount); let newOrderTax = orderTax; + let newExclusiveOrderTax = exclusiveOrderTax; if (newOrderDiscount > 0 && orderSubtotal > 0) { const taxRatio = discountedSubtotal / orderSubtotal; newOrderTax = Math.round(orderTax * taxRatio * 100) / 100; + newExclusiveOrderTax = Math.round(exclusiveOrderTax * taxRatio * 100) / 100; } - const preRoundTotal = discountedSubtotal + newOrderTax + (order.packaging_charge || 0) + (order.delivery_charge || 0); + const preRoundTotal = discountedSubtotal + newExclusiveOrderTax + (order.packaging_charge || 0) + (order.delivery_charge || 0); const orderTotal = Math.round(preRoundTotal); const roundOff = orderTotal - preRoundTotal; diff --git a/package-lock.json b/package-lock.json index 27976ce..7ddaf8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -460,7 +460,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -482,7 +481,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -499,7 +497,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -514,7 +511,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1353,6 +1349,7 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1531,6 +1528,7 @@ "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", @@ -1788,6 +1786,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1834,6 +1833,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2887,8 +2887,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -3171,6 +3170,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -3570,7 +3570,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -3591,7 +3590,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -3742,6 +3740,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5644,7 +5643,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -6144,6 +6142,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -6241,6 +6240,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6318,7 +6318,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -6336,7 +6335,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -6764,7 +6762,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -7468,7 +7465,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -7729,6 +7725,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/temp-tax-test.js b/temp-tax-test.js new file mode 100644 index 0000000..ae8e954 --- /dev/null +++ b/temp-tax-test.js @@ -0,0 +1,5 @@ +const { calculateItemTax } = require('./tests/helpers/test-setup'); // wait, calculateItemTax isn't exported from test-setup +const fs = require('fs'); + +const taxCode = fs.readFileSync('./main/services/tax.ts', 'utf8'); +console.log(taxCode.includes('calculateItemTax')); diff --git a/tests/inclusive-tax-test-results.txt b/tests/inclusive-tax-test-results.txt new file mode 100644 index 0000000..7166983 --- /dev/null +++ b/tests/inclusive-tax-test-results.txt @@ -0,0 +1,40 @@ +Integration Test: Inclusive Tax Correctness +[DB] Opening database at: /var/folders/0d/2kwkrk3j5j373ss9sbf8sd6m0000gn/T/flo-tax-incl-test-ddogJ0/flo.db +[DB] Schema: v0 → v14 +[DB] Applying migration v1: initial_schema +[DB] Install defaults loaded; first-run setup pending +[DB] Migration v1 complete +[DB] Applying migration v2: hash_plaintext_pins +[DB] Migration v2 complete +[DB] Applying migration v3: cloud_identity_and_outbox +[DB] Migration v3 complete +[DB] Applying migration v4: add_notes_limits_settings +[DB] Migration v4 complete +[DB] Applying migration v5: add_print_logs_table +[DB] Migration v5 complete +[DB] Applying migration v6: add_loyalty_settings +[DB] Migration v6 complete +[DB] Applying migration v7: add_discount_settings +[DB] Migration v7 complete +[DB] Applying migration v8: add_loyalty_index +[DB] Migration v8 complete +[DB] Applying migration v9: add_sequences_table +[DB] Migration v9 complete +[DB] Applying migration v10: fix_sequences_composite_key +[DB] Migration v10 complete +[DB] Applying migration v11: first_run_setup_uses_welcome_form +[DB] Migration v11 complete +[DB] Applying migration v12: fix_table_integer_ids +[DB] Migration v12 complete +[DB] Applying migration v13: fix_null_table_ids +[DB] Migration v13 complete +[DB] Applying migration v14: simplify_loyalty_settings +[DB] Migration v14 complete +[DB] integrity_check: ok +[DB] foreign_key_check: clean +[Auth] Generated new JWT secret for this install + ✓ order created + ✓ subtotal = ₹1000 + ✓ total = ₹1000 (inclusive tax should not increase total) + ✓ discounted total = 900 +[DB] Database closed diff --git a/tests/integration-inclusive-tax.test.ts b/tests/integration-inclusive-tax.test.ts new file mode 100644 index 0000000..42328ad --- /dev/null +++ b/tests/integration-inclusive-tax.test.ts @@ -0,0 +1,78 @@ +/** + * Integration Test: Inclusive Tax Correctness + */ +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-tax-incl-test-')); +Module._load = function (request, parent, isMain) { + if (request === 'electron') return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + return originalLoad.apply(this, arguments); +}; + +const { + initTestDb, createApp, startServer, + seedOwnerUser, seedCategory, seedProduct, + api, assert, assertEqual, getResults, closeDatabase, getDatabase, now, +} = require('./helpers/test-setup'); + +const { orderRoutes } = require('../main/routes/orders'); +const { billRoutes } = require('../main/routes/bills'); + +async function main() { + console.log('Integration Test: Inclusive Tax Correctness'); + const db = initTestDb(); + + db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', ?)").run(now()); + db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_type', 'restaurant', ?)").run(now()); + db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('state_code', '27', ?)").run(now()); + + const { authHeader } = seedOwnerUser(db); + seedCategory(db, 'cat-tax', 'Tax Test Menu'); + // Seed inclusive product (5% tax, price 1000 inclusive) + db.prepare("INSERT INTO products (id, category_id, name, price, tax_type, track_inventory, stock_quantity) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run('prod-incl', 'cat-tax', 'Inclusive Coffee', 1000, 'inclusive', 0, 0); + + const app = createApp({ + '/api/orders': orderRoutes, + '/api/bills': billRoutes, + }); + const { baseUrl, server } = await startServer(app); + + try { + // 1. Create order + const createRes = await api(baseUrl, '/api/orders', { + method: 'POST', + body: { type: 'takeaway', items: [{ product_id: 'prod-incl', quantity: 1 }] }, + headers: authHeader, + }); + assertEqual(createRes.status, 201, 'order created'); + const orderId = createRes.data.order.id; + + const initialTax = createRes.data.order.tax_amount; + const initialTotal = createRes.data.order.total; + const initialSubtotal = createRes.data.order.subtotal; + + assertEqual(initialSubtotal, 1000, 'subtotal = ₹1000'); + assertEqual(initialTotal, 1000, 'total = ₹1000 (inclusive tax should not increase total)'); + + // 2. Apply 10% discount + const discountRes = await api(baseUrl, `/api/orders/${orderId}/discount`, { + method: 'PATCH', + body: { discount_type: 'percentage', discount_value: 10 }, + headers: authHeader, + }); + assertEqual(discountRes.data.order.total, 900, 'discounted total = 900'); + + } finally { + server.close(); + closeDatabase(); + try { fs.rmSync(testDir, { recursive: true, force: true }); } catch { } + } + + const { failed } = getResults(); + process.exit(failed === 0 ? 0 : 1); +} +main().catch((err) => { console.error(err); process.exit(1); });