Payment Flows

Detailed documentation of payment flows through the system

Payment Intent Creation Flow

Overview

A payment always starts with creating a product. The Zooly payment system follows a Product-Agnostic architecture where a central stripe_payments table handles all payment logic, while each product type has its own dedicated table and pricing API. The product-creation API creates the product-specific record first (including the 3-way charge breakdown: Stripe fee, platform fee, talent gross share), then the buyer initiates checkout and the system creates a Stripe PaymentIntent by fetching the product's price from the product table (product is source of truth).

Step-by-Step

  1. Product creation (prerequisite)

    • Frontend calls product-specific creation API (e.g., POST /api/my-product/create)
    • Product record is created in the domain-specific table
    • Returns PaymentProduct with payFor, payForId, sellerAccountId, and priceData
    • See Adding a New Product for details
  2. Buyer initiates checkout

    • Frontend calls POST /api/payments/create-intent
    • Request includes: payFor, payForId, optional hostPartnerSlug
  3. API Route (apps/zooly-app/app/api/payments/create-intent/route.ts)

    • Extracts params from request body
    • Verifies buyer identity via getVerifiedUserInfo()
    • Calls service function from @zooly/srv-stripe-payment
  4. Service Logic (packages/srv-stripe-payment/src/createPaymentIntent.ts)

    • Fetches product from DB: getProductByPayForId(payFor, payForId)
      • Product is source of truth for price (priceData.amountCent) and seller (sellerAccountId)
    • Creates or gets Stripe customer: createOrGetStripeCustomer(email, name)
    • Creates stripe_payments DB record with amount from product table
    • Creates Stripe PaymentIntent via Stripe API
    • Updates DB record with stripePaymentIntentId
    • Returns { stripePaymentId, stripeClientSecret, stripePublishableKey }
  5. Frontend receives clientSecret

    • Uses Stripe.js confirmPayment() to process payment
    • Stripe handles card processing

Pricing via Product Table

Price breakdown is computed when the product is created and stored in the product table as priceData. The payment service fetches it via getProductByPayForId() (in packages/srv-stripe-payment/src/getProductByPayForId.ts).

The 3-way split (Stripe fee, platform fee, talent gross share) is determined at product creation time. Agent, ambassador, and host partner shares are computed later at payment completion.

Standard Products (VOICE_OVER, IMAGE, LIKENESS):

  • Stripe Fee = STRIPE_FEE_FIXED_CENT + (amountCent * STRIPE_FEE_PERCENTAGE_BPS / 10000)
  • Platform Fee = Fixed $5.00 AI creation cost
  • Talent Gross Share = amountCent - stripeFeeCent - platformFeeCent

Merch Products (MERCH):

  • Stripe Fee = same formula as above
  • Platform Fee = $0 (margin built into product price)
  • Talent Gross Share = amountCent - stripeFeeCent
  • Price is passed as knownAmountCent (cart total calculated by the merch route), not from a product table row
  • sellerAccountId from merch_campaign (currently defaults to "zooly_acc")

Offer / marketplace products (OFFER):

  • Domain record: offers table (payForId = offer id)
  • Buyer-visible total: total_brand_price_cent (= agreed offer amount + Zooly fee)
  • Platform fee on the charge is the offer’s zooly_fee_cent (currently 20% of the agreed offer_amount_cent, not the fixed $5.00 used for IP-term licenses)
  • Stripe fee is computed on total_brand_price_cent (same calculateStripeFees helper as other products)
  • computePriceData(totalBrandPriceCent, zoolyFeeCent) yields ProductPriceData so talent gross share = total − Stripe fee − Zooly fee
  • Checkout: getProductByPayForId("OFFER", offerId) requires offer status ACCEPTED (no knownAmountCent)

Offer marketplace payments (escrow)

Offers use the same PaymentIntent and completePayment() engine, but defer share tracking until the brand accepts delivery:

  1. Charge succeedscompletePayment() marks stripe_payments SUCCEEDED, creates a PPU code, runs applyOfferPaymentEscrow: offer → PAID, sets payment_stripe_id and auto_release_at (~30 days). No insertShareTrackingForProduct / batch tracking at this step.

  2. Talent delivers — normal offer APIs (outside the generic payment routes).

  3. Brand accepts deliveryreleaseOfferEscrowForOffer() (from @zooly/srv-stripe-payment) loads the succeeded payment + product, calls insertShareTrackingForProduct (same agent/talent/stripe/platform + advance-offset logic as other products), then marks the offer COMPLETED. OPEN shares accumulate toward payouts as usual.

  4. Settlement product lookup when creating tracking uses getProductByPayForId("OFFER", offerId, payment.amountCent) so the stored charge amount cannot drift from the offer.

