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).
Product creation (prerequisite)
POST /api/my-product/create)PaymentProduct with payFor, payForId, sellerAccountId, and priceDataBuyer initiates checkout
POST /api/payments/create-intentpayFor, payForId, optional hostPartnerSlugAPI Route (apps/zooly-app/app/api/payments/create-intent/route.ts)
getVerifiedUserInfo()@zooly/srv-stripe-paymentService Logic (packages/srv-stripe-payment/src/createPaymentIntent.ts)
getProductByPayForId(payFor, payForId)
priceData.amountCent) and seller (sellerAccountId)createOrGetStripeCustomer(email, name)stripe_payments DB record with amount from product tablestripePaymentIntentId{ stripePaymentId, stripeClientSecret, stripePublishableKey }Frontend receives clientSecret
confirmPayment() to process paymentPrice 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_FIXED_CENT + (amountCent * STRIPE_FEE_PERCENTAGE_BPS / 10000)amountCent - stripeFeeCent - platformFeeCentMerch Products (MERCH):
amountCent - stripeFeeCentknownAmountCent (cart total calculated by the merch route), not from a product table rowsellerAccountId from merch_campaign (currently defaults to "zooly_acc")Offer / marketplace products (OFFER):
offers table (payForId = offer id)total_brand_price_cent (= agreed offer amount + Zooly fee)zooly_fee_cent (currently 20% of the agreed offer_amount_cent, not the fixed $5.00 used for IP-term licenses)total_brand_price_cent (same calculateStripeFees helper as other products)computePriceData(totalBrandPriceCent, zoolyFeeCent) yields ProductPriceData so talent gross share = total − Stripe fee − Zooly feegetProductByPayForId("OFFER", offerId) requires offer status ACCEPTED (no knownAmountCent)Offers use the same PaymentIntent and completePayment() engine, but defer share tracking until the brand accepts delivery:
Charge succeeds — completePayment() 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.
Talent delivers — normal offer APIs (outside the generic payment routes).
Brand accepts delivery — releaseOfferEscrowForOffer() (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.
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 payments differ from standard IP term payments:
buyerUserId.sellerAccountId comes from merch_campaign.sellerAccountId (currently defaults to "zooly_acc" — platform system account).Fan completes shipping form and navigates to payment page
Payment page fetches pricing (POST /api/merch/calc-price)
{ subtotal, shippingCost, total, stripePublishableKey, allowSkipPayment }Payment page creates PI (POST /api/merch/create-payment-intent)
sellerAccountIdcreateMerchPaymentIntent() from @zooly/srv-stripe-payment:
stripe_payment record (payFor: "MERCH", payForId: sessionId, buyerUserId: sessionId, buyerAccountId: null)merch-pi-{paymentId} and metadata { stripePaymentId, merchSessionId }paymentIntentId (Stripe PI ID) and stripePaymentDbId (internal ID){ stripePaymentId, stripeClientSecret, stripePublishableKey }Fan pays via Stripe.js — stripe.confirmPayment() processes card
Confirm page calls /api/merch/order/complete
session.stripePaymentDbIdcompletePaymentFromClient(stripePaymentDbId):
completePayment() (same atomic engine as IP terms)createOrGetMerchOrderFromSession()Webhook fallback — if client crashes, charge.succeeded webhook completes the payment via the same idempotent completePayment() function
Tester sessions (role === "tester") skip Stripe entirely:
/api/merch/order/complete with ppuCode: "DEMO_SKIP_PAYMENT"session.role === "tester", creates order without payment verificationPayment 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.
Frontend calls POST /api/payments/complete
confirmPayment() returns successfully (or after Stripe redirect){ stripePaymentId } to the endpointClient Completion Service (completePaymentFromClient() in packages/srv-stripe-payment/src/completePayment.ts)
SUCCEEDED: returns existing purchase code (fast path)CREATED: queries Stripe API via retrieveStripePaymentIntent(pi_xxx) with expanded latest_chargesucceeded: extracts charge ID (ch_xxx) from latest_charge and calls completePayment()getProductByPayForId()Stripe fires charge.succeeded webhook
Webhook Handler (apps/zooly-app/app/api/payments/webhook/stripe/route.ts)
verifyWebhookSignature(body, signature)stripePaymentId from charge metadatacompletePayment(stripePaymentId, chargeId) from service layercompletePayment()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)
FOR UPDATE row lock to prevent double-spendCREATED to SUCCEEDEDstripeChargeId (ch_xxx)stripeFeeCentnull if already completed (idempotent)b. Revenue distribution (product-dependent)
payFor !== "OFFER": insertShareTrackingForProduct (insertShareTrackingForProduct.ts) in the same transaction:
getShareEligibleAgents(sellerAccountId)createShareTrackingBatch: AGENT (optional, OPEN), TALENT net (OPEN), STRIPE_FEE → stripe_acc (CLOSED), PLATFORM → zooly_acc (CLOSED)OPEN row (close or split against payOutId / reduceAdvanceAmount) — same logic as before this helper was extractedpayFor === "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)
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)
{ ppuCode, stripePayment, trackingRecords }stillProcessing: trueBoth 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 SUCCEEDEDSUCCEEDED, returns existing purchase code without creating duplicatesDaily cron job (2 PM UTC) processes payouts for accounts that have accumulated $100 or more in OPEN tracking records.
Cron Trigger (apps/zooly-app/app/api/payments/process-payouts/route.ts)
CRON_SECRET bearer token)payoutDaemon() from service layerPayout Daemon (packages/srv-stripe-payment/src/payoutDaemon.ts)
a. Acquire distributed lock
processLock mechanism (10-minute TTL)b. Find accounts needing inspection
account_payment_settings where:
lastPayoutInspectionAt IS NULL ORlastPayoutInspectionAt < NOW() - INTERVAL '1 day'zooly_acc, stripe_acc)c. For each account:
i. Sum OPEN tracking records
getOpenTrackingSumByAccount(accountId)ii. Check threshold
If sum is greater than or equal to accountPaymentSettings.minimumPayoutAmountCent (default $100):
Two-Phase Commit:
Phase 1 (DB):
createPayOut({ status: "PENDING", payeeAccountId, amountCent: sumCent })getOpenTrackingByAccount(accountId)closeTrackingRecords(trackingIds, payOutId)Phase 2 (Stripe):
getPayoutRouteByAccountId(accountId)kycVerified is true (required)stripe.transfers.create({
amount: sumCent,
currency: 'usd',
destination: stripeConnectAccountId,
idempotencyKey: `payout-${payOutId}`
})
Phase 3 (Confirm):
updatePayOutStatus(payOutId, "CHARGE", stripeTransferId)updatePayOutStatus(payOutId, "CANCELED")cancelPayoutAndReopenTracking(payOutId, trackingIds)emailOutstandingPayout is false)emailOutstandingPayout to trueiii. Update inspection timestamp
updateLastPayoutInspection(accountId)d. Release distributed lock
Return result
{ processed, skipped, errors }Prevents double-payout if Stripe transfer succeeds but DB write fails:
If Phase 3 fails after successful Stripe transfer, the idempotency key prevents duplicate transfers on retry.
Admin-initiated refunds create negative payment records, cancel original tracking, and adjust advance balances if tracking was already paid out.
Admin triggers refund
POST /api/payments/refund{ stripePaymentId }API Route (apps/zooly-app/app/api/payments/refund/route.ts)
processRefund(stripePaymentId) from service layerRefund Processing (packages/srv-stripe-payment/src/refund.ts)
Phase 1 (DB-first - all reversals in one transaction):
a. Get original payment + tracking
SUCCEEDED (can't refund already-refunded)listTrackingByStripePayment(stripePaymentId)b. SELECT FOR UPDATE on original payment
c. Create negative stripePayment record
REFUNDEDamountCent set to negative original amountstripeFeeCent set to original stripe fee (preserved for audit)canceledByStripePaymentId set to null (this is the refund record)d. Update original stripePayment
REFUNDEDcanceledByStripePaymentId set to refund stripe payment IDe. For each original tracking record:
stripePaymentId set to refund stripe payment IDamountCent set to negative original tracking amountREFUNDEDCANCELEDcanceledByTrackingId set to refund tracking IDincrementAdvanceAmount(payOutId, trackingAmount)f. COMMIT transaction
Phase 2 (Stripe - with idempotency key):
g. Create Stripe refund
stripe.refunds.create({
charge: original stripePayment.stripeChargeId
}, {
idempotencyKey: `refund-${stripePaymentId}`
})
re_xxx refund IDPhase 3 (Confirm):
h. Update refund stripePayment
updateStripeChargeId(refundPaymentId, re_xxx)i. Log refund event
REFUNDReturn result
{ success: true, refundStripePaymentId, stripeRefundId }When a refund is processed for a payment whose tracking was already CLOSED (paid out):
payOut.advanceAmountCent by the tracking amountAllows pre-paying talent before they've earned the amount. Future earnings automatically offset against the advance balance.
Admin triggers advance
POST /api/payments/admin/advance-payout{ accountId, amountCent }Service Logic (packages/srv-stripe-payment/src/advancePayout.ts)
stripe.transfers.create({ amount: amountCent, destination: acct_xxx })advanceAmountCent set to amountCent (full amount is advance)When createShareTrackingBatch() creates a new OPEN tracking record:
Check for advance balance
getPayoutsWithAdvanceBalance(payeeAccountId)advanceAmountCent is greater than 0createdAt ASC (FIFO - oldest advance first)If advance exists:
CLOSED (not OPEN)payOutIdreduceAdvanceAmount(payoutId, trackingAmount)CLOSED, linked to advanceOPENadvanceAmountCent to 0 on payoutIf no advance exists:
status = OPEN (normal accumulation)On This Page
Payment Intent Creation FlowOverviewStep-by-StepPricing via Product TableOffer marketplace payments (escrow)Merch Payment FlowOverviewStep-by-StepDemo ModePayment Completion FlowOverviewPrimary Path: Client EndpointFallback Path: WebhookShared Completion Engine: ,[object Object]Return ResultIdempotencyPayout Processing FlowOverviewStep-by-StepTwo-Phase Commit PatternRefund FlowOverviewStep-by-StepRefund Interaction with AdvancesAdvance Payout FlowOverviewCreating an AdvanceOffsetting Future EarningsNext Steps