Skip to main content

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.

FlowProductCodebaseCheckout RouteStatus
CWA GenericVoice/Chat/Bundle (pricing page)churchwiseai-webapi/stripe/checkout/route.tsCORRECT
CWA Church CheckoutVoice/Chat/Bundle (dashboard upgrade)churchwiseai-webapi/stripe/church-checkout/route.tsCORRECT (creates shell pre-checkout, but church already exists)
CWA EmbeddedVoice/Chat/Bundle (onboard embedded checkout)churchwiseai-webapi/stripe/checkout-embedded/route.tsCORRECT (reads existing church, no new writes)
SermonWise ProSermonWise ($19.95/mo)churchwiseai-webapi/sermons/checkout/route.tsCORRECT
ShareWiseAISocial media SaaS ($19.95–$99.95/mo)churchwiseai-webapi/social/checkout/route.tsCORRECT
ITW PremiumIllustrateTheWord ($9.95/mo)sermon-illustrationsapi/stripe/checkout/route.tsCORRECT
CWA OnboardChurchWiseAI new church signupchurchwiseai-webapi/onboard/route.tsBROKEN — being refactored
PewSearch Pre-CheckoutPewSearch Premium/Pro Websitepewsearch/webapi/stripe/pre-checkout/route.tsBROKEN — being refactored
PewSearch CheckoutPewSearch Premium/Pro Website (upgrade path)pewsearch/webapi/stripe/checkout/route.tsBROKEN — being refactored

Webhook Handlers

Each codebase has its own webhook endpoint. They share the Stripe account but filter events by product metadata.

Webhook RouteHandlesKey Event
churchwiseai-web/api/stripe/webhookCWA church plans (chat/voice/bundle), SermonWise Pro, ShareWiseAI, AI Starter Kitcheckout.session.completed
pewsearch/web/api/stripe/webhookPewSearch Premium, Pro Websitecheckout.session.completed
sermon-illustrations/api/stripe/webhookITW Premiumcheckout.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):

  1. Create churches row (or find existing by existing_church_id)
  2. Create premium_churches row (status='preview')
  3. Create primary identity + church_identity_roles (admin)
  4. Create backup identity (if backup email provided)
  5. Provision chatbot (organization_settings + related tables)

Target behavior (webhook does ALL of this AFTER checkout):

  1. Read metadata from session.metadata
  2. Create or find churches row (upsert by slug or existing_church_id)
  3. Create premium_churches row (status='active', never 'preview')
  4. Create primary identity + church_identity_roles (admin role)
  5. Create backup identity + role (if backup_email in metadata)
  6. Provision chatbot: organization_settings, default agents (idempotent — skip if exists)
  7. For voice plans: insert church_voice_agents stub (status='pending_setup'), alert founder
  8. Send welcome email (3 retries — losing this means no dashboard access)
  9. Sync to MailerLite (cwa-customers, cwa-trial groups) if marketing_opt_in=true
  10. Create church_team_members admin row (idempotent)
  11. 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):

  1. Create premium_churches row (status=null/preview)
  2. Create Stripe customer

Target behavior (webhook does ALL of this AFTER checkout):

  1. Read metadata from session.metadata
  2. Look up churches row by church_id from metadata (PewSearch churches already exist)
  3. Create premium_churches row (status='active')
  4. Set churches.is_premium = true
  5. Send welcome email with magic link (admin_token)
  6. Sync to MailerLite if marketing_opt_in=true

Failure Scenarios

ScenarioWhat HappensRecovery
Customer abandons checkoutNothing in DB. Clean state.Customer can restart flow.
Card declinedStripe shows error on checkout page. Nothing in DB.Customer can update card and retry on same Stripe session.
Webhook delivery failsNothing 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 failsWebhook returns 500. Stripe retries. Idempotency via webhook_events prevents duplicates.Auto-recovery. Fix DB issue, Stripe will retry.
Welcome email failsSubscription 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 webhookPage 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 failsSubscription is active. Chatbot not provisioned.Logs error. Sends founder alert email. Manual provisioning via admin dashboard.
Duplicate webhook eventIdempotency 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.

