fix(billing): lock the invoice row before recomputing amount_paid #477
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/PMS-695-lock-invoice-on-payment"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Concurrent payments against one invoice lost updates.
create_paymentreadtotal, amount_paidwith no row lock, computed the new absolute values in Rust, and wrote them back; under READ COMMITTED two concurrent requests both read the pre-paymentamount_paid, so whichever committed last discarded the other. A 400.00 and a 600.00 payment against a 1000.00 invoice both persisted theirpaymentsrows but left the invoice atamount_paid = 600.00. The same race defeated the PMS-194 overpayment guard in the other direction: two 700.00 payments both passed700 <= 1000and the invoice ended up with 1400.00 of payments recorded against it.delete_paymentshared the shape and lost one side of a delete racing a create.create_paymentnow takesSELECT ... FOR UPDATEon the invoice before it inserts the payment row, so the lock covers the whole read-modify-write and an overpayment rejection no longer has to unwind an inserted row. Both paths then call one sharedrecompute_invoice_payment_statehelper that derivesamount_paidfromSUM(payments.amount)and recomputesbalance_due/status/paid_atfrom it in a single statement, which makesinvoices.amount_paid = SUM(payments.amount)true by construction and removes the duplicated status ladder plus the(prior_paid - amount).max(ZERO)clamp that only existed to paper over the race. The zero-payments case still lands onsent, identical to the ladder it replaces.update_invoiceis the third writer of the same columns: it rewritesbalance_dueandstatusfrom a pre-transaction read ofamount_paid, so it takes the same row lock in the same order and re-reads both under it, rather than writing a stale snapshot back over a payment that committed in between.Three integration tests in
tests/billing.rscover it. The two concurrency tests hold the race window open deterministically: a third transaction takes the invoice row lock, both payment requests are sent and park against it, and the lock is released only once both are in flight. Without that the handlers finish faster than the second request arrives and never overlap. Against the pre-fix service both fail with exactly the reported symptoms (amount_paid = 400.00instead of 1000.00; both 700.00 payments returning 200).#PMS-695