Client SaaS
TypeScript
Pull Request Merged
PR #202 merged: fix: harden payment flow against orphaned holds and webhook races
Summary
Plugs four money-handling gaps found in the payments audit:
- Cancelled jobs now release the Stripe card hold.
CancelPaymentexisted but was wired to nothing — cancelling a job left the client's manual-capture authorization sitting on their card until Stripe expired it (~7 days) and orphaned the local payment inAuthorized. All four cancellation paths (client cancel, admin cancel, series cancel, lapsed same-day close) now cancel any pending/authorized payment after commit. - Admin job cancel goes through
CancelJobPosting. Previously a raw status update: cleaners weren't notified, the accepted quote stayedAccepted, series status never rolled up, and payments weren't released. Submitted quotes now end upWithdrawn(consistent with client cancellation) rather thanCancelled. - Webhook hardening:
markCancellednow only transitions fromPending/Authorized, so a stale/replayedpayment_intent.canceledcan't flip aCapturedpayment toCancelled.- Event idempotency uses an atomic
Cache::addclaim instead of check-then-set (concurrent duplicate deliveries no longer race), and the claim is released if a handler throws so Stripe's retry can reprocess.
- Double-payout is now impossible at the DB level.
payouts.payment_idgets a unique constraint, andCreatePayouttakes alockForUpdateon the payment row and re-checks before creating the Stripe transfer — closing the race betweenCompleteJob,RetryPaymentCapture, andProcessWeeklyPayouts.
Deploy note: verified prod has no duplicate payment_id rows in payouts, so the unique-constraint migration is safe.
Tests
- New: payment-hold release on client cancel, admin cancel, and lapsed same-day close; captured payments untouched by cancellation; stale
payment_intent.canceledno longer clobbers captured payments; atomic claim/release idempotency. - Updated: admin cancel test (quotes now
Withdrawn),CancelJobPostinginstantiation via container.
+369
additions
-56
deletions
13
files changed