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(ADMIN_REVIEW) API->>DB: OFFER_SUBMITTED notification to the brand API->>API: sendAdminOfferSubmittedEmail (admin heads-up email) 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 (skipping admin review) 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 /agent/z 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).

The original offer is admin-reviewed once (at submit). After that, counters between brand and talent stay private: respond routes a counter to ADMIN_REVIEW only when adminReviewedAt is still null; otherwise it goes straight to COUNTERED and notifies the other party directly — it does not re-enter the bouncer queue.

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 } Note over API: adminReviewedAt already set (offer was approved) API->>DB: updateOfferStatus(id, → COUNTERED, counterData) DB-->>API: offer with counter_changes jsonb (proposal stored, canonical columns untouched) API->>Brand: OFFER_COUNTERED notification (no admin re-review) API-->>Talent: { offer } Note over Talent,Brand: Either side can counter again (COUNTERED → COUNTERED) until one accepts.

The two counter paths differ in when the deltas hit the canonical columns:

  • First counter → ADMIN_REVIEW (only while adminReviewedAt is null): the proposed deltas are applied straight onto the canonical columns and fees recomputed before the offer re-enters review.
  • Private counter → COUNTERED (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.

Either way 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 14 states. The diagram below mirrors OFFER_TRANSITIONS exactly.

stateDiagram-v2 [*] --> DRAFT: Buyer saves a draft DRAFT --> ADMIN_REVIEW: Buyer submits DRAFT --> COUNTERED: Below-threshold auto-counter DRAFT --> REJECTED: Below-threshold auto-reject DRAFT --> CANCELLED ADMIN_REVIEW --> APPROVED: Admin approves ADMIN_REVIEW --> REJECTED: Admin rejects 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, skipping admin review.
  • ADMIN_REVIEW is the bouncer queue. The original offer passes through it once on submit (admins get sendAdminOfferSubmittedEmail; the brand gets an OFFER_SUBMITTED notification). A first counter while adminReviewedAt is still null also routes here and notifies admins with an in-app OFFER_COUNTERED; each row of a blast offer enters here too.
  • Post-approval counters stay private between brand and talent: APPROVED/COUNTERED → COUNTERED does not re-enter admin review. 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.