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.amountMinorUnit) 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):
stripeFeeFixedMinorUnit + (amountMinorUnit * STRIPE_FEE_PERCENTAGE_BPS / 10000) where stripeFeeFixedMinorUnit = 30 for normal currencies, 0 for zero-decimal currencies (e.g. JPY)amountMinorUnit - stripeFeeMinorUnit - platformFeeMinorUnitcalculateStripeFees(amountMinorUnit, currency) from @zooly/util handles the zero-decimal logic automaticallyMerch Products (MERCH):
amountMinorUnit - stripeFeeMinorUnitknownAmountMinorUnit (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_minor_unit (= agreed offer amount + Zooly fee)zooly_fee_minor_unit (currently 20% of the agreed offer_amount_minor_unit, not the fixed $5.00 used for IP-term licenses)total_brand_price_minor_unit (same calculateStripeFees helper as other products)computePriceData(totalBrandPriceMinorUnit, zoolyFeeMinorUnit) yields ProductPriceData so talent gross share = total − Stripe fee − Zooly feegetProductByPayForId("OFFER", offerId) requires offer status ACCEPTED (no knownAmountMinorUnit). The brand opens this from the conversation Send Payment drawer after talent Approve — not from brand-first Approve, and not from /offers/[id]/pay. See Offer approval and brand payment.Offers use the same PaymentIntent engine, but authorize-then-capture and defer share tracking until the brand accepts delivery. The UI path is conversation Offer → dual Approve → Send Payment (not a standalone pay page).
create-intent + Stripe Elements in
BrandPayPanel. Stripe holds the card (requires_capture).completePaymentFromClient() sees
requires_capture and moves the offer ACCEPTED → PENDING_PAY_CAPTURE
(applyOfferPendingCapture, awaitingAdminCapture: true). That is
successful payment-in. There is no PPU code yet. The Send Payment drawer
closes; View payment is gone so a second charge cannot start.PAID, marks
stripe_payments SUCCEEDED, and runs applyOfferPaymentEscrow
(payment_stripe_id, auto_release_at ~30 days). No
insertShareTrackingForProduct at this step.releaseOfferEscrowForOffer() loads the
succeeded payment + product, calls insertShareTrackingForProduct, then
marks the offer COMPLETED. OPEN shares accumulate toward payouts as
usual. This path may require the talent's Connect account
(CONNECT_ONBOARDING_REQUIRED) — payout readiness, not Accept.A declined capture or voided auth (7-day cron) returns the offer to
ACCEPTED. Idempotency: duplicate completion paths still use
markStripePaymentCompleted with FOR UPDATE. Escrow release is idempotent
if tracking already exists.
Settlement product lookup when creating tracking uses
getProductByPayForId("OFFER", offerId, payment.amountMinorUnit) so the stored
charge amount cannot drift from the due phase.
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)stripeFeeMinorUnitnull 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): if Stripe is still holding the
authorization (requires_capture), the offer moves to
PENDING_PAY_CAPTURE and the client gets awaitingAdminCapture: true. After capture, applyOfferPaymentEscrow moves the offer to
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, a paid OFFER notifies both sides: a PAYMENT_RECEIVED notification to the talent (delivery prompt) and a PAYMENT_CONFIRMED notification to the brand (payment receipt). Both are emailed by the notification email cron. A failure here is logged and does not undo the payment.
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.minimumPayoutAmountMinorUnit (default $100):
Two-Phase Commit:
Phase 1 (DB):
createPayOut({ status: "PENDING", payeeAccountId, amountMinorUnit: sumMinorUnit })getOpenTrackingByAccount(accountId)closeTrackingRecords(trackingIds, payOutId)Phase 2 (Stripe):
getPayoutRouteByAccountId(accountId)kycVerified is true (required)stripe.transfers.create({
amount: sumMinorUnit,
currency: payoutCurrency.toLowerCase(), // e.g. "usd", "jpy"
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
REFUNDEDamountMinorUnit set to negative original amountstripeFeeMinorUnit 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 IDamountMinorUnit 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.advanceAmountMinorUnit 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, amountMinorUnit }Service Logic (packages/srv-stripe-payment/src/advancePayout.ts)
stripe.transfers.create({ amount: amountMinorUnit, destination: acct_xxx })advanceAmountMinorUnit set to amountMinorUnit (full amount is advance)When createShareTrackingBatch() creates a new OPEN tracking record:
Check for advance balance
getPayoutsWithAdvanceBalance(payeeAccountId)advanceAmountMinorUnit is greater than 0createdAt ASC (FIFO - oldest advance first)If advance exists:
CLOSED (not OPEN)payOutIdreduceAdvanceAmount(payoutId, trackingAmount)CLOSED, linked to advanceOPENadvanceAmountMinorUnit 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