From one mover to N: scaling payouts.
A walkthrough of how a moving & storage platform refactored its synchronous, single-employee Stripe payout into an idempotent, queue-backed multi-employee payout — and the duplicate-charge race condition that forced the rewrite.
01 — Single-mover origin
One booking, one mover, one synchronous transfer.
BO UI
Backend
GQL proxy
Supplier Payments
awaits Stripe
Stripe API
Main DB
bookings · transfers
Payments DB
audit log
Worked because
One booking → one mover. A single payout per booking meant a single Stripe transfer, a single DB row, and a single UI request. Synchronous was simple and easy to reason about.
Stopped working when
We supported multiple movers per booking. Now the same UI could fire several transfer requests for the same booking — and the backend had no defense against two of them landing at the same moment.
02 — The race condition
Two requests, no atomicity, $100 orphaned in Stripe.
Request A
user double-clicks Pay
- Check existing transfer
transfer = null
- Create Stripe transfer
+$100 to mover_1
- Save to DB
transfer_A persisted
- Return success
request A done
t=0ms
Request B
2ms later
- Check existing transfer
transfer = null
- Create Stripe transfer
+$100 to mover_1
- Save to DB
transfer_B overwrites A
- Return success
both look fine
t=2ms
03 — Async redesign
Enqueue, return, let the workers chew.
Async path
- UI POSTs createBulkTransfers
{ bookingId, userIds? }
- Backend validates & upserts pending rows🔒 idempotent
unique (bookingId, userId)
- Enqueue N transfer jobs⚙ bullmq
one per mover, dedup key = userId
- Backend returns processing immediately⚡ async
no waiting on Stripe
- Workers pull jobs in parallel
transfer_worker × N
- Each worker hits Stripe & updates status
pending → processing → success
- UI polls or refreshes for resolution
partial success/failure handled
BullMQ workers
N transfer jobs. N workers pull in parallel. Each worker is idempotent — if it crashes, the job retries against the same dedup key.
transfers (Payments DB)
unique (bookingId, userId)The unique (bookingId, userId) index turns the race into a no-op: the second insert fails fast, the second job exits without touching Stripe. Workers process in parallel, the UI never blocks on the slowest mover, and partial failures isolate to the single transfer that broke instead of poisoning the whole batch.
04 — Phased rollout
Four phases, two services, zero customer-facing breakage.
- PHASE 01
Database & infrastructure
supplier-paymentsschema · redis · queue- ▢add userId, amount, version to transfer schema
- ▢compound unique index (bookingId, userId)
- ▢add processing status for optimistic UI updates
- ▢wire redis module for connection pooling
- ▢stand up BullMQ queue + worker module
- PHASE 02
API surface
supplier-paymentsgraphql · stripe service · migration- ▢createBulkTransfers(bookingId, userIds?) — userIds undefined = "Pay All"
- ▢getTransfersForBooking(bookingId) for N transfers per booking
- ▢retryTransfer(transferId) for failed-row recovery
- ▢atomic upsert on stripe-db service
- ▢migration to backfill userId on existing transfer rows
- PHASE 03
Backend integration
us-nest-backend-apigql proxy · job service · types- ▢GQL proxy layer exposes the new bulk + retry endpoints
- ▢FlexSuppliersJobService updated for transfers array
- ▢PaymentSummary type for UI rollups + backwards compat
- ▢dependent services migrated off the boolean transfer signature
- PHASE 04
UI changes
us-nest-backend-api · clientmodal · status chips · jobs table- ▢per-employee status component — grey · amber · emerald · red
- ▢PaymentModal: "Pay All" → "Pay Rest" with already-paid disabled
- ▢jobs table indicator + click-to-open detail modal
- ▢GQL schema additions for transfers array + PaymentSummary
- ▢partial success/failure display with retry per-row
05 — Payoff
Pay All. In parallel. With per-mover status.
Booking #BK-19284
Pay crew for completed move
Total payout
$1,800.00
Sarah Chen
Lead
Marcus Rivera
Mover
David Park
Mover
Lisa Wong
Driver
Tap Pay All to enqueue transfers for the unpaid crew.
Status legend
What changed
Pay All enqueues N jobs and returns immediately. Each row reflects its own worker's progress. Already-paid movers are disabled; failures isolate to a single row with a retry option.