Offers Architecture

Data flow and lifecycle of the offers system

Data Flow

Buyer submits offer (authenticated)

sequenceDiagram participant Browser participant ZLinkPage as ZLinkPage (offers SPA) participant API as zooly-app API participant Auth as Auth (cookie) participant DB as Postgres Browser->>ZLinkPage: Visit /z/:slug (must be logged in) ZLinkPage->>API: POST /api/offers/submit (credentials: include) API->>Auth: resolveAccountId(cookie) Auth-->>API: buyerAccountId API->>DB: getAccountBySlug(slug) DB-->>API: sellerAccount API->>DB: createOffer (status DRAFT) → updateOfferStatus(APPROVED) API->>DB: OFFER_RECEIVED notification to the talent API->>DB: OFFER_SUBMITTED notification to the brand DB-->>API: offer API-->>ZLinkPage: { offerId }

For IMAGE campaigns, ZLinkPage first creates an imageCandidateSession, polls for the multi-model candidate set (OpenAI / Nano-Banana / Flux), the brand picks one, and only then submitOffer fires with imageCandidateSessionId, selectedModelKey, and the chosen imageKind. VOICE campaigns do the same through a voiceCandidateSession (voiceCandidateSessionId + selectedVoiceModelKey); VIDEO requires both image and voice. Brands can skip image generation entirely with skipImageGeneration: true. Drafts can be saved before any candidate is picked (saveAsDraft: true).

If the offer comes in below the talent's effective minimum and the talent has chosen the auto_reject or auto_counter below-threshold policy, submit short-circuits: the offer is written straight from DRAFT to REJECTED or COUNTERED instead of reaching the talent as a normal offer, and both parties are notified.

Unauthenticated users visiting a Z-link are redirected to login by App.tsx. After login, returnTo brings them back to the Z-link page.

Seller lists offers (authenticated)

sequenceDiagram participant Browser participant AgentSidebar as AgentSidebar (offers SPA) participant API as zooly-app API participant Auth as Auth (cookie) participant DB as Postgres Browser->>AgentSidebar: Navigate to /dashboard/agent/offers AgentSidebar->>API: GET /api/offers/list?perspective=seller (credentials: include) API->>Auth: resolveAccountId(cookie) Auth-->>API: accountId API->>DB: listOffersBySellerAccount(accountId, status?, limit, offset) DB-->>API: { offers, total } API-->>AgentSidebar: { offers, buyerAccounts, sellerAccounts, total, limit, offset }

Talent counters with multiple field changes

A talent (or the brand) can propose changes to more than the price — usage types, sharing options, image kind, script text, the chosen sample image, or the chosen voice read — using the counterChanges whitelist (offerCounterChangesSchema).

Every counter stays between brand and talent: respond routes it to COUNTERED and notifies the other party directly.

sequenceDiagram participant Talent as Talent (AgentDetailPage) participant API as zooly-app API participant DB as Postgres participant Brand Talent->>API: POST /api/offers/respond { action: "counter", counterNote, counterChanges } API->>DB: updateOfferStatus(id, → COUNTERED, counterData) DB-->>API: offer with counter_changes jsonb (proposal stored, canonical columns untouched) API->>Brand: OFFER_COUNTERED notification API-->>Talent: { offer } Note over Talent,Brand: Either side can counter again (COUNTERED → COUNTERED) until one accepts.

COUNTERED is a holding state: the proposal is stored in counter_changes jsonb only; the canonical columns and fees are left untouched until the other side accepts (COUNTERED → ACCEPTED), at which point the stored deltas are merged and fees recomputed. The raw proposal stays in counter_changes jsonb for audit and brand-side diff display.

Status Lifecycle

The full FSM lives in packages/types/src/types/Offer.ts (OFFER_TRANSITIONS). It has 13 states. The diagram below mirrors OFFER_TRANSITIONS exactly.