Idempotency: duplicate completion paths still use markStripePaymentCompleted with FOR UPDATE. Escrow release is idempotent if tracking already exists.

Merch Payment Flow

Overview

Merch payments differ from standard IP term payments:

  • Anonymous buyers: No user account. The merch session ID is used as buyerUserId.
  • Externally-calculated price: Cart total is computed by the merch route (not from a product table row).
  • Campaign-based seller: sellerAccountId comes from merch_campaign.sellerAccountId (currently defaults to "zooly_acc" — platform system account).

Step-by-Step

  1. Fan completes shipping form and navigates to payment page

  2. Payment page fetches pricing (POST /api/merch/calc-price)

    • Server recalculates cart total (subtotal + shipping)
    • Returns { subtotal, shippingCost, total, stripePublishableKey, allowSkipPayment }
  3. Payment page creates PI (POST /api/merch/create-payment-intent)

    • Route recalculates cart total server-side, fetches campaign for sellerAccountId
    • Calls createMerchPaymentIntent() from @zooly/srv-stripe-payment:
      • Creates stripe_payment record (payFor: "MERCH", payForId: sessionId, buyerUserId: sessionId, buyerAccountId: null)
      • Creates Stripe PaymentIntent with idempotency key merch-pi-{paymentId} and metadata { stripePaymentId, merchSessionId }
      • Updates merch session: paymentIntentId (Stripe PI ID) and stripePaymentDbId (internal ID)
    • Returns { stripePaymentId, stripeClientSecret, stripePublishableKey }
  4. Fan pays via Stripe.jsstripe.confirmPayment() processes card

  5. Confirm page calls /api/merch/order/complete

    • Route reads session.stripePaymentDbId
    • Calls completePaymentFromClient(stripePaymentDbId):
      • Queries Stripe to verify PI succeeded
      • Extracts charge ID, calls completePayment() (same atomic engine as IP terms)
      • Creates share tracking records (TALENT, STRIPE_FEE, etc.)
    • Creates merch order via createOrGetMerchOrderFromSession()
    • Sends confirmation email
  6. Webhook fallback — if client crashes, charge.succeeded webhook completes the payment via the same idempotent completePayment() function

Demo Mode

Tester sessions (role === "tester") skip Stripe entirely:

  • Payment page shows "Tester mode active" banner
  • Calls /api/merch/order/complete with ppuCode: "DEMO_SKIP_PAYMENT"
  • Route verifies session.role === "tester", creates order without payment verification

Payment Completion Flow

Overview

Payment completion uses a dual-path approach: the client endpoint (POST /api/payments/complete) is the primary path that verifies with Stripe and completes the payment, while the webhook (charge.succeeded) serves as a fallback for cases where the client never calls the endpoint. Both paths call the same idempotent completePayment() function.

Primary Path: Client Endpoint

  1. Frontend calls POST /api/payments/complete

    • After confirmPayment() returns successfully (or after Stripe redirect)
    • Sends { stripePaymentId } to the endpoint
  2. Client Completion Service (completePaymentFromClient() in packages/srv-stripe-payment/src/completePayment.ts)

    • Loads payment from DB
    • If already SUCCEEDED: returns existing purchase code (fast path)
    • If still CREATED: queries Stripe API via retrieveStripePaymentIntent(pi_xxx) with expanded latest_charge
    • If PaymentIntent status is succeeded: extracts charge ID (ch_xxx) from latest_charge and calls completePayment()
    • If PaymentIntent still processing: returns 202 status so the UI can retry
    • Fetches product to get pricing breakdown via getProductByPayForId()

Fallback Path: Webhook

  1. Stripe fires charge.succeeded webhook

    • May arrive before or after the client endpoint call
  2. Webhook Handler (apps/zooly-app/app/api/payments/webhook/stripe/route.ts)

    • Verifies webhook signature: verifyWebhookSignature(body, signature)
    • Extracts stripePaymentId from charge metadata
    • Calls completePayment(stripePaymentId, chargeId) from service layer

Shared Completion Engine: completePayment()

Both paths call completePayment() (packages/srv-stripe-payment/src/completePayment.ts), which runs the entire completion in a single atomic transaction:

a. Mark payment completed (markStripePaymentCompleted)

  • Uses FOR UPDATE row lock to prevent double-spend
  • Updates status from CREATED to SUCCEEDED
  • Sets stripeChargeId (ch_xxx)
  • Sets stripeFeeCent
  • Returns null if already completed (idempotent)

