Knowledge > Architecture > Payment Flow Architecture
Payment Flow Architecture
Source of truth for all Stripe payment flows across the ChurchWiseAI portfolio.
The Principle
Nothing touches the database until checkout.session.completed fires.
If a customer abandons checkout, nothing exists in the database. If the card is declined, nothing exists. If payment succeeds, everything is created atomically by the Stripe webhook — church record, subscription record, identities, chatbot provisioning, welcome email — all in a single webhook handler.
This is the only safe pattern. Pre-payment DB writes create orphan records, send premature emails, and grant dashboard access before any money changes hands.
Payment-First Atomic Provisioning
All Checkout Flows — Status Audit
Seven checkout flows exist across three codebases. Four are correct. Three are being refactored.
| Flow | Product | Codebase | Checkout Route | Status |
|---|---|---|---|---|
| CWA Generic | Voice/Chat/Bundle (pricing page) | churchwiseai-web | api/stripe/checkout/route.ts | CORRECT |
| CWA Church Checkout | Voice/Chat/Bundle (dashboard upgrade) | churchwiseai-web | api/stripe/church-checkout/route.ts | CORRECT (creates shell pre-checkout, but church already exists) |
| CWA Embedded | Voice/Chat/Bundle (onboard embedded checkout) | churchwiseai-web | api/stripe/checkout-embedded/route.ts | CORRECT (reads existing church, no new writes) |
| SermonWise Pro | SermonWise ($19.95/mo) | churchwiseai-web | api/sermons/checkout/route.ts | CORRECT |
| ShareWiseAI | Social media SaaS ($19.95–$99.95/mo) | churchwiseai-web | api/social/checkout/route.ts | CORRECT |
| ITW Premium | IllustrateTheWord ($9.95/mo) | sermon-illustrations | api/stripe/checkout/route.ts | CORRECT |
| CWA Onboard | ChurchWiseAI new church signup | churchwiseai-web | api/onboard/route.ts | BROKEN — being refactored |
| PewSearch Pre-Checkout | PewSearch Premium/Pro Website | pewsearch/web | api/stripe/pre-checkout/route.ts | BROKEN — being refactored |
| PewSearch Checkout | PewSearch Premium/Pro Website (upgrade path) | pewsearch/web | api/stripe/checkout/route.ts | BROKEN — being refactored |
Webhook Handlers
Each codebase has its own webhook endpoint. They share the Stripe account but filter events by product metadata.
| Webhook Route | Handles | Key Event |
|---|---|---|
churchwiseai-web/api/stripe/webhook | CWA church plans (chat/voice/bundle), SermonWise Pro, ShareWiseAI, AI Starter Kit | checkout.session.completed |
pewsearch/web/api/stripe/webhook | PewSearch Premium, Pro Website | checkout.session.completed |
sermon-illustrations/api/stripe/webhook | ITW Premium | checkout.session.completed |
CWA tiers (chatbot_starter, chatbot_pro, etc.) are explicitly filtered OUT of the PewSearch webhook. PewSearch tiers are not registered on the CWA webhook.
Canonical Flow Diagram
This is the correct flow for any new checkout. All three broken flows must be refactored to match this pattern.
Customer fills form (name, email, church, plan)
|
v
Checkout Route (API)
- Validate inputs
- Build metadata object (all customer data)
- Create Stripe Checkout Session with metadata
- NO DB writes
- Return session URL (or clientSecret for embedded)
|
v
Customer completes payment on Stripe Checkout
|
+--- Card declined ──> Stripe shows error. Nothing in DB. Customer retries.
|
+--- Customer abandons ──> Nothing in DB. Clean state.
|
v
Stripe fires checkout.session.completed webhook
|
v
Webhook Handler (API)
1. Verify Stripe signature
2. Check webhook_events table for duplicate (idempotency)
3. Read ALL customer data from session.metadata
4. Create/find churches row
5. Create premium_churches row (status='active')
6. Create identities + admin role
7. Provision chatbot (organization_settings)
8. For voice plans: create church_voice_agents stub, alert founder
9. Send welcome email with magic link (3 retries)
10. Sync to MailerLite if marketing_opt_in=true
11. Create admin team member
12. Log event to webhook_events (marks as processed)
|
v
Return page polls for premium_churches record
- Polls every 2s for up to 30s
- Shows spinner during poll
- Redirects to admin dashboard on success
- Shows timeout message if record not found after 30s
Stripe Session Metadata Schema (Post-Refactor)
All customer data is passed via Stripe session metadata. The webhook reads this and creates DB records. No DB writes before checkout.
CWA Church Plans (new church onboard flow)
church_name — "Grace Community Church"
contact_name — "Pastor John Smith"
email — "john@gracecommunity.com"
phone — "+14155551234" (optional)
city — "Austin" (optional)
state — "TX" (optional)
country — "US" (optional)
tier — "starter_chat" | "pro_chat" | "suite_chat" | "starter_voice" | "pro_voice" | "starter_both" | "pro_both" | "suite_both"
marketing_opt_in — "true" | "false"
backup_name — "Jane Smith" (optional)
backup_email — "jane@gracecommunity.com" (optional)
backup_phone — "+14155554321" (optional)
existing_church_id — UUID (if matched to PewSearch directory, optional)
source — "churchwiseai" | "pewsearch"
PewSearch Plans (claim flow)
church_id — UUID (always set — PewSearch churches already exist)
name — "Pastor John Smith"
email — "john@gracecommunity.com"
role — "pastor" | "office_administrator" | etc.
tier — "premium" | "pro_website"
marketing_opt_in — "true" | "false"
source — "pewsearch"
B2C Plans (SermonWise, ShareWiseAI, ITW)
user_id — Supabase Auth UUID
product — "sermon_pro" | "social" | (ITW uses supabase_user_id instead)
tier — (social only) "pro" | "business" | "agency"
loyalty_discount — "true" (optional, when family discount applied)
discount_source — "sermon_pro" | "itw" | "social" (optional)
Existing Plans (CWA Generic / Church-Checkout)
church_id — UUID (church already exists in DB)
premium_id — UUID (premium_churches row already exists)
tier — plan key string
product — plan key string (generic checkout)
user_id — Supabase Auth UUID (generic checkout, if logged in)
Webhook Responsibilities — CWA Church Plans
This is the complete list of what activateChurch() in churchwiseai-web/api/stripe/webhook does on checkout.session.completed. After the refactor, the onboard route must stop doing any of this pre-checkout.
Current behavior (onboard route does steps 1–5 BEFORE checkout):
- Create
churchesrow (or find existing byexisting_church_id) - Create
premium_churchesrow (status='preview') - Create primary
identity+church_identity_roles(admin) - Create backup
identity(if backup email provided) - Provision chatbot (
organization_settings+ related tables)
Target behavior (webhook does ALL of this AFTER checkout):
- Read metadata from
session.metadata - Create or find
churchesrow (upsert by slug orexisting_church_id) - Create
premium_churchesrow (status='active', never 'preview') - Create primary
identity+church_identity_roles(admin role) - Create backup
identity+ role (ifbackup_emailin metadata) - Provision chatbot:
organization_settings, default agents (idempotent — skip if exists) - For voice plans: insert
church_voice_agentsstub (status='pending_setup'), alert founder - Send welcome email (3 retries — losing this means no dashboard access)
- Sync to MailerLite (
cwa-customers,cwa-trialgroups) ifmarketing_opt_in=true - Create
church_team_membersadmin row (idempotent) - Log to
webhook_events(idempotency guard against duplicate fires)
Webhook Responsibilities — PewSearch Plans
activateChurch() in pewsearch/web/api/stripe/webhook on checkout.session.completed:
Current behavior (pre-checkout routes do steps 1–2 BEFORE checkout):
- Create
premium_churchesrow (status=null/preview) - Create Stripe customer
Target behavior (webhook does ALL of this AFTER checkout):
- Read metadata from
session.metadata - Look up
churchesrow bychurch_idfrom metadata (PewSearch churches already exist) - Create
premium_churchesrow (status='active') - Set
churches.is_premium = true - Send welcome email with magic link (
admin_token) - Sync to MailerLite if
marketing_opt_in=true
Failure Scenarios
| Scenario | What Happens | Recovery |
|---|---|---|
| Customer abandons checkout | Nothing in DB. Clean state. | Customer can restart flow. |
| Card declined | Stripe shows error on checkout page. Nothing in DB. | Customer can update card and retry on same Stripe session. |
| Webhook delivery fails | Nothing in DB. Stripe retries with exponential backoff for up to 72 hours. | Auto-recovery. Check Stripe Dashboard → Webhooks for failed events. |
| Webhook fires but DB write fails | Webhook returns 500. Stripe retries. Idempotency via webhook_events prevents duplicates. | Auto-recovery. Fix DB issue, Stripe will retry. |
| Welcome email fails | Subscription is active. Email fails silently. | Webhook retries email 3 times. If all fail, logs CRITICAL error. Admin can use /api/onboard/resend-link to recover. |
| Return page loads before webhook | Page polls premium_churches every 2s for up to 30s. Shows spinner. | Automatic. Webhook typically fires within 2–5 seconds. If timeout: show message and direct to support. |
| Chatbot provisioning fails | Subscription is active. Chatbot not provisioned. | Logs error. Sends founder alert email. Manual provisioning via admin dashboard. |
| Duplicate webhook event | Idempotency check on webhook_events.stripe_event_id catches duplicate. Returns 200 immediately. | Automatic. |
Trial Rules
Trial periods are applied at the Stripe Checkout session level via subscription_data.trial_period_days.
| Product | Trial | Reason |
|---|---|---|
CWA Chat plans (starter/pro/suite _chat, annual variants) | 14 days | No per-minute cost. Safe to trial. |
| CWA Voice plans | None | Per-minute Telnyx costs start immediately on provisioning. |
| CWA Bundle plans | None | Contains voice component. |
| PewSearch Premium / Pro Website | None | Directory listing. No consumption cost. |
| SermonWise Pro | None | Not currently offered. |
| ITW Premium | None | Not currently offered. |
| ShareWiseAI | None | Not currently offered. |
Trial logic lives in each checkout route. The webhook does NOT need to know about trials — Stripe manages the trial-to-active transition and fires customer.subscription.updated when it converts.
Currency Enforcement
All Stripe Checkout sessions MUST set currency: 'usd'. This prevents Stripe from inferring currency from the customer's locale.
// Required on every stripe.checkout.sessions.create() call
currency: 'usd',
This is enforced in all correct flows. The refactored flows must include it.
Pricing Reference
All prices are USD. See C:\dev\PRICING.md for Stripe product/price IDs (test + live).
| Product | Plan | Monthly | Annual |
|---|---|---|---|
| CWA Chatbot | Starter | $14.95 | — |
| CWA Chatbot | Pro | $34.95 | — |
| CWA Chatbot | Suite | $59.95 | — |
| CWA Voice | Starter | $39.95 | — |
| CWA Voice | Pro | $69.95 | — |
| CWA Bundle | Starter | $49.95 | — |
| CWA Bundle | Pro | $79.95 | — |
| CWA Bundle | Suite | $99.95 | — |
| PewSearch Premium | — | $9.95 | — |
| PewSearch Pro Website | — | $19.95 | — |
| ITW Premium | — | $9.95 | $95.40 |
| SermonWise Pro | — | $19.95 | $191.40 |
| ShareWiseAI | Pro | $19.95 | — |
| ShareWiseAI | Business | $49.95 | — |
| ShareWiseAI | Agency | $99.95 | — |
Idempotency
The webhook is called by Stripe and may be retried. Every handler MUST be idempotent.
How idempotency works:
-
Event-level:
webhook_eventstable stores every processedstripe_event_id. At the top of each webhook handler, check for the ID before processing. Return200 { received: true }immediately for duplicates. -
Record-level: Use
upsertwithonConflictfor all DB writes. Never bareinsert. -
Chatbot provisioning: Check for existing
organization_settingsrow before callingprovisionChatbot(). Skip if exists. -
Voice agent stub: Check for existing
church_voice_agentsrow before inserting. Skip if exists. -
Team member: Check
countof existing admin rows before inserting. Skip if exists. -
Welcome email: Email is NOT idempotent — if Stripe fires twice and both succeed before the idempotency check runs, two welcome emails go out. Acceptable. Mitigated by the
webhook_eventscheck at the top of the handler.
B2C Pattern (SermonWise, ShareWiseAI, ITW)
B2C products use Supabase Auth (not token-based auth). Their subscription pattern differs from church plans:
Tables used: user_subscriptions + pricing_tiers (NOT premium_churches)
On checkout.session.completed:
- Upsert user_subscriptions { user_id, pricing_tier_id, stripe_customer_id, stripe_subscription_id, status }
- Update profiles.subscription_tier (LEGACY — keep for backward compat until all reads migrate)
On subscription.updated:
- Update user_subscriptions { status, current_period_end, cancel_at_period_end }
On subscription.deleted:
- Update user_subscriptions { status='canceled', canceled_at }
Never store B2C subscription state directly on the profiles table. profiles.subscription_tier is legacy and will be removed once all reads migrate to user_subscriptions.
The Return Page Problem
After Stripe redirects the customer to the success URL, the webhook may not have fired yet. The return/thank-you page must handle this gracefully.
Correct implementation:
success_url: /onboard/return?session_id={CHECKOUT_SESSION_ID}
Return page:
1. Read session_id from query params
2. Verify session with Stripe (server-side) — confirms payment actually succeeded
3. Poll premium_churches WHERE church_id matches session metadata
4. Every 2 seconds, up to 30 seconds (15 attempts)
5. On record found: redirect to /admin/[admin_token]
6. On timeout: show "Your payment was received. Check your email for your dashboard link."
Do NOT redirect to the dashboard URL directly from success_url — the admin_token may not exist yet.
Upgrade Flow (Existing Customers)
When an active customer upgrades their plan, the checkout flow is different. No new subscription is created.
CWA Church Checkout (churchwiseai-web/api/stripe/church-checkout):
1. Resolve church by admin token
2. Look up existing premium_churches row
3. If status=active AND stripe_subscription_id exists:
a. Retrieve subscription from Stripe
b. If current price = requested price: redirect to dashboard (no-op)
c. If different price: stripe.subscriptions.update() with proration_behavior='create_prorations'
d. Redirect to dashboard with ?upgraded=true
4. If no active subscription: create new Stripe Checkout session (normal flow)
PewSearch Checkout (pewsearch/web/api/stripe/checkout):
1. Look up church by church_id or token
2. If existing active subscription:
a. If same or lower tier: redirect with ?already_active=true (no-op)
b. If higher tier: stripe.subscriptions.update() with price swap
3. If no active subscription: normal checkout session creation
The customer.subscription.updated webhook handles DB updates for upgrades.
Testing Checklist
Before declaring any payment flow refactor complete, verify all of these:
Pre-Payment (should be clean)
- Submitting the onboard form creates NO records in
churches,premium_churches,identities, ororganization_settings - Abandoning Stripe Checkout leaves NO records in any table
- A card decline leaves NO records in any table
- The return page shows a loading state while polling
Post-Payment (webhook must create everything)
-
checkout.session.completedcreateschurchesrow (or finds existing) -
checkout.session.completedcreatespremium_churchesrow withstatus='active' -
checkout.session.completedcreatesidentities+church_identity_roles(admin) -
checkout.session.completedcreatesorganization_settings(chatbot provision) - Welcome email is sent with valid
admin_tokenlink - Accessing the magic link opens the admin dashboard
- For voice plans:
church_voice_agentsstub is created withstatus='pending_setup' - MailerLite subscriber is created if
marketing_opt_in=true -
church_team_membersadmin row is created
Idempotency
- Firing the same
checkout.session.completedevent twice does NOT create duplicate records -
webhook_eventstable has the event ID after first fire - Second fire returns
200 { received: true }immediately
Stripe
-
currency: 'usd'is set on all session creates - Trial period is applied for chat plans only
- Session metadata contains all required fields
-
subscription_data.metadatamirrors session metadata (for subscription lifecycle events)
Test Cards
| Card | What it tests |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Card declined |
4000 0025 0000 3155 | 3DS authentication required |
Use Stripe test mode. Webhook: stripe listen --forward-to localhost:3002/api/stripe/webhook
What NOT to Do
These patterns caused real bugs and must never reappear:
-
Do NOT insert
churchesbefore checkout. If payment fails, you own an orphan church record with no customer. -
Do NOT insert
premium_churchesbefore checkout. The current onboard setsstatus='preview'pre-checkout. This leaks dashboard access and orphans data. -
Do NOT send the welcome email before checkout. The current onboard webhook does this correctly; the onboard route previously sent it pre-checkout, granting free access.
-
Do NOT call
provisionChatbot()before checkout. Createsorganization_settingsbefore subscription exists. Results in chatbot being available on a non-paying record. -
Do NOT create identities before checkout. Pre-checkout identity creation means non-paying users get auth records.
-
Do NOT use
insertwithoutupsertin webhook handlers. Stripe WILL retry. UseupsertwithonConflicton every write. -
Do NOT omit
currency: 'usd'on checkout sessions. Stripe infers from customer locale and charges in wrong currency. -
Do NOT use
echowhen piping env vars to Vercel. Useprintf.echoappends a trailing newline that silently breaks env var values.
Related Files
C:\dev\PRICING.md— All Stripe product/price IDs (test + live), billing rulesC:\dev\knowledge\data\pricing.yaml— Canonical pricing source (YAML)C:\dev\knowledge\architecture\database-schema.md— Full DB schemaC:\dev\knowledge\architecture\supabase-auth-config.md— Auth configurationC:\dev\churchwiseai-web\src\lib\chatbot-provision.ts— Chatbot provisioning logicC:\dev\churchwiseai-web\src\lib\email.ts— Welcome email + lifecycle emailsC:\dev\churchwiseai-web\src\lib\mailerlite.ts— MailerLite sync