Offers System Overview

Overview of the IP licensing offers backend

What is the Offers System?

The Offers System is the physical record of a licensing deal. Most live deals start as a conversation on a public Z-link. When terms settle, Offer-Gen mints a one-page offer. Both sides Approve on that document (brand first is thanks only; talent Approve is accept). The brand then opens Send Payment, pays the due slice plus a 20% Zooly fee, and Stripe holds the card (PENDING_PAY_CAPTURE → later PAID). Talents deliver; brands mark complete (or the auto-release timer does). Stripe Connect is how talents get paid out, not how they accept or how the brand pays in.

The full click-by-click path is Offer approval and brand payment.

A second path still exists: authenticated Z-link forms (ZLinkPage) that submit POST /api/offers/submit with campaign type, brief, and optional image/voice candidates. Talents can still reject or counter (including multi-field counters: price, usage, sharing, script, picked sample, picked voice, image kind).

Public Z-links emit server-rendered Open Graph and Twitter metadata using the talent name (for example, “Make an offer to Alex Rivera via their AI agent”), so the intended call to action is preserved when a link is shared. In the dashboard, both the live and preview URL fields are links that open in a new tab; the adjacent copy and QR actions remain available.

Key Features

  • Authenticated submission, brand or admin gating throughout. buyerAccountId is resolved from the session cookie; perspective=admin requires the admin role.
  • Thirteen-state FSM enforced in OFFER_TRANSITIONS (packages/types/src/types/Offer.ts). See Architecture for the diagram.
  • Drafts. Brands can save a partial offer with saveAsDraft: true, resume from /dashboard/drafts, and submit when ready. Drafts are exempt from the "candidate session required" rule.
  • Multi-model image + voice candidates. IMAGE campaigns fan out to multiple image models (OpenAI, Nano-Banana, Flux); VOICE campaigns generate voice reads; VIDEO requires both. The brand picks a model key (and optionally flips imageKind between DRAFT/FINAL) at submit. skipImageGeneration lets a brand bypass image generation.
  • Multi-currency. Every monetary column is *_minor_unit (integer) plus a currency ISO 4217 code. Zero-decimal currencies handled in packages/util/src/currency.ts. (6a07f00f)
  • Multi-field counters. counter_changes jsonb carries a whitelisted partial of fields a talent (or brand) may propose. Applied onto the canonical columns and fees recomputed. (3b6f6cbf)
  • No moderation step. A submitted offer goes straight to the talent (DRAFT → APPROVED); counters stay between brand and talent.
  • Below-threshold policies. When an offer is under the seller's minimum, the Bouncer policy decides: flag, ask_principal, auto_counter, or auto_reject (the last two never reach the seller as a normal offer).
  • Dual Approve then pay-in. Brand Approve on the document is acknowledgement (brand_approved_at) and does not charge. A material rewrite of those terms clears that stamp; the brand must Approve again. Talent Approve is accept (ACCEPTED) and does not require Connect. Conversation Counter / Reject are messages to Z; a counter reaches the written offer only after Z's last-look in the chat. The brand then opens View payment / Send Payment for the due slice plus a 20% fee. See Offer approval and brand payment.
  • Authorize-then-capture payment. A successful card is a hold (PENDING_PAY_CAPTURE); capture to PAID is admin/auto. An admin queue + cron handle release and 7-day auth voiding.
  • Auto-expiry. Pending offers carry expires_at / expire_policy; a cron sends stale reminders and transitions to EXPIRED.
  • Stripe payouts. Talents onboard through Stripe Connect Express — see Stripe Connect (Talent Payouts). (ef054780)
  • Idempotency on respond / deliver / complete via Idempotency-Key.
  • CORS opt-in on every endpoint for the zooly-app2 (Vite) origin to call zooly-app (Next.js).

System Components

1. Database Layer (packages/db)

  • Schema: offers table with the 13-state offer_status enum, the offer_expire_policy enum, and the image_kind enum. counter_changes jsonb for multi-field counters.
  • Access functions: createOffer, getOfferById, updateOfferStatus, updateOfferDraft, listOffersBySellerAccount, listOffersByBuyerAccount, listAllOffers, the payment/escrow helpers, the below-threshold auto-actions, and the cron query helpers. See API reference.

2. Contracts Layer (packages/contracts)

  • Zod schemas for every endpoint: submitOfferBodySchema, respondToOfferBodySchema (with offerCounterChangesSchema), deliverOfferBodySchema, completeOfferBodySchema, requestRevisionBodySchema, disputeOfferBodySchema, the admin capture schemas, getOfferResponseSchema, listOffersQuerySchema / listOffersResponseSchema.
  • OfferStatus, ExpirePolicy, OfferCounterChanges, OFFER_TRANSITIONS, Offer, NewOffer, OfferHistory types in @zooly/types.

3. API Layer (apps/zooly-app)

Core lifecycle endpoints under /api/offers/:

  • Submit (POST /api/offers/submit)
  • List (GET /api/offers/list) — seller / buyer / admin perspectives
  • Get (GET /api/offers/[id])
  • Respond (POST /api/offers/respond) — talent accept / reject / counter (with counterChanges). Accept does not require Connect. Conversation Offer Counter / Reject never call this on the click; a counter confirmed in the chat reaches the same transition after Z's last-look.
  • Brand approve (POST /api/offers/approve) — sets brand_approved_at without changing status; wakes the talent-side agent.
  • Deliver (POST /api/offers/deliver)
  • Complete (POST /api/offers/complete)
  • Revision (POST /api/offers/revision), Dispute (POST /api/offers/dispute), Cancel (POST /api/offers/cancel)

Plus admin, candidate-session, blast, and cron routes — see the full endpoint list.

Stripe Connect onboarding for talent payouts: /api/payments/connect/start + /api/payments/connect/return plus the account.updated webhook (packages/srv-stripe-payment). Full walkthrough in Stripe Connect (Talent Payouts).

4. Client API (packages/offers/client)

submitOffer(), fetchOffers(), respondToOffer(), fetchPayoutRoute(), startStripeConnect() in packages/offers/client/src/lib/appApi.ts. New code routes through @zooly/api-client (W1-01 foundations).

5. UI Integration

The offers UI is a single React SPA in packages/offers/client (exported as App), mounted by both the Next.js zooly-app and the Vite zooly-app2 over the route subtrees it owns (/z, /agent, /dashboard).

  • ZLinkPage: guest or signed-in chat on /z/:slug. Formal offers are usually minted from that conversation (Offer-Gen), not from a campaign form. A legacy form path still calls submitOffer() with image/voice candidates.
  • Conversation Offer document: ConversationsPage at /dashboard/agent/conversations/:roomId?view=offer. The Offer tab stays disabled until a formal offer exists. Approve is the only direct write. Counter / Reject / Add on a Z recommend switch to Conversation as chat lines; Z asks last-look, and a counter reaches the written offer only after the answer. After Z writes, a one-sided brand stamp is cleared. After talent Approve the brand sees View payment → Send Payment. See Offer approval and brand payment.
  • Agreement view (?view=agreement): lawyer-reviewable draft that AgreementAg writes from the live Offer.
  • VerificationPage: Stripe Connect onboarding for payouts (needs-connect / needs-completion / connected). Not part of Accept or brand pay-in.
  • Shared offer components live in packages/ui-offers (OfferActionBar, OfferStatusBadge, OfferSummaryCard, DeliverablePreview, …).