Skip to content

fix(bills): bind validated customer and award eligible cashback on zero-balance settlement #1254

Closed

Description

@khaira777

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:

  1. Create an order and generate an unpaid bill (customer_id: null), then apply a 100% discount (or partial payment followed by discount so balance = 0).
  2. Settle the bill via POST /api/bills/:id/payment or POST /api/bills/:id/payments passing a valid customer_id.
  3. Observe that payment_status becomes 'paid', but bill.customer_id in both the HTTP 200 response and the SQLite bills row remains null, loyaltyPointsEarned is 0 (even when bill.total > 0 from an earlier partial payment), and payment_idempotency caches the unlinked response.

Root Cause

In main/routes/bills.ts:

  • preparePaymentBatch (lines 2911-2944) validates bodyCustomerId against bill.customer_id and orders.customer_id (enforcing customer_mismatch and customer_unavailable) and returns effectiveCustomerId.
  • The positive-payment branch (prepared.length > 0, lines 3537-3542) executes if (!bill.customer_id && effectiveCustomerId) UPDATE bills SET customer_id = ?, updated_at = ? WHERE id = ? before reading freshBill and computing calculateCashback(db, bill, effectiveCustomerId).
  • The zero-balance settlement branch (prepared.length === 0, lines 3393-3477) omits the UPDATE bills SET customer_id = ? statement, then reads freshBill.customer_id (null) at line 3422 and passes null to calculateCashback.

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_id via POST /api/bills/:id/payment or POST /api/bills/:id/payments updates bills.customer_id and returns the bound customer_id in the response.
  • Eligible loyalty cashback on zero-remaining-balance settlement (when freshBill.total > 0) is calculated and credited once in loyalty_ledger under the bound customer.
  • Idempotent replay via Idempotency-Key returns 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 selected customer_id.
  • Batch payment (POST /api/bills/:id/payments) zero-balance settlement with newly selected customer_id.
  • Zero-balance settlement after partial payment + discount (total > 0, balance = 0) with newly selected customer_id awards loyalty cashback once.
  • Idempotent replay and conflicting/missing customer rejection checks.

Related Historical Context & Considerations

Activity

  1. added a commit that references this issue on Oct 21, 2026
    9910f23
  2. khaira777 commented on Oct 21, 2026

    @khaira777
    ContributorAuthor

    Post-Merge Verification & Closure (N2)

    • Resolved via PR: fix(main): persist customer attribution on zero-balance bill settlement #1261
    • Reviewed Branch Head SHA: c6fdc6365e9d73371fb3cfbd2019c7c6ea378d95 (updated onto main as f6c43f525db6a83fe70bfc6472a072adf694863e)
    • Merged main Commit SHA: (see GitHub merge event above)
    • Root Cause: In applyPaymentBatch (main/routes/bills.ts), when a bill's remaining balance was already 0 (prepared.length === 0, e.g., after a 100% discount or prior payments covering the full balance) and a newly selected customer_id was supplied on settlement (POST /api/bills/:id/payment or POST /api/bills/:id/payments), the for (const item of prepared) loop did not execute, so UPDATE bills SET customer_id = ? was skipped before freshBill was re-read and calculateCashback(freshBill) ran.
    • Fix Summary: Added if (prepared.length === 0 && !bill.customer_id && effectiveCustomerId) immediately after the prepared loop inside the settlement transaction to persist effectiveCustomerId to bills.customer_id when status !== 'paid' and !hasExistingPayments, before re-reading freshBill and running calculateCashback(freshBill).
    • Regression Tests Verified: tests/bills-settlement-idempotency.test.ts (118/118 assertions passed, covering 100%-discounted zero-balance settlement with newly selected customer, idempotent replay, already-settled guard, and partial-prior-payment guard).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions