Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
Expand Down Expand Up @@ -604,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
Expand Down
26 changes: 7 additions & 19 deletions main/routes/bills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions main/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion main/services/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let subtotal = 0;
let totalTax = 0;
Expand Down
45 changes: 45 additions & 0 deletions tests/integration-bill-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -171,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();
Expand Down
88 changes: 77 additions & 11 deletions tests/integration-tax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -255,11 +277,55 @@ 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');

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', {
method: 'POST',
body: { order_id: mixedOrderId },
body: { order_id: itemDiscountOrderId },
headers: authHeader,
});
assertEqual(mixedBillRes.status, 201, 'bill generated for discounted categorized order');
Expand Down
Loading