Pricing Consistency

Server-side fee calculation and validation for offers

Pricing Formula

Every offer has three price fields, all computed server-side:

FieldFormulaDescription
offerAmountMinorUnitInput from buyerWhat the talent receives
zoolyFeeMinorUnitMath.round(offerAmountMinorUnit × 0.20)20% Zooly platform fee
totalBrandPriceMinorUnitofferAmountMinorUnit + zoolyFeeMinorUnitWhat the brand pays

The client sends only offerAmountMinorUnit. The server computes and stores the other two values. Client-sent fee fields are ignored.

Where Fees Are Computed

On Offer Creation

createOffer() in packages/db/src/access/offers.ts computes zoolyFeeMinorUnit and totalBrandPriceMinorUnit from the provided offerAmountMinorUnit before inserting the row.

On Counter-Offers

updateOfferStatus() in packages/db/src/access/offers.ts recomputes both fee fields whenever a countered amount is applied to the canonical offer_amount_minor_unit column. A counter through POST /api/offers/respond (→ COUNTERED) only stores the proposal, so fees are recomputed when the other side accepts (COUNTERED → ACCEPTED).

Conversation Offer Counter reaches the same stores-only transition through the chat: the click posts a chat line to the actor's agent, Z asks last-look, and only after the answer the agent records the counter. The written amount (and fees) stay until the other side accepts — or until Offer-Update rewrites the row. See Offer approval and brand payment.

updateOfferDraft() likewise recomputes fees when a draft edit changes the amount.

On Offer Submission

POST /api/offers/submit only accepts offerAmountMinorUnit (positive integer). The submitOfferBodySchema contract has no fee fields at all, so any client-sent zoolyFeeMinorUnit / totalBrandPriceMinorUnit are dropped at parse time before reaching createOffer.

Payment Validation

When a payment intent is created for an offer, getOfferProduct() in packages/srv-stripe-payment/src/getProductByPayForId.ts requires status ACCEPTED and charges the due phase, not necessarily the full deal:

  1. Loads the offer by ID
  2. Resolves the unpaid phase via offerDueCharge (example: 50% on approval)
  3. Asserts the stored full-deal zoolyFeeMinorUnit matches Math.round(offerAmountMinorUnit × 0.20)
  4. Asserts the stored totalBrandPriceMinorUnit matches offerAmountMinorUnit + zoolyFeeMinorUnit
  5. Charges due talent slice + 20% of that slice (Send Payment "Charged today")
  6. If either full-deal assertion fails, or there is no unpaid phase, the payment intent is rejected

This prevents price drift if the fee percentage changes or if stored values are manually edited. A later phase is not due until the earlier one is paid.

Price Data for Stripe

The computePriceData(amountMinorUnit, platformFeeMinorUnit) function in packages/srv-stripe-payment/src/getProductByPayForId.ts produces the full ProductPriceData breakdown used by Stripe:

  • Total charge amount (what brand pays)
  • Stripe processing fee (calculated via calculateStripeFees(amount, currency) from @zooly/util — the fixed fee component is 0 for zero-decimal currencies)
  • Platform fee (the 20% Zooly fee)
  • Talent gross share (total minus Stripe fee minus platform fee)

This function is exported from @zooly/srv-stripe-payment for use in escrow release and other payment flows.

Key Files

FileRole
packages/db/src/access/offers.tsFee computation in createOffer and updateOfferStatus
apps/zooly-app/app/api/offers/submit/route.tsInput validation, strips client fee fields
packages/srv-stripe-payment/src/getProductByPayForId.tsPayment-time consistency assertion, computePriceData