b. Revenue distribution (product-dependent)

  • payFor !== "OFFER": insertShareTrackingForProduct (insertShareTrackingForProduct.ts) in the same transaction:
    • Loads getShareEligibleAgents(sellerAccountId)
    • Builds rows and calls createShareTrackingBatch: AGENT (optional, OPEN), TALENT net (OPEN), STRIPE_FEEstripe_acc (CLOSED), PLATFORMzooly_acc (CLOSED)
    • Applies advance offset for each new OPEN row (close or split against payOutId / reduceAdvanceAmount) — same logic as before this helper was extracted
  • payFor === "OFFER" (escrow): applyOfferPaymentEscrow only — offer → PAID, sets payment_stripe_id / auto_release_at. No share-tracking rows yet; release happens in releaseOfferEscrowForOffer() after delivery acceptance.

Note: HOST_PARTNER and AMBASSADOR tracking are still listed as gaps in TODOs and Gaps; insertShareTrackingForProduct does not create them today.

c. Create PPU code (createPpuCode)

  • Generates unique code
  • Links to stripePaymentId (1:1 relationship)

d. After the transaction commits, OFFER payments may enqueue a PAYMENT_RECEIVED notification to the talent (delivery prompt).

e. Structured logging via the payment logger (PAYMENT_COMPLETION component)

Return Result

  • If already completed: Returns existing PPU code
  • If new completion: Returns { ppuCode, stripePayment, trackingRecords }
  • If still processing (client path only): Returns 202 with stillProcessing: true

Idempotency

Both the client endpoint and webhook may attempt completion. The system handles this safely:

  • markStripePaymentCompleted uses FOR UPDATE row lock — only one caller transitions CREATED to SUCCEEDED
  • If already SUCCEEDED, returns existing purchase code without creating duplicates
  • Whichever path arrives first does the work; the second is a no-op

Payout Processing Flow

Overview

Daily cron job (2 PM UTC) processes payouts for accounts that have accumulated $100 or more in OPEN tracking records.

Step-by-Step

  1. Cron Trigger (apps/zooly-app/app/api/payments/process-payouts/route.ts)

    • Verifies cron auth (CRON_SECRET bearer token)
    • Calls payoutDaemon() from service layer
  2. Payout Daemon (packages/srv-stripe-payment/src/payoutDaemon.ts)

    a. Acquire distributed lock

    • Uses existing processLock mechanism (10-minute TTL)
    • Prevents concurrent daemon runs

    b. Find accounts needing inspection

    • Queries account_payment_settings where:
      • lastPayoutInspectionAt IS NULL OR
      • lastPayoutInspectionAt < NOW() - INTERVAL '1 day'
    • Excludes system accounts (zooly_acc, stripe_acc)
    • Default limit: 100 accounts per run

    c. For each account:

    i. Sum OPEN tracking records

    • getOpenTrackingSumByAccount(accountId)
    • Returns total cents accumulated

    ii. Check threshold

    • If sum is greater than or equal to accountPaymentSettings.minimumPayoutAmountCent (default $100):

      Two-Phase Commit:

      Phase 1 (DB):

      • Create PENDING PayOut record: createPayOut({ status: "PENDING", payeeAccountId, amountCent: sumCent })
      • Get all OPEN tracking records: getOpenTrackingByAccount(accountId)
      • Close all tracking: closeTrackingRecords(trackingIds, payOutId)

      Phase 2 (Stripe):

      • Get payout route: getPayoutRouteByAccountId(accountId)
      • Verify kycVerified is true (required)
      • Create Stripe Connect transfer:
        stripe.transfers.create({
          amount: sumCent,
          currency: 'usd',
          destination: stripeConnectAccountId,
          idempotencyKey: `payout-${payOutId}`
        })

      Phase 3 (Confirm):

      • If Stripe succeeds:
        • Update PayOut: updatePayOutStatus(payOutId, "CHARGE", stripeTransferId)
      • If Stripe fails:
        • Cancel PayOut: updatePayOutStatus(payOutId, "CANCELED")
        • Reopen tracking: cancelPayoutAndReopenTracking(payOutId, trackingIds)
        • Send outstanding payout email (if emailOutstandingPayout is false)
        • Set emailOutstandingPayout to true

    iii. Update inspection timestamp

    • updateLastPayoutInspection(accountId)

    d. Release distributed lock

  3. Return result

    • { processed, skipped, errors }

Two-Phase Commit Pattern

Prevents double-payout if Stripe transfer succeeds but DB write fails:

  • Phase 1: Create PENDING PayOut + close tracking (DB)
  • Phase 2: Create Stripe transfer
  • Phase 3: Update PayOut to CHARGE (or CANCELED if transfer fails)