ProductTrialReason
CWA Chat plans (starter/pro/suite _chat, annual variants)14 daysNo per-minute cost. Safe to trial.
CWA Voice plansNonePer-minute Telnyx costs start immediately on provisioning.
CWA Bundle plansNoneContains voice component.
PewSearch Premium / Pro WebsiteNoneDirectory listing. No consumption cost.
SermonWise ProNoneNot currently offered.
ITW PremiumNoneNot currently offered.
ShareWiseAINoneNot 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).

ProductPlanMonthlyAnnual
CWA ChatbotStarter$14.95
CWA ChatbotPro$34.95
CWA ChatbotSuite$59.95
CWA VoiceStarter$39.95
CWA VoicePro$69.95
CWA BundleStarter$49.95
CWA BundlePro$79.95
CWA BundleSuite$99.95
PewSearch Premium$9.95
PewSearch Pro Website$19.95
ITW Premium$9.95$95.40
SermonWise Pro$19.95$191.40
ShareWiseAIPro$19.95
ShareWiseAIBusiness$49.95
ShareWiseAIAgency$99.95

Idempotency

The webhook is called by Stripe and may be retried. Every handler MUST be idempotent.

How idempotency works:

  1. Event-level: webhook_events table stores every processed stripe_event_id. At the top of each webhook handler, check for the ID before processing. Return 200 { received: true } immediately for duplicates.

  2. Record-level: Use upsert with onConflict for all DB writes. Never bare insert.

  3. Chatbot provisioning: Check for existing organization_settings row before calling provisionChatbot(). Skip if exists.

  4. Voice agent stub: Check for existing church_voice_agents row before inserting. Skip if exists.

  5. Team member: Check count of existing admin rows before inserting. Skip if exists.

  6. 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_events check 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, or organization_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.completed creates churches row (or finds existing)
  • checkout.session.completed creates premium_churches row with status='active'
  • checkout.session.completed creates identities + church_identity_roles (admin)
  • checkout.session.completed creates organization_settings (chatbot provision)
  • Welcome email is sent with valid admin_token link
  • Accessing the magic link opens the admin dashboard
  • For voice plans: church_voice_agents stub is created with status='pending_setup'
  • MailerLite subscriber is created if marketing_opt_in=true
  • church_team_members admin row is created

Idempotency

  • Firing the same checkout.session.completed event twice does NOT create duplicate records
  • webhook_events table 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.metadata mirrors session metadata (for subscription lifecycle events)

Test Cards

CardWhat it tests
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Card declined
4000 0025 0000 31553DS 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:

  1. Do NOT insert churches before checkout. If payment fails, you own an orphan church record with no customer.

  2. Do NOT insert premium_churches before checkout. The current onboard sets status='preview' pre-checkout. This leaks dashboard access and orphans data.

  3. 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.

  4. Do NOT call provisionChatbot() before checkout. Creates organization_settings before subscription exists. Results in chatbot being available on a non-paying record.

  5. Do NOT create identities before checkout. Pre-checkout identity creation means non-paying users get auth records.

  6. Do NOT use insert without upsert in webhook handlers. Stripe WILL retry. Use upsert with onConflict on every write.

  7. Do NOT omit currency: 'usd' on checkout sessions. Stripe infers from customer locale and charges in wrong currency.

  8. Do NOT use echo when piping env vars to Vercel. Use printf. echo appends a trailing newline that silently breaks env var values.


  • C:\dev\PRICING.md — All Stripe product/price IDs (test + live), billing rules
  • C:\dev\knowledge\data\pricing.yaml — Canonical pricing source (YAML)
  • C:\dev\knowledge\architecture\database-schema.md — Full DB schema
  • C:\dev\knowledge\architecture\supabase-auth-config.md — Auth configuration
  • C:\dev\churchwiseai-web\src\lib\chatbot-provision.ts — Chatbot provisioning logic
  • C:\dev\churchwiseai-web\src\lib\email.ts — Welcome email + lifecycle emails
  • C:\dev\churchwiseai-web\src\lib\mailerlite.ts — MailerLite sync