01 · Integration map
Everything lives in the app/ workspace. Money is INTEGER cents throughout. The worker talks to Stripe with plain fetch (no SDK) and verifies webhook signatures asynchronously — the documented Workers pattern.
| Piece | Where | What it does |
|---|---|---|
| Config gate | app/worker/stripe.ts L69–71 | stripeConfigured(env) — true iff STRIPE_SECRET_KEY is set. Flips the whole rail between fixture and live modes. |
| Signature verify | app/worker/stripe.ts L103–124 | verifyStripeSignature — HMAC check of Stripe-Signature against STRIPE_WEBHOOK_SECRET. |
| Customer reuse | app/worker/stripe.ts L154–169 | Creates/persists users.stripe_customer_id before Checkout so Checkout + Portal share one Customer (PR #123 fix). |
| Hosted Checkout | app/worker/stripe.ts L199–242 | createCheckoutSession — amount-only wallet top-up session; returns mode: "stripe_live" + hosted URL. |
| Billing Portal | app/worker/stripe.ts L246–275 | createPortalSession — card management is portal-only; no local card storage. |
| Webhook parse + credit | app/worker/stripe.ts L294–371 | Accepts checkout.session.completed / payment_intent.succeeded; credits the wallet ledger idempotently. |
| HTTP routes | app/worker/routes/stripe.ts L18/L37/L53 | POST /wallet/checkout · POST /wallet/portal · POST /webhooks/stripe (all under /api/v1). |
| Deposit gate | app/worker/routes/payments.ts L31 | When Stripe is configured, the fixture deposit returns 409 stripe_checkout_required pointing at /wallet/checkout. |
| Migrations | app/migrations/ | 0010_payments.sql · 0016_stripe_events.sql (processed_stripe_events) · 0018_stripe_customer.sql · 0023_normalize_negative_held_balances.sql. |
| Client | app/src/lib/api/payments.ts L63/L69 · app/src/pages/Billing.tsx L122–125, L177 | Billing catches the 409, calls walletCheckout, redirects to hosted Checkout; handles ?checkout=success|cancelled returns idempotently (PR #133 guard). |
02 · Production state
soldi workerwrangler secret list shows STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, SESSION_SECRET, FHC_INGEST_SECRET (set by Cam 2026-07-03, test mode, "Soldi sandbox" account).
Endpoint https://app.soldi.cc/api/v1/webhooks/stripe delivers 200 OK; manual resend also 200 and correctly replay-ignored.
Gate inversion, hosted checkout deposit, D1 deposit/event rows, resend idempotency, portal Customer reuse, and a Stripe-funded lead buy — all browser/D1 proven.
Historical duplicate test-mode Customer for demo@soldi.cc (pre-PR#123). Cam decision below. App-side persisted id: cus_UouKZd8WGskgex — do not rotate it.
Source of truth: artifacts/soldi-completion-2026-07/COMPLETION_TRACKER.md rows P1 (GREEN) and P2 (IN_PROGRESS), with receipts under artifacts/soldi-completion-2026-07/shots/. Live QA re-probe 2026-07-06: POST /api/v1/payments/deposit → 409 stripe_checkout_required; POST /api/v1/wallet/checkout → mode:"stripe_live" + checkout.stripe.com URL.
03 · How to operate
Add funds (buyer wallet top-up)
- Stripe absent (local dev, no secrets): Billing's Add Funds hits
POST /payments/deposit→ instant fixture credit. - Stripe configured (production): the same call returns
409 stripe_checkout_required; the client callsPOST /wallet/checkoutand redirects to hosted Checkout. Webhook credits the wallet; the return URL/billing?checkout=successrefreshes once and cleans the query.
Webhook idempotency
Every funding event claims a row in processed_stripe_events via INSERT … ON CONFLICT(event_id) DO NOTHING; a replay/resend that changes 0 rows is acknowledged but credits nothing. Safe to resend from the dashboard at any time.
Card management + refunds
Cards: POST /wallet/portal → Stripe Billing Portal (the local POST /payment-methods is 410-gone). Refunds are wallet-ledger refunds, not card refunds: buyers file via RefundModal; admins decide via GET /admin/refunds + POST /admin/refunds/:id/decide (app/worker/routes/admin-refunds.ts L84/L114; replay of a decision 409s).
bunx wrangler secret list --config app/wrangler.jsonc bunx wrangler secret put STRIPE_SECRET_KEY --config app/wrangler.jsonc bunx wrangler secret put STRIPE_WEBHOOK_SECRET --config app/wrangler.jsonc
04 · Testing recipes
Test mode only. Never complete a live-mode charge from QA. Do not create new Stripe Customers for the demo account — reuse is the invariant under proof.
- Test cards:
4242 4242 4242 4242(success) ·4000 0000 0000 9995(decline) ·4000 0025 0000 3155(3DS challenge). Any future expiry / any CVC. - Dashboard resend: Developers → Webhooks → endpoint → pick the event → Resend. Expect 200 and no double credit (check
wallet_transactions). - Gate probe (no money moves):
curl -sL -c jar -X POST https://app.soldi.cc/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"demo@soldi.cc","password":"soldidemo"}'
# fixture path must 409 while Stripe is configured
curl -sL -b jar -X POST https://app.soldi.cc/api/v1/payments/deposit \
-H 'content-type: application/json' -d '{"amountCents":1000}'
# live gate: expect mode "stripe_live" + checkout.stripe.com URL
curl -sL -b jar -X POST https://app.soldi.cc/api/v1/wallet/checkout \
-H 'content-type: application/json' -d '{"amountCents":1000}'
An unvisited Checkout session simply expires — creating one is a safe, non-destructive probe.
05 · Open decision — P2 duplicate Customer
Stripe test mode holds two historical Customers for demo@soldi.cc; the older one predates the PR #123 reuse fix. The app persists and reuses cus_UouKZd8WGskgex. Post-fix proof shows no third Customer is ever created. Pick one:
Option A — delete the duplicate
- In the authenticated Stripe dashboard (test mode), search Customers for
demo@soldi.cc. - Delete only the customer that is not
cus_UouKZd8WGskgex. - Re-run the portal probe (
POST /wallet/portal) and confirm exactly one Customer remains → mark P2 GREEN in the tracker.
Option B — accept as documented artifact
- Say so explicitly; the tracker terminal becomes
PARKED_STRIPE_ACCEPTEDwith the decision packet (shots/p2-stripe-duplicate-decision-packet-20260703.txt) as the record.
Either option unblocks the final Zak wrap ("Text 2" — drafted in SHARE_WITH_ZAK.md, gated). Nothing else in the payment rail waits on this, and it does not block beta testers. Safe default if undecided: Option B posture. Beta note (recommendation, pending Cam's ratification): the rail stays in test mode for the beta — testers fund wallets with test cards; no real money moves. Full decision table: docs/plans/mvp-build-public-release/BETA_READINESS.md §5.
06 · Why not Clerk Billing
| Rail | Fee (US card) | Wallet top-ups | Escrow / payouts | Auth impact |
|---|---|---|---|---|
| Stripe (current) | 2.9% + $0.30 | Amount-only hosted Checkout — live today | Connect path documented (manual payouts, reserves) | None — PBKDF2 auth untouched |
| Clerk Billing | 2.9% + $0.30 + 0.7% (needs Stripe underneath anyway) | Plan-ID subscriptions only; no arbitrary-amount API | None | Billing subject must be Clerk Users/Orgs — would entangle or replace live auth |
Reversal condition: only if Clerk ships a real wallet/payments product (arbitrary amounts, escrow/payout primitives, non-Clerk billing subjects). None exists as of 2026-07. Full ADR: docs/content/DECISIONS.md § 2026-07-06. Note: the original "Clerk" idea may have conflated Clerk with Stripe Billing/Connect.
07 · Future — seller payouts via Stripe Connect
Gated on Decision #0 (docs/content/prd/SUPPLY_SIDE.md §3): lead marketplace vs principal/committed-offer. No money-out rails are built, and none should be until Cam decides — payout semantics and seller-facing status promises both hang on it.
- Documented path: Stripe Connect with manual payouts (platform controls timing) and connected-account reserves (hold-back for refund risk).
- When Decision #0 lands, spec first (SUPPLY_SIDE PRD), then Express onboarding → manual payout schedule → reserve policy.
08 · Curated Stripe docs
- Checkout overview · Checkout quickstart
- Webhooks — incl. async signature verification for Workers-style runtimes (Soldi already implements the fetch
httpClient+ async verify pattern) - Billing Portal · Refunds
- Connect · Manual payouts · Reserves
- Testing / test cards