Offers System Overview

Overview of the IP licensing offers backend

What is the Offers System?

The Offers System lets content creators (buyers / brands) submit licensing offers to account owners (sellers / talents) for use of their IP — AI-generated images for ads, voice-overs for radio spots, video, music, and so on. Brands discover talents via Z-links (e.g. /z/alex-rivera-a3f2), pick a campaign type, fill out the brief, optionally generate candidate images or voice reads, and submit. Talents review offers in their Agent dashboard and can accept, reject, or counter — including multi-field counters (price, usage, sharing, script, picked sample, picked voice, image kind), not just price. Brands then authorize and capture payment via Stripe; talents deliver; brands mark complete (or the auto-release timer does it for them).

Key Features

  • Authenticated submission, brand or admin gating throughout. buyerAccountId is resolved from the session cookie; perspective=admin requires the admin role.
  • Fourteen-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)
  • Bouncer queue, reviewed once. The original offer routes through ADMIN_REVIEW; admins get an email plus an in-app notification. Post-approval counters stay private between brand and talent — they don't re-enter the queue.
  • Below-threshold policies. When an offer is under the talent's minimum, the talent's policy decides: flag, ask_talent, auto_counter, or auto_reject (the last two skip admin review).
  • Authorize-then-capture payment. Brands' cards are authorized (PENDING_PAY_CAPTURE) then captured (PAID); 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 14-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) — accept / reject / counter (with counterChanges)
  • 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: collects the brief, drives the image/voice candidate sessions, lets the brand pick imageKind and the model key, calls submitOffer().
  • AgentLayout / AgentSidebar / AgentDetailPage: list offers, render the right action bar per status, support multi-field counters, and show counter diffs.
  • VerificationPage: drives the Stripe Connect onboarding state machine (needs-connect / needs-completion / connected).
  • Brand offer pages (zooly-app, /offers/[id]/pay and /offers/[id]): Stripe Elements payment, deliverable preview, "Mark complete".
  • Shared offer components live in packages/ui-offers (OfferActionBar, OfferStatusBadge, OfferSummaryCard, DeliverablePreview, …).
  • Sidebar bell: real pending-offer count, mark-as-read on click.