stateDiagram-v2 [*] --> DRAFT: Buyer saves a draft DRAFT --> APPROVED: Buyer submits (offer reaches the talent) DRAFT --> COUNTERED: Below-threshold auto-counter DRAFT --> REJECTED: Below-threshold auto-reject DRAFT --> CANCELLED APPROVED --> ACCEPTED: Talent accepts APPROVED --> COUNTERED: Either side counters (private) APPROVED --> REJECTED APPROVED --> CANCELLED APPROVED --> EXPIRED: Auto-expiry cron COUNTERED --> ACCEPTED: Other side accepts counter COUNTERED --> COUNTERED: Either side counters again COUNTERED --> REJECTED COUNTERED --> CANCELLED COUNTERED --> EXPIRED: Auto-expiry cron ACCEPTED --> PENDING_PAY_CAPTURE: Brand authorizes card (Stripe hold) ACCEPTED --> CANCELLED PENDING_PAY_CAPTURE --> PAID: Capture released (admin/auto) PENDING_PAY_CAPTURE --> ACCEPTED: Capture declined / auth voided PENDING_PAY_CAPTURE --> CANCELLED PAID --> DELIVERED: Talent uploads deliverable DELIVERED --> COMPLETED: Brand marks complete (or auto-release) DELIVERED --> REVISION_REQUESTED: Brand asks for changes DELIVERED --> DISPUTED REVISION_REQUESTED --> DELIVERED COMPLETED --> DISPUTED DISPUTED --> COMPLETED REJECTED --> [*] CANCELLED --> [*] EXPIRED --> [*]

Notable behaviour:

  • DRAFT is the buyer-only working state. saveAsDraft: true on submit keeps the row here. Below-threshold auto_reject / auto_counter policies transition straight out of DRAFT to REJECTED / COUNTERED instead of reaching the talent.
  • APPROVED means the offer is live with the talent. There is no moderation step: submit takes the offer straight from DRAFT to APPROVED (the talent gets OFFER_RECEIVED, the brand gets OFFER_SUBMITTED), and each row of a blast offer is inserted here directly.
  • Counters stay between brand and talent: APPROVED/COUNTERED → COUNTERED, and COUNTERED → COUNTERED lets each side counter repeatedly until one accepts.
  • ACCEPTED → PENDING_PAY_CAPTURE → PAID is a Stripe authorize-then-capture flow. The brand's card is authorized (a hold is placed, payment_authorized_at recorded) on the amount_capturable_updated webhook, then captured to reach PAID. A declined capture or a voided authorization (e.g. the expire-pending-captures cron approaching Stripe's 7-day auth limit) returns the offer to ACCEPTED.
  • EXPIRED is a terminal state reached from APPROVED/COUNTERED by the auto-expiry cron when expires_at elapses and the offer's expire_policy allows it. See Auto-expiry.
  • Talent payouts use Stripe Connect Express. See Stripe Connect (Talent Payouts) for the onboarding flow, endpoints, and webhook.
  • auto_release_at on DELIVERED lets a background worker (/api/offers/cron/auto-release) promote the offer to COMPLETED if the brand never marks it complete.

Auto-expiry

Offers waiting on a counter-party (APPROVED / COUNTERED) carry an expires_at deadline derived from expires_in_days (brand override, default 30). The hourly /api/offers/cron/expire worker:

  1. Loads offers past expires_at (getOffersForExpiry).
  2. For the remind_talent policy, fires one OFFER_STALE_REMINDER, bumps the deadline, and records stale_reminder_sent_at (the dedup signal).
  3. Once a reminded offer elapses again — or immediately for the expire policy — transitions it to EXPIRED and emits OFFER_EXPIRED.

ping_brand is schema-only for now; the cron logs and skips it. Entering APPROVED or COUNTERED resets the clock (re-derives expires_at, clears stale_reminder_sent_at).

CORS

All offer endpoints use getCorsHeaders(origin) based on ALLOWED_DOMAINS_CORS. The zooly-app2 origin must be in that env var for cross-origin requests to succeed.