Payment System Architecture

Architecture and design decisions for the payment system

System Architecture

The payment system follows a layered architecture with clear separation of concerns:

┌─────────────────────────────────────────────────────────────┐
│                    API Routes (Next.js)                     │
│  Thin layer: auth, params extraction, delegate to service  │
└───────────────────────┬─────────────────────────────────────┘

┌───────────────────────▼─────────────────────────────────────┐
│            Service Layer (@zooly/srv-stripe-payment)          │
│  Business logic: payment completion, payouts, refunds        │
└───────────────────────┬─────────────────────────────────────┘

┌───────────────────────▼─────────────────────────────────────┐
│              Access Layer (@zooly/app-db)                    │
│  Database operations: CRUD, transactions, queries             │
└───────────────────────┬─────────────────────────────────────┘

┌───────────────────────▼─────────────────────────────────────┐
│              Database Schema (Drizzle ORM)                   │
│  Tables: stripe_payments, pay_out, payment_share_tracking  │
└─────────────────────────────────────────────────────────────┘

High-Level Flow Diagram

graph TB Buyer["Buyer Checkout"] -->|"POST /api/payments/create-intent"| CreateIntent["Create Payment Intent"] CreateIntent -->|"getProductByPayForId"| Product["Fetch Product (source of truth)"] Product -->|"Stripe API"| PaymentIntent["Stripe PaymentIntent"] PaymentIntent -->|clientSecret| Frontend["Frontend Stripe.js"] Frontend -->|confirmPayment| Stripe["Stripe Processing"] Stripe --> ClientComplete["POST /complete (primary)"] Stripe --> Webhook["Webhook (fallback)"] ClientComplete -->|"verify with Stripe API"| Complete["Atomic Payment Completion"] Webhook -->|completePayment| Complete Complete -->|createShareTrackingBatch| Tracking["Share Tracking Records"] Tracking -->|"Daily Cron"| PayoutDaemon["Payout Daemon"] PayoutDaemon -->|"Threshold Check"| Transfer["Stripe Connect Transfer"]

Payment Intent Creation Flow

sequenceDiagram participant Buyer participant API participant Service participant DB participant Stripe Buyer->>API: POST /api/payments/create-intent API->>Service: createPaymentIntent(params) Service->>DB: getProductByPayForId(payFor, payForId) DB-->>Service: PaymentProduct (price + seller from product table) Service->>DB: createStripePayment(product.amountCent) Service->>Stripe: create PaymentIntent Stripe-->>Service: PaymentIntent (pi_xxx) Service->>DB: updateStripePaymentIntentId Service-->>API: { stripePaymentId, clientSecret } API-->>Buyer: Return clientSecret

Payment Completion Flow

Payment completion uses a dual-path approach. The client endpoint is the primary path; the webhook is the fallback. Both call the same idempotent completePayment() function with FOR UPDATE row locking.

sequenceDiagram participant Buyer participant CompleteAPI as "POST /complete" participant StripeAPI as "Stripe API" participant Service as completePayment participant DB participant Webhook as "Webhook (fallback)" Buyer->>CompleteAPI: stripePaymentId CompleteAPI->>DB: getStripePaymentById alt Already SUCCEEDED DB-->>CompleteAPI: payment with status SUCCEEDED CompleteAPI-->>Buyer: Return existing ppuCode else Still CREATED CompleteAPI->>StripeAPI: paymentIntents.retrieve(pi_xxx) alt PaymentIntent succeeded StripeAPI-->>CompleteAPI: charge ID from latest_charge CompleteAPI->>Service: completePayment(id, chargeId) Service->>DB: markStripePaymentCompleted (atomic FOR UPDATE) Service->>DB: createShareTrackingBatch Service->>DB: Handle advance offset Service->>DB: createPpuCode Service-->>CompleteAPI: ppuCode CompleteAPI-->>Buyer: Return ppuCode else PaymentIntent not yet succeeded CompleteAPI-->>Buyer: 202 Payment still processing end end Note over Webhook: Webhook fires later — calls same completePayment(), no-op if already completed

Revenue Distribution Flow

graph LR Payment[Payment $100] --> StripeFee[Stripe Fee $3.20] Payment --> PlatformFee[Platform Fee $X] Payment --> TalentGross[Talent Gross $Y] PlatformFee --> HostPartner[Host Partner 10%] PlatformFee --> Ambassador[Ambassador Share] TalentGross --> Agent[Agent Share] TalentGross --> TalentNet[Talent Net Share] TalentNet --> Accumulate[Accumulate in Tracking] Agent --> Accumulate HostPartner --> Accumulate Ambassador --> Accumulate Accumulate -->|$100 threshold| Payout[Stripe Connect Transfer]

Payout Processing Flow

sequenceDiagram participant Cron participant Daemon participant DB participant Stripe Cron->>Daemon: Daily 2 PM UTC Daemon->>DB: Find accounts needing inspection loop For each account Daemon->>DB: Sum OPEN tracking records alt Sum >= $100 Daemon->>DB: Create PENDING PayOut (Phase 1) Daemon->>DB: Close all OPEN tracking Daemon->>Stripe: Create Transfer (Phase 2) alt Transfer Success Daemon->>DB: Update PayOut to CHARGE (Phase 3) else Transfer Failed Daemon->>DB: Cancel PayOut + Reopen tracking end else Sum < $100 Daemon->>DB: Skip (below threshold) end end

