Offers Architecture

Data flow and lifecycle of the offers system

Data Flow

Buyer submits offer (authenticated)

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 visitors can start talking on a Z-link as guests. The product holds the thread behind a login gate only when an account is required to move the deal (for example before a formal offer is minted). After signup through that gate, returnTo keeps them in the same conversation. See Offer approval and brand payment.

A legacy authenticated form path (ZLinkPage → POST /api/offers/submit) still exists; the sequence below describes that path.

Seller lists offers (authenticated)

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 through POST /api/offers/respond stays between brand and talent: respond routes it to COUNTERED and notifies the other party directly. That is the Z-link form / auto-counter path.

The conversation Offer tab is different: only Approve writes directly there. Counter and Reject are chat to Z (through the live Conversation chat). The click never calls respond; Z asks last-look, and only after the answer the persona records the counter — through the same COUNTERED transition and notification below, now triggered from the chat. See Offer approval and brand payment.

This sequence is the respond API path (Z-link forms, auto-counter). Conversation Offer Counter reaches the same transition only after Z's last-look in the chat — never on the click.

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.

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 seller (submitted by the buyer; awaiting the seller's decision). There is no moderation step: submit takes the offer straight from DRAFT to APPROVED (the seller gets OFFER_RECEIVED, the buyer gets OFFER_SUBMITTED), and each row of a blast offer is inserted here directly. Note for Z-Agent: this maps to WRITTEN_OFFER_AWAITING_TALENT, or BRAND_ACKED_AWAITING_TALENT once brand_approved_at is set. Neither is a booking. The offers-system word APPROVED is never shown to agents as a stage.
  • ACCEPTED means the talent approved the written offer (or the brand accepted the talent's counter). This is the booking: BOOKED_AWAITING_PAYMENT. Talent Accept does not require Stripe Connect. See Z-agent deal stages for the full status → stage map.
  • Brand Approve on the conversation document (POST /api/offers/approve) sets brand_approved_at and does not change status. That click is thanks / Approval sent, not payment. A material rewrite of the written terms (Offer-Update after Z is asked to change them, including Add or Counter from Offer) clears brand_approved_at (BRAND_APPROVAL_CLEARED); the brand must Approve the new version before talent can book (ZLY-1932). View payment / Send Payment is offered to the brand only while status is ACCEPTED.
  • Counters via respond stay between brand and talent: APPROVED/COUNTERED → COUNTERED, and COUNTERED → COUNTERED lets each side counter repeatedly until one accepts. Conversation Offer Counter does not take this transition until Z rewrites the row.
  • ACCEPTED → PENDING_PAY_CAPTURE → PAID is a Stripe authorize-then-capture flow. The brand pays the due slice (plus 20% fee) from Send Payment. The card is authorized (a hold is placed, payment_authorized_at recorded) on the amount_capturable_updated webhook or client complete path; PENDING_PAY_CAPTURE is successful payment-in. Capture reaches 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. Connect is not part of Accept or brand pay-in. See Stripe Connect (Talent Payouts).
  • 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.