Skip to content

Stripe + Escrow Audit — 2026-04-21 (B27)

Stripe + Escrow Audit — 2026-04-21 (B27)

Section titled “Stripe + Escrow Audit — 2026-04-21 (B27)”

Status: research / gap analysis Scope: verify Stripe test env + escrow flow wired end-to-end in the current codebase. Driven by user report that Stripe/escrow was not visibly integrated.

#ItemStatusEvidence
1Stripe TEST keys in envPASS (placeholders)apps/api/.env.example:13-14sk_test_xxx / whsec_xxx. .env.deploy.example:16 → real pk_test_51TMQRr… (publishable, OK to commit).
2capture_method: 'manual' for escrow holdFAILapps/api/src/modules/payments/stripe.service.ts:56-64 — PaymentIntent created without capture_method, defaults to automatic → funds auto-capture at confirmation. No Stripe-level hold. “Escrow” is a DB column only.
3Webhook signature verified via rawBodyPASSapps/api/src/modules/webhooks/stripe-webhook.controller.ts:35-46 uses req.rawBody + stripeService.verifyWebhookSignaturestripe.webhooks.constructEvent (stripe.service.ts:117-122).
4ACCEPTED → PaymentIntent authorizeFAILbooking.service.ts:115-148 acceptBooking only updates status. Never calls stripe.createPaymentIntent. No production code path invokes StripeService.createPaymentIntent.
5IN_PROGRESS / COMPLETED → captureFAILstartJob (186-208) + completeJob (210-242) never call capturePaymentIntent. capturePaymentIntent has zero production callers (unit test only).
6CONFIRM → payout to connected accountPARTIALconfirmCompletion (244-273) flips escrowStatus: RELEASED in DB. No Stripe transfer. Connect transfer_data.destination would auto-split at capture — but capture never fires (see #2, #5).
7Refund on CANCELLEDPARTIALcancel() (363-428) calls stripe.refundPaymentIntent — but only when process.env.NODE_ENV !== 'production' (line 383). Production refunds explicitly disabled. Also relies on stripePaymentIntentId which is only set via checkout.session.completed webhook (webhook.service.ts:124-159) — a Checkout flow that is never triggered.
8FE PaymentSheet renders during bookingFAILapps/mobile/lib/hooks/useBookingFlow.ts has correct initPaymentSheet/presentPaymentSheet wiring — but grep shows zero callers. Active screen apps/mobile/app/book/[professionalId].tsx uses useCreateBooking (plain POST, no payment). No <StripeProvider> in app root. BE route /bookings/create-intent referenced by useCreatePaymentIntent does not exist.
9Test cards documentedFAILNo reference to 4242 4242 4242 4242 anywhere in repo.
10Smoke test: book → Send → PaymentSheetFAILCannot pass — no PaymentSheet in active flow (see #8).

Escrow is cosmetic — a Booking.escrowStatus column (HELD/RELEASED/REFUNDED/DISPUTED) advanced by business-logic status changes. No funds are ever held or moved by Stripe.

StripeService is fully implemented but disconnected from the booking flow. Stripe Connect onboarding (PaymentsController.createOnboardingLink) + Identity verification webhooks do work end-to-end.

Stripe surface wired:

  • ~30% for onboarding + identity verification (works)
  • 0% for payments / escrow (disconnected)
PItemOwnerTarget
P0Demo-day messaging: do NOT demo Stripe PaymentSheet. Show Connect onboarding + trust tier progress only.Demo scripttonight
P1Add BE route POST /bookings/create-intent calling stripe.createPaymentIntent with capture_method: 'manual'. Return {clientSecret, paymentIntentId}. Wire to useCreatePaymentIntent.BEpost-demo
P1Wire useBookingFlow into active booking screen; add <StripeProvider publishableKey={EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY}> to apps/mobile/app/_layout.tsx.FEpost-demo
P1In booking.service.ts::acceptBooking, persist stripePaymentIntentId to Booking; call stripe.capturePaymentIntent from completeJob (capture on pro marking done, release on consumer confirm).BEpost-demo
P1Remove NODE_ENV !== 'production' guard around refund in cancel(). Either explicit feature flag or remove. Uncontrolled prod path today.BEpost-demo
P2Document test cards in docs/testing.md Stripe section: 4242… success, 4000 0000 0000 9995 decline, 4000 0025 0000 3155 3DS.Docspost-demo
P2Add webhook handler for payment_intent.amount_capturable_updated (fires when manual-capture PI authorized). Set escrowStatus: HELD from webhook, not booking create.BEpost-demo
P2Add EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_… placeholder comment to apps/api/.env.example (cross-ref FE env).Docspost-demo

For tonight’s cofounder demo, frame the payment story as:

  1. “Stripe Connect onboarding is wired — pros create a Connect account, complete KYC, upload ID via Stripe Identity. This is the hard regulatory part and it works.”
  2. “Escrow lifecycle is modeled in the DB (HELDRELEASED / REFUNDED / DISPUTED) and drives booking state transitions.”
  3. “PaymentSheet wiring (consumer checkout) is the next sprint — we wanted the legal/compliance piece solid first.”

This is truthful and avoids promising something the code doesn’t do.

  • apps/api/src/modules/payments/stripe.service.ts
  • apps/api/src/modules/payments/payments.controller.ts
  • apps/api/src/modules/webhooks/stripe-webhook.controller.ts
  • apps/api/src/modules/webhooks/stripe-webhook.service.ts
  • apps/api/src/modules/booking/booking.service.ts
  • apps/mobile/app/book/[professionalId].tsx
  • apps/mobile/lib/hooks/useBookingFlow.ts
  • apps/mobile/lib/hooks/use-bookings.ts
  • apps/api/.env.example
  • .env.deploy.example
  • prisma/schema.prisma (EscrowStatus enum, line 417)

Related: docs/decisions/0006-stripe-connect-express.md — ADR has no divergence; it described intent, not implementation-completeness.