If Phase 3 fails after successful Stripe transfer, the idempotency key prevents duplicate transfers on retry.

Refund Flow

Overview

Admin-initiated refunds create negative payment records, cancel original tracking, and adjust advance balances if tracking was already paid out.

Step-by-Step

  1. Admin triggers refund

    • POST /api/payments/refund
    • Body: { stripePaymentId }
    • Auth: Admin required
  2. API Route (apps/zooly-app/app/api/payments/refund/route.ts)

    • Extracts params, verifies admin auth
    • Calls processRefund(stripePaymentId) from service layer
  3. Refund Processing (packages/srv-stripe-payment/src/refund.ts)

    Phase 1 (DB-first - all reversals in one transaction):

    a. Get original payment + tracking

    • Verify status is SUCCEEDED (can't refund already-refunded)
    • Guard: Partial refunds NOT supported (full refund only)
    • Get all tracking records: listTrackingByStripePayment(stripePaymentId)

    b. SELECT FOR UPDATE on original payment

    • Prevents concurrent refunds (race condition protection)

    c. Create negative stripePayment record

    • Status set to REFUNDED
    • amountCent set to negative original amount
    • stripeFeeCent set to original stripe fee (preserved for audit)
    • Same buyer/seller/payFor/payForId as original
    • canceledByStripePaymentId set to null (this is the refund record)

    d. Update original stripePayment

    • Status set to REFUNDED
    • canceledByStripePaymentId set to refund stripe payment ID

    e. For each original tracking record:

    • Create negative refund tracking:
      • stripePaymentId set to refund stripe payment ID
      • amountCent set to negative original tracking amount
      • Status set to REFUNDED
      • Same type and payeeAccountId as original
    • Cancel original tracking:
      • Status set to CANCELED
      • canceledByTrackingId set to refund tracking ID
    • If original tracking was CLOSED (already paid out):
      • Find the PayOut it was linked to
      • Increment advance: incrementAdvanceAmount(payOutId, trackingAmount)
      • This creates a future offset: talent will earn less from future payments

    f. COMMIT transaction

    Phase 2 (Stripe - with idempotency key):

    g. Create Stripe refund

    stripe.refunds.create({
      charge: original stripePayment.stripeChargeId
    }, {
      idempotencyKey: `refund-${stripePaymentId}`
    })
    • Returns re_xxx refund ID

    Phase 3 (Confirm):

    h. Update refund stripePayment

    • If Stripe succeeded: updateStripeChargeId(refundPaymentId, re_xxx)
    • If Stripe failed: Log error, flag for manual retry

    i. Log refund event

    • Component: REFUND
    • Includes refund and original payment details
  4. Return result

    • { success: true, refundStripePaymentId, stripeRefundId }

Refund Interaction with Advances

When a refund is processed for a payment whose tracking was already CLOSED (paid out):

  • The system increments payOut.advanceAmountCent by the tracking amount
  • This effectively creates a future offset: the talent "owes back" that money
  • Future earnings will be deducted via the advance offset mechanism

Advance Payout Flow

Overview

Allows pre-paying talent before they've earned the amount. Future earnings automatically offset against the advance balance.

Creating an Advance

  1. Admin triggers advance

    • POST /api/payments/admin/advance-payout
    • Body: { accountId, amountCent }
    • Auth: Admin required
  2. Service Logic (packages/srv-stripe-payment/src/advancePayout.ts)

    • Get payout route, verify KYC
    • Create Stripe Connect transfer: stripe.transfers.create({ amount: amountCent, destination: acct_xxx })
    • Create PayOut record with advanceAmountCent set to amountCent (full amount is advance)
    • Return PayOut

Offsetting Future Earnings

When createShareTrackingBatch() creates a new OPEN tracking record:

  1. Check for advance balance

    • getPayoutsWithAdvanceBalance(payeeAccountId)
    • Returns PayOuts where advanceAmountCent is greater than 0
    • Ordered by createdAt ASC (FIFO - oldest advance first)
  2. If advance exists:

    • If tracking amount is less than or equal to advance remainder:
      • Create tracking with status CLOSED (not OPEN)
      • Link to advance PayOut via payOutId
      • Decrement advance: reduceAdvanceAmount(payoutId, trackingAmount)
    • If tracking amount is greater than advance remainder:
      • Split into two tracking records:
        • Record 1: amount equals advance remainder, status CLOSED, linked to advance
        • Record 2: amount equals tracking amount minus advance remainder, status OPEN
      • Set advanceAmountCent to 0 on payout
  3. If no advance exists:

    • Create tracking with status = OPEN (normal accumulation)

Next Steps