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.
Audit checklist — evidence per item
Section titled “Audit checklist — evidence per item”| # | Item | Status | Evidence |
|---|---|---|---|
| 1 | Stripe TEST keys in env | PASS (placeholders) | apps/api/.env.example:13-14 → sk_test_xxx / whsec_xxx. .env.deploy.example:16 → real pk_test_51TMQRr… (publishable, OK to commit). |
| 2 | capture_method: 'manual' for escrow hold | FAIL | apps/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. |
| 3 | Webhook signature verified via rawBody | PASS | apps/api/src/modules/webhooks/stripe-webhook.controller.ts:35-46 uses req.rawBody + stripeService.verifyWebhookSignature → stripe.webhooks.constructEvent (stripe.service.ts:117-122). |
| 4 | ACCEPTED → PaymentIntent authorize | FAIL | booking.service.ts:115-148 acceptBooking only updates status. Never calls stripe.createPaymentIntent. No production code path invokes StripeService.createPaymentIntent. |
| 5 | IN_PROGRESS / COMPLETED → capture | FAIL | startJob (186-208) + completeJob (210-242) never call capturePaymentIntent. capturePaymentIntent has zero production callers (unit test only). |
| 6 | CONFIRM → payout to connected account | PARTIAL | confirmCompletion (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). |
| 7 | Refund on CANCELLED | PARTIAL | cancel() (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. |
| 8 | FE PaymentSheet renders during booking | FAIL | apps/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. |
| 9 | Test cards documented | FAIL | No reference to 4242 4242 4242 4242 anywhere in repo. |
| 10 | Smoke test: book → Send → PaymentSheet | FAIL | Cannot pass — no PaymentSheet in active flow (see #8). |
Overall verdict
Section titled “Overall verdict”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)
Action items
Section titled “Action items”| P | Item | Owner | Target |
|---|---|---|---|
| P0 | Demo-day messaging: do NOT demo Stripe PaymentSheet. Show Connect onboarding + trust tier progress only. | Demo script | tonight |
| P1 | Add BE route POST /bookings/create-intent calling stripe.createPaymentIntent with capture_method: 'manual'. Return {clientSecret, paymentIntentId}. Wire to useCreatePaymentIntent. | BE | post-demo |
| P1 | Wire useBookingFlow into active booking screen; add <StripeProvider publishableKey={EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY}> to apps/mobile/app/_layout.tsx. | FE | post-demo |
| P1 | In booking.service.ts::acceptBooking, persist stripePaymentIntentId to Booking; call stripe.capturePaymentIntent from completeJob (capture on pro marking done, release on consumer confirm). | BE | post-demo |
| P1 | Remove NODE_ENV !== 'production' guard around refund in cancel(). Either explicit feature flag or remove. Uncontrolled prod path today. | BE | post-demo |
| P2 | Document test cards in docs/testing.md Stripe section: 4242… success, 4000 0000 0000 9995 decline, 4000 0025 0000 3155 3DS. | Docs | post-demo |
| P2 | Add webhook handler for payment_intent.amount_capturable_updated (fires when manual-capture PI authorized). Set escrowStatus: HELD from webhook, not booking create. | BE | post-demo |
| P2 | Add EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_… placeholder comment to apps/api/.env.example (cross-ref FE env). | Docs | post-demo |
Demo narrative workaround
Section titled “Demo narrative workaround”For tonight’s cofounder demo, frame the payment story as:
- “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.”
- “Escrow lifecycle is modeled in the DB (
HELD→RELEASED/REFUNDED/DISPUTED) and drives booking state transitions.” - “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.
Relevant file paths
Section titled “Relevant file paths”apps/api/src/modules/payments/stripe.service.tsapps/api/src/modules/payments/payments.controller.tsapps/api/src/modules/webhooks/stripe-webhook.controller.tsapps/api/src/modules/webhooks/stripe-webhook.service.tsapps/api/src/modules/booking/booking.service.tsapps/mobile/app/book/[professionalId].tsxapps/mobile/lib/hooks/useBookingFlow.tsapps/mobile/lib/hooks/use-bookings.tsapps/api/.env.example.env.deploy.exampleprisma/schema.prisma(EscrowStatus enum, line 417)
Related: docs/decisions/0006-stripe-connect-express.md — ADR has no divergence; it described intent, not implementation-completeness.