Repository navigation
fix(bills): bind validated customer and award eligible cashback on zero-balance settlement #1254
Description
Summary
When settling a bill whose remaining balance is 0 (prepared.length === 0 in applyPaymentBatch, e.g. after a 100% bill discount or kitchen delivery override) via POST /api/bills/:id/payment or POST /api/bills/:id/payments with a newly selected customer_id, the validated customer is not persisted to bills.customer_id and loyalty cashback calculation receives null instead of the effective customer ID.
Verified Reproduction
On 08187174bfa47f08154e2b9b64cdb17b0ef2c459:
- Create an order and generate an unpaid bill (
customer_id: null), then apply a 100% discount (or partial payment followed by discount sobalance = 0). - Settle the bill via
POST /api/bills/:id/paymentorPOST /api/bills/:id/paymentspassing a validcustomer_id. - Observe that
payment_statusbecomes'paid', butbill.customer_idin both the HTTP200response and the SQLitebillsrow remainsnull,loyaltyPointsEarnedis0(even whenbill.total > 0from an earlier partial payment), andpayment_idempotencycaches the unlinked response.
Root Cause
In main/routes/bills.ts:
preparePaymentBatch(lines2911-2944) validatesbodyCustomerIdagainstbill.customer_idandorders.customer_id(enforcingcustomer_mismatchandcustomer_unavailable) and returnseffectiveCustomerId.- The positive-payment branch (
prepared.length > 0, lines3537-3542) executesif (!bill.customer_id && effectiveCustomerId) UPDATE bills SET customer_id = ?, updated_at = ? WHERE id = ?before readingfreshBilland computingcalculateCashback(db, bill, effectiveCustomerId). - The zero-balance settlement branch (
prepared.length === 0, lines3393-3477) omits theUPDATE bills SET customer_id = ?statement, then readsfreshBill.customer_id(null) at line3422and passesnulltocalculateCashback.
Expected Behavior & Chosen Design
In applyPaymentBatch (main/routes/bills.ts), within the if (prepared.length === 0) branch, persist effectiveCustomerId to bills.customer_id when !bill.customer_id && effectiveCustomerId before re-reading freshBill and invoking calculateCashback, matching the positive-payment branch.
Acceptance Criteria
- Settling a zero-remaining-balance bill with a newly selected valid
customer_idviaPOST /api/bills/:id/paymentorPOST /api/bills/:id/paymentsupdatesbills.customer_idand returns the boundcustomer_idin the response. - Eligible loyalty cashback on zero-remaining-balance settlement (when
freshBill.total > 0) is calculated and credited once inloyalty_ledgerunder the bound customer. - Idempotent replay via
Idempotency-Keyreturns the cached response with the bound customer and does not duplicate loyalty awards. - Conflicting customer (
customer_mismatch) and non-existent customer (customer_unavailable) rejections remain enforced.
Required Regression Tests
- Single payment (
POST /api/bills/:id/payment) zero-balance settlement with newly selectedcustomer_id. - Batch payment (
POST /api/bills/:id/payments) zero-balance settlement with newly selectedcustomer_id. - Zero-balance settlement after partial payment + discount (
total > 0, balance = 0) with newly selectedcustomer_idawards loyalty cashback once. - Idempotent replay and conflicting/missing customer rejection checks.
Related Historical Context & Considerations
- Related PRs / Issue: fix(main): gate exact-balance discount auto-settlement on kitchen delivery status #1211, fix(billing): remediate loyalty re-award and zero-balance idempotency #1249, Bill discount endpoint permits invalid order state changes and lacks audit logging #1176
- Financial & Data Integrity Considerations: Aligns zero-balance settlement customer attribution and single-award loyalty cashback with the standard payment path inside
withTxn.
Activity
fix(main): persist customer attribution on zero-balance bill settleme…
khaira777 commentedon Oct 21, 2026
Post-Merge Verification & Closure (N2)
c6fdc6365e9d73371fb3cfbd2019c7c6ea378d95(updated ontomainasf6c43f525db6a83fe70bfc6472a072adf694863e)mainCommit SHA: (see GitHub merge event above)applyPaymentBatch(main/routes/bills.ts), when a bill's remaining balance was already0(prepared.length === 0, e.g., after a 100% discount or prior payments covering the full balance) and a newly selectedcustomer_idwas supplied on settlement (POST /api/bills/:id/paymentorPOST /api/bills/:id/payments), thefor (const item of prepared)loop did not execute, soUPDATE bills SET customer_id = ?was skipped beforefreshBillwas re-read andcalculateCashback(freshBill)ran.if (prepared.length === 0 && !bill.customer_id && effectiveCustomerId)immediately after thepreparedloop inside the settlement transaction to persisteffectiveCustomerIdtobills.customer_idwhenstatus !== 'paid'and!hasExistingPayments, before re-readingfreshBilland runningcalculateCashback(freshBill).tests/bills-settlement-idempotency.test.ts(118/118assertions passed, covering 100%-discounted zero-balance settlement with newly selected customer, idempotent replay, already-settled guard, and partial-prior-payment guard).