Soldi · Stripe payments runbook · July 6, 2026

Stripe is live. Here is exactly how it works.

Operator + agent runbook for Soldi's production payment rail: the integration map, what is proven on the live soldi worker, how to operate and test it, the one open decision, and where the rail goes next. Decision of record: stay Stripe; Clerk Billing rejected — confirmed by Cam 2026-07-06 (docs/content/DECISIONS.md).

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.

PieceWhereWhat it does
Config gateapp/worker/stripe.ts L69–71stripeConfigured(env) — true iff STRIPE_SECRET_KEY is set. Flips the whole rail between fixture and live modes.
Signature verifyapp/worker/stripe.ts L103–124verifyStripeSignature — HMAC check of Stripe-Signature against STRIPE_WEBHOOK_SECRET.
Customer reuseapp/worker/stripe.ts L154–169Creates/persists users.stripe_customer_id before Checkout so Checkout + Portal share one Customer (PR #123 fix).
Hosted Checkoutapp/worker/stripe.ts L199–242createCheckoutSession — amount-only wallet top-up session; returns mode: "stripe_live" + hosted URL.
Billing Portalapp/worker/stripe.ts L246–275createPortalSession — card management is portal-only; no local card storage.
Webhook parse + creditapp/worker/stripe.ts L294–371Accepts checkout.session.completed / payment_intent.succeeded; credits the wallet ledger idempotently.
HTTP routesapp/worker/routes/stripe.ts L18/L37/L53POST /wallet/checkout · POST /wallet/portal · POST /webhooks/stripe (all under /api/v1).
Deposit gateapp/worker/routes/payments.ts L31When Stripe is configured, the fixture deposit returns 409 stripe_checkout_required pointing at /wallet/checkout.
Migrationsapp/migrations/0010_payments.sql · 0016_stripe_events.sql (processed_stripe_events) · 0018_stripe_customer.sql · 0023_normalize_negative_held_balances.sql.
Clientapp/src/lib/api/payments.ts L63/L69 · app/src/pages/Billing.tsx L122–125, L177Billing catches the 409, calls walletCheckout, redirects to hosted Checkout; handles ?checkout=success|cancelled returns idempotently (PR #133 guard).

02 · Production state

Secrets on the soldi worker

wrangler 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).

P1 GREEN
Dashboard webhook proven

Endpoint https://app.soldi.cc/api/v1/webhooks/stripe delivers 200 OK; manual resend also 200 and correctly replay-ignored.

P1 GREEN
B7 proof battery

Gate inversion, hosted checkout deposit, D1 deposit/event rows, resend idempotency, portal Customer reuse, and a Stripe-funded lead buy — all browser/D1 proven.

proven
P2 — one open row

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.

PENDING CAM

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/checkoutmode:"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 calls POST /wallet/checkout and redirects to hosted Checkout. Webhook credits the wallet; the return URL /billing?checkout=success refreshes 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).

secrets (repo root, .env CF token)never echo values
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 probes (demo account)-sL always · do not open the URL
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_ACCEPTED with 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

RailFee (US card)Wallet top-upsEscrow / payoutsAuth impact
Stripe (current)2.9% + $0.30Amount-only hosted Checkout — live todayConnect path documented (manual payouts, reserves)None — PBKDF2 auth untouched
Clerk Billing2.9% + $0.30 + 0.7% (needs Stripe underneath anyway)Plan-ID subscriptions only; no arbitrary-amount APINoneBilling 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

Companion artifact: Soldi MVP handoff dossier — full status board, open decisions, QA record, and this week's action list.