decision
ADR-0012: Stripe Integration — Customer-OAuth-Only in V1
ADR-0012 (**Billing half Accepted** (2026-07-06, `ai-engineer` → `ceo-blair`; implementation spec: [specs/2026-07-06-stripe-pro-billing-design.md](../superpowers/specs/2026-07-06-stripe-pro-billing-design.md), #203) — staging build cleared; **Ally Roger / counsel billing+PII sign-off remains the prod go-live gate.** The Stripe **data-access OAuth** half stays **Deferred** (post-V1). See the Ratification amendment below., 2026-05-14): Stripe Integration — Customer-OAuth-Only in V1.
▶ Watch the 1:20 summary — ADR-0012 — Stripe Integration — Customer-OAuth-Only in V1, explained
Status: Billing half Accepted (2026-07-06, ai-engineer → ceo-blair; implementation spec: specs/2026-07-06-stripe-pro-billing-design.md, #203) — staging build cleared; Ally Roger / counsel billing+PII sign-off remains the prod go-live gate. The Stripe data-access OAuth half stays Deferred (post-V1). See the Ratification amendment below.
Date: 2026-05-14
Deciders: Seth (Lead Architect), Blair (CEO), Ally Roger (legal / billing sign-off)
Context
The SPEC (§5.5 cost, §8.1 data sources, §7.4 billing) describes two Stripe integration paths for MemberIntel V1:
- Stripe Connect — for MP customers already on the Caseproof platform, where Connect onboarding gives MemberIntel access to the customer’s Stripe data (revenue, churn, dunning metrics).
- Stripe OAuth into the customer’s own Stripe account — for non-Connect customers who authorize MemberIntel to read their Stripe data.
The SPEC flags this as Open Q4: “Stripe Connect vs. customer-OAuth split — the scope of OAuth flow depends on this.” The Phase 1 friction-points document adds that the Stripe integration approach needs Ally Roger sign-off.
V1 also requires a billing flow for the Pro tier ($29/mo per account — see the Ratification amendment; the original “per site” was superseded by the 2026-07-06 pricing decision, SPEC §5.4). Caseproof already has a Stripe account for billing. The question is how to integrate Stripe for both billing (subscriptions) and data access (reading the customer’s own Stripe metrics).
The SPEC’s cost discipline mandate (§5.5) requires server-side enforcement of tier-based quotas. ADR-0001 already established check_and_consume in entitlement/service.py as the single enforcement point. The billing integration must feed tier changes into that entitlement service.
Decision
V1 uses Stripe Checkout for Pro subscriptions — not Stripe Connect. The integration is customer-OAuth-only for two distinct concerns:
-
Billing (subscription management): MemberIntel creates a Stripe Customer and a Checkout Session when a Free user upgrades to Pro. Stripe handles card collection, recurring billing, failed-payment dunning (SPEC §7.4 US-B3: 3 failures then 7-day grace then downgrade). Webhooks propagate subscription state changes back to MemberIntel.
-
Stripe data access (revenue/churn metrics): V1 uses customer-OAuth into the customer’s own Stripe account — the same OAuth flow the SPEC describes for non-Connect customers. Customers already on the Caseproof platform (with existing Connect authorizations for other Caseproof products) will also go through this MemberIntel-specific OAuth, not a Connect-driven data pull. This avoids the V1 dependency on Connect onboarding, platform liability, and marketplace commission structure.
Webhook-driven entitlement updates. Stripe webhooks (checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed) write to a stripe_events_processed table (idempotency key: Stripe event ID). The handler for each event updates the users.tier column — and that is the entire entitlement change: check_and_consume (ADR-0001) reads TIERS[tier] live, so flipping the column changes caps immediately. (Corrected 2026-07-06: there is no “refresh quotas” call — an earlier draft said so; entitlement is a live read, not a cached value to refresh.) No separate billing-aware code paths.
Schema shape:
stripe_customers— mapsuser_idtostripe_customer_id. Created at first Checkout Session.stripe_subscriptions— mapsstripe_customer_idtostripe_subscription_id,status,current_period_start/end,tier. Written by webhook handler.stripe_events_processed— idempotency log. Columns:stripe_event_id(PK),event_type,processed_at. Prevents double-processing on webhook retry.
Customer-OAuth flow for Stripe data access is deferred to a post-V1 data-ingestion slice. The SPEC’s Phase 1 scope includes “Stripe Connect query + Stripe OAuth for non-Connect customers” (§13), but the billing integration is the critical-path dependency for the Pro tier. The data-access OAuth is separable and ships when the MP-sync pipeline is ready — Stripe metrics without MP membership data are of limited value.
V1.5 will evaluate Stripe Connect for per-customer billing if the product needs marketplace-style onboarding, commission splits, or automatic provisioning for Caseproof platform customers. Connect is not rejected — it’s deferred.
Consequences
Positive:
- Fastest path to a working billing flow. Stripe Checkout is a hosted page — no PCI scope, no card-input UI to build, no custom dunning logic.
- Single Stripe account (Caseproof’s existing relationship). No Connect onboarding, no platform agreement, no marketplace commission.
- Webhook + idempotency key pattern is well-tested; Stripe guarantees at-least-once delivery and the
stripe_events_processedtable makes processing exactly-once. - Tier changes propagate to
entitlement/service.pythrough the samecheck_and_consumepath — no billing-specific bypass. - Deferring Connect keeps V1’s legal surface small (no platform agreement, no commission structure, no onboarding liability).
Negative / costs:
- Connect customers don’t get automatic data access in V1 — they must separately authorize MemberIntel’s Stripe OAuth for data read. This is a small UX friction for the subset of customers who are both on the Caseproof platform and MemberIntel Pro users.
- Stripe Checkout’s hosted page means limited UI customization for the upgrade flow. The two-click upgrade (SPEC US-B1) is “click upgrade → confirm on Stripe → redirect back” — two clicks on our side, but the Stripe page is between them.
- Webhook delivery is asynchronous. There is a window (typically seconds, up to minutes on retry) between a successful checkout and the entitlement update. The user may see Free-tier behavior for that window. Mitigation: the Checkout redirect can include a
?session_idparameter that the frontend can poll until the webhook processes. - Deferring Connect means V1 cannot offer automatic per-customer provisioning for Caseproof platform users. Each customer relationship is direct between MemberIntel and the subscriber.
Mitigations:
- The Checkout redirect includes
session_id; the frontend polls/api/v1/billing/checkout-status?session_id=...which returns{ "status": "active" }once the webhook has processed. This closes the entitlement-propagation gap without polling Stripe directly. - For Connect customers, the V1.5 evaluation will assess whether the UX friction of separate OAuth is material enough to warrant Connect’s platform agreement overhead.
- The
stripe_events_processedtable doubles as an audit log — useful for support and for the monthly cost-per-cohort dashboard (SPEC §5.5). - Dunning logic (SPEC §7.4 US-B3) is handled entirely by Stripe’s Smart Retries + our webhook handler for
invoice.payment_failed. No custom dunning code in V1.
Alternatives considered
- Stripe Connect (marketplace model) — rejected for V1. Connect requires a platform agreement, onboarding flow, commission structure, and marketplace liability. It solves the data-access question for Caseproof platform customers but introduces a billing-complexity and legal-surface cost that V1 doesn’t need. Deferred to V1.5 evaluation.
- Direct card processing (Stripe Elements / Payment Intents) — rejected: brings PCI scope, custom card-input UI, dunning logic, and failed-payment retry code. Stripe Checkout handles all of this. SPEC §7.4 US-B1 demands “two clicks, card on file, immediate billing” — Checkout delivers this with zero custom billing code.
- Third-party billing (Paddle, Lemon Squeezy) — rejected: Caseproof has an existing Stripe relationship; adding a second payment processor increases operational surface with no benefit in V1.
- Billing-agnostic entitlement (tier set by admin, no payment link) — rejected: the SPEC mandates self-serve upgrade/downgrade (US-B1, US-B2). A manual tier-provisioning system doesn’t meet the product requirement.
- Polling Stripe for subscription state instead of webhooks — rejected: polling introduces latency and API rate-limit consumption. Webhooks are near-real-time and the idempotency-table pattern is proven.
Ratification amendment (2026-07-06): billing half Accepted (#203)
The billing (Pro subscription) half of this ADR is ratified to Accepted —
ai-engineer APPROVE-WITH-CHANGES → ceo-blair APPROVE-WITH-CONDITIONS. Implementation
spec: specs/2026-07-06-stripe-pro-billing-design.md.
Staging build is cleared; counsel/Ally Roger billing+PII sign-off remains the prod
go-live gate. The Stripe data-access OAuth half stays Deferred (post-V1).
Decisions + corrections folded at ratification:
- Pricing: per-ACCOUNT Pro, $29/mo, account-level entitlement (
ceo-blair). One
subscription unlocks Pro for the account — matchesusers.tier(single source of truth)
and keeps the Free→Pro conversion path frictionless. Per-site/seat pricing is a V1.5
question (Checkoutquantityis separable, no entitlement-model change). Supersedes the
original “$29/mo per site.” Upgrade copy: “$29/month, per account, cancel anytime, served
through period end” — no per-site, no implied annual lock-in; dunning downgrades give a
clear notice, never a silent drop. - Entitlement =
users.tieronly (no “refresh quotas” — corrected above). - Webhook idempotency by PK-insert, not check-then-act.
INSERT … ON CONFLICT DO NOTHINGonstripe_events_processed; 0 rows affected → no-op. Closes the concurrent
duplicate-delivery TOCTOU race. - Single-transaction atomicity. The idempotency insert,
users.tierupdate,
stripe_subscriptionsupsert, and audit rowflush()andcommit()once — no
two-commit gap. A passing test proving duplicate-event=no-op + single-txn is a prod
condition. - Audit actor for system-originated
user.tier_set. A webhook has no admin actor and
must not fabricate one; makeadmin_user_idnullable for system events and carry
source: "stripe_webhook"in the JSONB payload. - JWT-
tierstaleness window (consequence): the access token carriesuser.tier, so a
mid-session upgrade shows stale free-tier UX until the token refreshes — enforcement is
unaffected (check_and_consumereads the DB). No server-side authz decision trusts the
JWTtierclaim over the DB. - Known gap: missed-webhook divergence — no periodic reconciliation in V1; the handler
logs a discrepancy (fail-visible), and a reconciliation sweep is a named board follow-up.
The #291 register guard (register{tier:pro} → free; tier only elevates via a
confirmed webhook) stays green as a standing test.