From 5cb8f8b7cd959768431b7b102d07fe118f83468e Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 00:35:48 -0400 Subject: [PATCH 1/7] refactor: deduplicate order total recalculation --- main/services/orders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/services/orders.ts b/main/services/orders.ts index faf874ae..df2caf2b 100644 --- a/main/services/orders.ts +++ b/main/services/orders.ts @@ -22,7 +22,7 @@ export interface OrderTotals { export function calculateOrderTotals(db: Database, orderId: string | number): OrderTotals { const statusPlaceholders = TERMINAL_ITEM_STATUSES.map(() => '?').join(', '); - const activeItems = db.prepare(`SELECT * FROM order_items WHERE order_id = ? AND status NOT IN (${statusPlaceholders})`) + const activeItems = db.prepare(`SELECT * FROM order_items WHERE order_id = ? AND (status IS NULL OR status NOT IN (${statusPlaceholders}))`) .all(orderId, ...TERMINAL_ITEM_STATUSES) as OrderItemRow[]; let subtotal = 0; let totalTax = 0; From 46260dc6040fce83e9cdbc3ff9663256c950e33f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 01:39:22 -0400 Subject: [PATCH 2/7] test: cover taxable item discount recalculation --- tests/integration-tax.test.ts | 44 ++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/integration-tax.test.ts b/tests/integration-tax.test.ts index 0c542a20..a953dcc6 100644 --- a/tests/integration-tax.test.ts +++ b/tests/integration-tax.test.ts @@ -166,7 +166,6 @@ async function main() { headers: authHeader, }); assertEqual(mixedOrderRes.status, 201, 'mixed order created'); - const mixedOrderId = mixedOrderRes.data.order.id; const [uncategorizedItem, categorizedItem] = mixedOrderRes.data.order.items; assert(!uncategorizedItem.tax_snapshot, 'uncategorized item has no tax_snapshot'); assertEqual(uncategorizedItem.tax_amount, 0, 'uncategorized item has zero tax'); @@ -177,27 +176,50 @@ async function main() { const orderSnapshot = typeof orderSnapshotRaw === 'string' ? JSON.parse(orderSnapshotRaw) : orderSnapshotRaw; assertEqual(orderSnapshot.length, 1, 'order tax_snapshot has exactly one entry (only the categorized item)'); - // ── Step 6: cancelled items must not re-enter later item-discount recompute ── - console.log('\n6. Cancel one item, then discount the other — cancelled item must stay excluded'); - const cancelRes = await api(baseUrl, `/api/orders/${mixedOrderId}/items/${uncategorizedItem.id}/cancel`, { + // ── Step 6: cancelled taxable items must stay excluded from item-discount recompute ── + console.log('\n6. Cancel one taxable item, then discount the other - cancelled tax data must stay excluded'); + const itemDiscountOrderRes = await api(baseUrl, '/api/orders', { + method: 'POST', + body: { + type: 'takeaway', + items: [ + { product_id: 'prod-tax-1', quantity: 1 }, + { product_id: 'prod-tax-2', quantity: 1 }, + ], + }, + headers: authHeader, + }); + assertEqual(itemDiscountOrderRes.status, 201, 'item discount regression order created'); + const itemDiscountOrderId = itemDiscountOrderRes.data.order.id; + const itemDiscountVoidedItem = itemDiscountOrderRes.data.order.items.find((item: any) => item.product_id === 'prod-tax-1'); + const itemDiscountActiveItem = itemDiscountOrderRes.data.order.items.find((item: any) => item.product_id === 'prod-tax-2'); + + const cancelRes = await api(baseUrl, `/api/orders/${itemDiscountOrderId}/items/${itemDiscountVoidedItem.id}/cancel`, { method: 'PATCH', body: {}, headers: authHeader, }); - assertEqual(cancelRes.status, 200, 'uncategorized item cancelled'); + assertEqual(cancelRes.status, 200, 'taxable item cancelled'); - const itemDiscountRes = await api(baseUrl, `/api/orders/${mixedOrderId}/items/${categorizedItem.id}/discount`, { + const itemDiscountRes = await api(baseUrl, `/api/orders/${itemDiscountOrderId}/items/${itemDiscountActiveItem.id}/discount`, { method: 'PATCH', body: { discount_type: 'percentage', discount_value: 10 }, // 10% of ₹500 = ₹50 headers: authHeader, }); assertEqual(itemDiscountRes.status, 200, 'item discount applied after sibling cancel'); - // Regression check: before the fix, this recompute summed ALL items - // (including the cancelled one), so subtotal would include the - // cancelled ₹1000 item on top of the discounted ₹500 one. assertEqual(itemDiscountRes.data.item.subtotal, 450, 'discounted item subtotal (₹500 - ₹50)'); - const afterOrder = (await api(baseUrl, `/api/orders/${mixedOrderId}`, { headers: authHeader })).data.order; + const afterOrder = (await api(baseUrl, `/api/orders/${itemDiscountOrderId}`, { headers: authHeader })).data.order; assertEqual(afterOrder.subtotal, 450, "order subtotal excludes the cancelled item — didn't silently un-cancel it"); + assertEqual(afterOrder.tax_amount, 22.5, 'item discount tax uses only the active taxable item'); + const itemDiscountBreakdown = afterOrder.tax_breakdown; + const itemDiscountBreakdownGroups = Array.isArray(itemDiscountBreakdown?.[0]) + ? itemDiscountBreakdown + : [itemDiscountBreakdown]; + assertEqual(itemDiscountBreakdownGroups.length, 1, 'item discount tax breakdown contains only the active item'); + const itemDiscountSnapshot = typeof afterOrder.tax_snapshot === 'string' + ? JSON.parse(afterOrder.tax_snapshot) + : afterOrder.tax_snapshot; + assertEqual(itemDiscountSnapshot.length, 1, 'item discount tax snapshot contains only the active item'); // -- Step 7: voided items must stay excluded from order-discount tax recompute -- console.log('\n7. Void one taxable item, then discount the order - void data must stay excluded'); @@ -259,7 +281,7 @@ async function main() { console.log('\n8. Edit a bill discount — tax must not compound on the prior edit'); const mixedBillRes = await api(baseUrl, '/api/bills/generate', { method: 'POST', - body: { order_id: mixedOrderId }, + body: { order_id: itemDiscountOrderId }, headers: authHeader, }); assertEqual(mixedBillRes.status, 201, 'bill generated for discounted categorized order'); From 1b9c52d7d59abcd717ade94cdc090d8907ba1a04 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 20:49:48 -0400 Subject: [PATCH 3/7] fix: preserve active order and bill totals --- main/routes/orders.ts | 4 +-- tests/integration-bill-reconciliation.test.ts | 9 ++++++ tests/integration-tax.test.ts | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/main/routes/orders.ts b/main/routes/orders.ts index e565bcb0..0eea020a 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -841,8 +841,8 @@ router.post('/:id/items', orderWriteRateLimit, requireRole(...ROLE_ACCESS.sales) const pack = getActiveCountryPack(tenantInfo.country); const { total: billTotal, adjustment: billRoundOff } = applyPayableRounding(total, pack, currency); const newBillBalance = Math.max(0, billTotal - (existingBill.paid_amount || 0)); - db.prepare(`UPDATE bills SET total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, round_off = ?, updated_at = ? WHERE id = ?`) - .run(billTotal, newBillBalance, taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), taxRollup.snapshotJson, newDiscountAmount, currentOrder.service_charge || 0, billRoundOff, now(), existingBill.id); + db.prepare(`UPDATE bills SET subtotal = ?, total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, round_off = ?, updated_at = ? WHERE id = ?`) + .run(subtotal, billTotal, newBillBalance, taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), taxRollup.snapshotJson, newDiscountAmount, currentOrder.service_charge || 0, billRoundOff, now(), existingBill.id); } recordOrderAudit(db, { orderId: req.params.id as string, actorUserId: idempotencyUserId, action: 'items_added', details: { item_ids: insertedItemIds } }); diff --git a/tests/integration-bill-reconciliation.test.ts b/tests/integration-bill-reconciliation.test.ts index 45fb4a45..d6a43399 100644 --- a/tests/integration-bill-reconciliation.test.ts +++ b/tests/integration-bill-reconciliation.test.ts @@ -159,6 +159,15 @@ async function main() { const orderAfterAdd = addItemsRes.data.order; assert(orderAfterAdd.total > orderBTotal, `order total increased (₹${orderBTotal} → ₹${orderAfterAdd.total})`); + // The existing unpaid bill must stay in sync before it is re-generated. + const billAfterAddBeforeRegeneration = await api(baseUrl, `/api/bills/${billIdB}`, { headers: authHeader }); + assertEqual(billAfterAddBeforeRegeneration.status, 200, 'existing bill remains readable after adding items'); + assertEqual( + billAfterAddBeforeRegeneration.data.bill.subtotal, + orderAfterAdd.subtotal, + `bill subtotal (₹${billAfterAddBeforeRegeneration.data.bill.subtotal}) matches order subtotal (₹${orderAfterAdd.subtotal}) after adding items`, + ); + // Re-generate bill — it should sync with the updated order total const billAfterAdd = await api(baseUrl, '/api/bills/generate', { method: 'POST', diff --git a/tests/integration-tax.test.ts b/tests/integration-tax.test.ts index a953dcc6..3a5b5e87 100644 --- a/tests/integration-tax.test.ts +++ b/tests/integration-tax.test.ts @@ -277,6 +277,35 @@ async function main() { : discountAfterVoidRes.data.order.tax_snapshot; assertEqual(postVoidSnapshot.length, 1, 'tax snapshot contains only the active item'); + // -- Step 7b: legacy NULL item statuses must stay included in recalculation -- + console.log('\n7b. Discount an order with a legacy NULL item status - item tax must stay included'); + const nullStatusOrderRes = await api(baseUrl, '/api/orders', { + method: 'POST', + body: { + type: 'takeaway', + items: [{ product_id: 'prod-tax-2', quantity: 1 }], + }, + headers: authHeader, + }); + assertEqual(nullStatusOrderRes.status, 201, 'legacy NULL status regression order created'); + const nullStatusOrderId = nullStatusOrderRes.data.order.id; + const nullStatusItem = nullStatusOrderRes.data.order.items[0]; + db.prepare('UPDATE order_items SET status = NULL WHERE id = ?').run(nullStatusItem.id); + + const nullStatusDiscountRes = await api(baseUrl, `/api/orders/${nullStatusOrderId}/discount`, { + method: 'PATCH', + body: { discount_type: 'percentage', discount_value: 10 }, + headers: authHeader, + }); + assertEqual(nullStatusDiscountRes.status, 200, 'order discount applied with legacy NULL item status'); + assertEqual(nullStatusDiscountRes.data.order.subtotal, 500, 'NULL-status item remains in subtotal'); + assertEqual(nullStatusDiscountRes.data.order.tax_amount, 22.5, 'NULL-status item tax remains included'); + assertEqual(nullStatusDiscountRes.data.order.total, 472.5, 'total includes discounted NULL-status item and tax'); + const nullStatusSnapshot = typeof nullStatusDiscountRes.data.order.tax_snapshot === 'string' + ? JSON.parse(nullStatusDiscountRes.data.order.tax_snapshot) + : nullStatusDiscountRes.data.order.tax_snapshot; + assertEqual(nullStatusSnapshot.length, 1, 'tax snapshot retains the NULL-status item'); + // ── Step 8: bill discount edits must use item tax, not prior bill tax ── console.log('\n8. Edit a bill discount — tax must not compound on the prior edit'); const mixedBillRes = await api(baseUrl, '/api/bills/generate', { From cc0ff5d47a8bb2e0f774e46da90871b53cd74daf Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 21:00:41 -0400 Subject: [PATCH 4/7] no-mistakes(document): Documented bill synchronization and legacy item recalculation --- docs/API.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index bbce36b6..3ce5d86e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -532,6 +532,10 @@ For a retry-safe append, send an `Idempotency-Key` header containing 1–128 pri } ``` +When an unpaid bill already exists for the order, appending items also +synchronizes its `subtotal` and recalculates its total, balance, tax, discount, +service-charge, and round-off fields. + --- ### PATCH `/api/order-items/:id/status` @@ -552,8 +556,9 @@ Update item status (KDS workflow). ## Order Discounts -Both discount endpoints recalculate order totals from non-terminal order items; -cancelled, voided, `void_adjustment`, and refunded items are excluded. +Both discount endpoints recalculate order totals from active order items, +including legacy items whose status is `NULL`; cancelled, voided, +`void_adjustment`, and refunded items are excluded. ### PATCH `/api/orders/:id/discount` Apply order-level discount. From 67ae4cd1b6c7b3c40c82eaf77aa3f27d06b72fd2 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 21:12:19 -0400 Subject: [PATCH 5/7] fix: sync bill subtotal after item discounts --- main/routes/orders.ts | 4 +-- tests/integration-bill-reconciliation.test.ts | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/main/routes/orders.ts b/main/routes/orders.ts index 0eea020a..4ba6fd7b 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -1519,8 +1519,8 @@ router.patch('/:id/items/:itemId/discount', orderWriteRateLimit, requireRole(... const pack = getActiveCountryPack(tenantInfo.country); const { total: billTotal, adjustment: billRoundOff } = applyPayableRounding(orderTotal, pack, currency); const newBillBalance = Math.max(0, billTotal - (existingBill.paid_amount || 0)); - db.prepare(`UPDATE bills SET total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, round_off = ?, updated_at = ? WHERE id = ?`) - .run(billTotal, newBillBalance, taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), taxRollup.snapshotJson, newOrderDiscount, order.service_charge || 0, billRoundOff, now(), existingBill.id); + db.prepare(`UPDATE bills SET subtotal = ?, total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, round_off = ?, updated_at = ? WHERE id = ?`) + .run(orderSubtotal, billTotal, newBillBalance, taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), taxRollup.snapshotJson, newOrderDiscount, order.service_charge || 0, billRoundOff, now(), existingBill.id); } recordOrderAudit(db, { diff --git a/tests/integration-bill-reconciliation.test.ts b/tests/integration-bill-reconciliation.test.ts index d6a43399..0c2c4f4a 100644 --- a/tests/integration-bill-reconciliation.test.ts +++ b/tests/integration-bill-reconciliation.test.ts @@ -180,6 +180,42 @@ async function main() { const syncedBill = billAfterAdd.data.bill; assertEqual(syncedBill.total, orderAfterAdd.total, `bill total (₹${syncedBill.total}) matches order total (₹${orderAfterAdd.total}) after adding items`); + // ═══ Scenario C: Item Discount After Bill Was Generated ═══ + console.log('\n─── Scenario C: Item Discount After Bill ───'); + const orderC = await api(baseUrl, '/api/orders', { + method: 'POST', + body: { + type: 'takeaway', + items: [{ product_id: 'prod-recon-1', quantity: 1 }], + }, + headers: authHeader, + }); + assertEqual(orderC.status, 201, 'order C created'); + const orderIdC = orderC.data.order.id; + const itemC = orderC.data.order.items[0]; + + const billC = await api(baseUrl, '/api/bills/generate', { + method: 'POST', + body: { order_id: orderIdC }, + headers: authHeader, + }); + assertEqual(billC.status, 201, 'bill C created before item discount'); + + const itemDiscountRes = await api(baseUrl, `/api/orders/${orderIdC}/items/${itemC.id}/discount`, { + method: 'PATCH', + body: { discount_type: 'percentage', discount_value: 10 }, + headers: authHeader, + }); + assertEqual(itemDiscountRes.status, 200, 'item discount applied after bill creation'); + const orderAfterItemDiscount = (await api(baseUrl, `/api/orders/${orderIdC}`, { headers: authHeader })).data.order; + const billAfterItemDiscount = await api(baseUrl, `/api/bills/${billC.data.bill.id}`, { headers: authHeader }); + assertEqual(billAfterItemDiscount.status, 200, 'existing bill remains readable after item discount'); + assertEqual( + billAfterItemDiscount.data.bill.subtotal, + orderAfterItemDiscount.subtotal, + `bill subtotal (₹${billAfterItemDiscount.data.bill.subtotal}) matches order subtotal (₹${orderAfterItemDiscount.subtotal}) after item discount`, + ); + } finally { server.close(); closeDatabase(); From 0decc3181bcead1fd313732af07c629c777d64aa Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 21:21:39 -0400 Subject: [PATCH 6/7] no-mistakes(review): fix: deduplicate bill discount totals via shared calculator --- main/routes/bills.ts | 26 +++++++------------------- tests/integration-tax.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/main/routes/bills.ts b/main/routes/bills.ts index d204c75e..1572f236 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -25,6 +25,7 @@ import { scaleTaxSnapshots, } from '../services/tax'; import { applyPayableRounding } from '../services/tax-engine'; +import { calculateOrderTotals } from '../services/orders'; import { sendEvent } from '../services/telemetry'; import { getCurrencyFractionDigits, @@ -2171,25 +2172,12 @@ router.post('/:id/applyDiscount', requireRole(...ROLE_ACCESS.ownerManager), (req discountAmount = Number(discountAmount.toFixed(decimals)); // Derive undiscounted tax basis directly from active items to prevent compounding discounts. - const activeItems = db.prepare( - "SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded')" - ).all(bill.order_id) as any[]; - let itemTaxAmount = 0; - let itemExclusiveTax = 0; - const itemBreakdowns: any[][] = []; - const itemSnapshots: (string | null)[] = []; - for (const item of activeItems) { - const taxAmount = item.tax_amount || 0; - itemTaxAmount += taxAmount; - if (item.tax_type !== 'inclusive') itemExclusiveTax += taxAmount; - if (item.tax_breakdown) { - try { - const breakdown = JSON.parse(item.tax_breakdown); - if (Array.isArray(breakdown)) itemBreakdowns.push(breakdown); - } catch { } - } - itemSnapshots.push(item.tax_snapshot || null); - } + const { + totalTax: itemTaxAmount, + exclusiveTax: itemExclusiveTax, + allTaxBreakdowns: itemBreakdowns, + allTaxSnapshots: itemSnapshots, + } = calculateOrderTotals(db, bill.order_id); const discountedSubtotal = Math.max(0, bill.subtotal - discountAmount); const taxRatio = bill.subtotal > 0 ? discountedSubtotal / bill.subtotal : 1; diff --git a/tests/integration-tax.test.ts b/tests/integration-tax.test.ts index 3a5b5e87..a3f18732 100644 --- a/tests/integration-tax.test.ts +++ b/tests/integration-tax.test.ts @@ -306,6 +306,21 @@ async function main() { : nullStatusDiscountRes.data.order.tax_snapshot; assertEqual(nullStatusSnapshot.length, 1, 'tax snapshot retains the NULL-status item'); + const nullStatusBillRes = await api(baseUrl, '/api/bills/generate', { + method: 'POST', + body: { order_id: nullStatusOrderId }, + headers: authHeader, + }); + assertEqual(nullStatusBillRes.status, 201, 'bill created for legacy NULL-status order'); + const nullStatusBillDiscountRes = await api(baseUrl, `/api/bills/${nullStatusBillRes.data.bill.id}/applyDiscount`, { + method: 'POST', + body: { type: 'percentage', value: 10 }, + headers: authHeader, + }); + assertEqual(nullStatusBillDiscountRes.status, 200, 'bill discount applied with legacy NULL item status'); + assertEqual(nullStatusBillDiscountRes.data.bill.tax_amount, 22.5, 'bill discount retains tax from the NULL-status item'); + assertEqual(nullStatusBillDiscountRes.data.bill.total, 472.5, 'bill total includes discounted NULL-status item tax'); + // ── Step 8: bill discount edits must use item tax, not prior bill tax ── console.log('\n8. Edit a bill discount — tax must not compound on the prior edit'); const mixedBillRes = await api(baseUrl, '/api/bills/generate', { From 028158495329071b801a8e02c77c84dd3233b155 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 15 Sep 2026 21:32:24 -0400 Subject: [PATCH 7/7] no-mistakes(document): Document item-level bill synchronization --- docs/API.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API.md b/docs/API.md index 3ce5d86e..7ea83d72 100644 --- a/docs/API.md +++ b/docs/API.md @@ -609,6 +609,10 @@ Apply item-level discount. **Validations:** Same as order-level discount. +When an unpaid bill already exists for the order, applying an item-level +discount also synchronizes its `subtotal` and recalculates its total, balance, +tax, discount, service-charge, and round-off fields. + --- ## Bills