Refund Flow

sequenceDiagram participant Admin participant API participant Service participant DB participant Stripe Admin->>API: POST /api/payments/refund API->>Service: processRefund(stripePaymentId) Service->>DB: Get original payment + tracking Service->>DB: Create negative stripePayment (Phase 1) Service->>DB: Update original status = REFUNDED Service->>DB: Create negative tracking records Service->>DB: Cancel original tracking records alt Tracking was CLOSED (paid out) Service->>DB: Increment advanceAmountCent end Service->>Stripe: Create refund (Phase 2) Stripe-->>Service: Refund ID (re_xxx) Service->>DB: Update refund stripeChargeId (Phase 3) Service-->>API: Success

Key Design Decisions

1. Account-Centric Architecture

  • All payments keyed by accountId (not userId)
  • Enables multi-user accounts and better separation of concerns
  • System accounts (zooly_acc, stripe_acc) for platform/fee tracking

2. Atomic Operations

  • Critical paths wrapped in database transactions
  • Prevents inconsistencies from partial failures
  • Uses FOR UPDATE locks where needed for concurrency

3. Dual-Path Completion with Idempotency

  • Client endpoint (POST /complete) is the primary completion path — queries Stripe API to verify charge status
  • Webhook (charge.succeeded) is the fallback — covers cases where the client never calls the endpoint
  • Both paths call the same completePayment() function with FOR UPDATE row locking
  • markStripePaymentCompleted returns null if already completed — only one caller succeeds
  • Safe to process duplicate calls from either path

4. Two-Phase Payout Commit

  • Phase 1: Create PENDING PayOut + close tracking (DB)
  • Phase 2: Create Stripe transfer
  • Phase 3: Update PayOut to CHARGE (or CANCELED if transfer fails)
  • Prevents double-payout if DB write fails after successful Stripe transfer

5. Product as Source of Truth for Pricing

  • Price breakdown (Stripe fee, platform fee, talent gross share) lives in the product table
  • Product is created first; the payment system fetches pricing from the product via getProductByPayForId
  • Agent/ambassador/host partner shares calculated at completion time
  • Ensures stakeholder relationships are looked up fresh

6. System Accounts for Fees

  • Replaced v1's virtual user IDs with real account records
  • zooly_acc: Platform fee tracking (auto-closed)
  • stripe_acc: Stripe fee tracking (auto-closed)
  • Makes payeeAccountId NOT NULL across all tracking records

7. Advance Payout Offset

  • Advance payouts create PayOut with advanceAmountCent > 0
  • Future earnings automatically offset against advance balance
  • Refunds of paid-out tracking increment advance balance

8. Product-Agnostic Design

  • Payment system doesn't hard-code product schemas beyond routing
  • Linked via payFor enum + payForId foreign key (e.g. IP term id, merch session id, offer id for OFFER)
  • Product details live in domain-specific tables; getProductByPayForId() returns a normalized PaymentProduct

File Structure

packages/
├── db/src/
│   ├── schema/
│   │   ├── paymentEnums.ts          # All payment pgEnums
│   │   ├── stripePayments.ts        # Master transaction record
│   │   ├── payOut.ts                # Completed payouts
│   │   ├── paymentShareTracking.ts  # Revenue distribution
│   │   ├── paymentSystemLogs.ts    # Audit trail
│   │   ├── accountPayoutRoutes.ts   # Payout method config
│   │   ├── ppu.ts         # Proof of purchase
│   │   └── accountAgents.ts         # Agent relationships
│   └── access/
│       ├── stripePayments.ts        # Payment CRUD
│       ├── payOut.ts                # Payout CRUD
│       ├── paymentShareTracking.ts  # Tracking CRUD
│       └── ...                      # Other access functions
└── srv-stripe-payment/src/
    ├── stripe.ts                    # Stripe client & operations
    ├── getProductByPayForId.ts      # Product resolution and pricing (inc. OFFER / escrow amounts)
    ├── insertShareTrackingForProduct.ts  # Batch tracking + advance offset (shared completion + offer release)
    ├── completePayment.ts           # Payment completion (escrow branch for OFFER)
    ├── releaseOfferEscrowForOffer.ts     # OFFER: create tracking after delivery accepted
    ├── refund.ts                    # Refund processing
    ├── advancePayout.ts            # Advance payout
    ├── payoutDaemon.ts             # Daily cron daemon
    └── doPayout.ts                  # Per-account payout logic

apps/zooly-app/app/api/payments/
├── create-intent/route.ts          # Create payment intent
├── complete/route.ts                # Payment completion (primary path, verifies with Stripe)
├── webhook/stripe/route.ts          # Stripe webhook handler
├── process-payouts/route.ts         # Payout cron endpoint
├── refund/route.ts                  # Refund endpoint
└── admin/
    ├── do-payout/route.ts           # Manual payout
    └── advance-payout/route.ts      # Advance payout

Environment Variables

VariablePurposeRequired
STRIPE_SECRET_KEYStripe API authentication (sk_xxx)Yes
STRIPE_PUBLISHABLE_KEYStripe.js frontend (pk_xxx)Yes
STRIPE_WEBHOOK_SECRETWebhook signature verification (whsec_xxx)Yes
CRON_SECRETBearer token for cron endpoint authYes
DATABASE_URLPostgreSQL connection stringYes

Next Steps