TBBN.Merchant Platform docs
Architecturedocs/architecture/db-erd.md

Database ERD

System of record: PostgreSQL, schema authored in packages/db-schema (Prisma). This doc is the narrative companion — packages/db-schema/prisma/schema.prisma is the executable source of truth; keep them in sync.

All tables: UUIDv7 primary keys (id), created_at/updated_at as timestamptz, soft-delete via nullable deleted_at on user-facing rows (listings, offers), hard rows elsewhere.

Domain: Merchant (WORKING)

erDiagram
    MERCHANTS ||--o{ MERCHANT_USERS : has
    MERCHANTS ||--o{ API_KEYS : has
    MERCHANTS ||--|| MERCHANT_SETTINGS : has
    MERCHANTS ||--o{ MERCHANT_APPLICATIONS : "originates from"
    USERS ||--o{ MERCHANT_USERS : "is a"

    MERCHANTS {
      uuid id PK
      string legal_name
      string display_name
      string slug
      enum tier "SANDBOX|STARTER|GROWTH|ENTERPRISE"
      enum status "TEMP_PENDING|UNDER_REVIEW|APPROVED|SUSPENDED|REJECTED"
      string[] listing_mode_allowed "GLOBAL|PRIVATE|BOTH|ENTERPRISE"
      string country
      string default_currency
      timestamptz approved_at
    }
    MERCHANT_APPLICATIONS {
      uuid id PK
      uuid merchant_id FK "nullable until approved"
      string rep_email
      string rep_name
      timestamptz temp_account_expires_at
      jsonb submitted_docs
      enum status
      string reviewer_notes
    }
    MERCHANT_USERS {
      uuid id PK
      uuid merchant_id FK
      uuid user_id FK
      enum role "OWNER|ADMIN|DEVELOPER|ACCOUNT_MANAGER"
      timestamptz invited_at
      timestamptz accepted_at
    }
    API_KEYS {
      uuid id PK
      uuid merchant_id FK
      string key_prefix
      string key_hash
      string[] scopes
      enum environment "SANDBOX|PRODUCTION"
      enum status "ACTIVE|ROTATED|REVOKED"
      timestamptz last_used_at
      timestamptz expires_at
    }
    MERCHANT_SETTINGS {
      uuid id PK
      uuid merchant_id FK
      string webhook_base_url
      string checkout_endpoint_url
      string default_locale
      jsonb feature_flags
    }

Domain: Identity (WORKING)

Every Seller — TBBN's "Marketplace profile" — has a User from creation (Phase 22): either created together at direct self-registration, or by a merchant's first verify() call. See docs/business/conflict-resolution-log.md item 18. SELLER_MERCHANT_LINKS still exists exactly as before, now purely as cross-merchant attribution (this merchant's seller-ref → this already-existing account), not identity creation.

erDiagram
    USERS ||--o| SELLERS : "has one"
    SELLERS ||--o{ SELLER_MERCHANT_LINKS : has
    MERCHANTS ||--o{ SELLER_MERCHANT_LINKS : has
    USERS ||--o{ SESSIONS : has

    USERS {
      uuid id PK
      string email "nullable, unique when present"
      string phone "nullable, unique when present"
      string name
      enum auth_type "MERCHANT_REP|MARKETPLACE_MEMBER|TBBN_OPS"
      enum status
    }
    SELLERS {
      uuid id PK
      uuid user_id FK "always set — created together at registration"
      string name "nullable — name, nickname, or username per merchant's legal discretion; see conflict-resolution-log.md item 15"
      string primary_email
      string primary_phone
      int reputation_score "nullable until reputation-service, Phase 12"
    }
    SELLER_MERCHANT_LINKS {
      uuid id PK
      uuid seller_id FK
      uuid merchant_id FK
      string merchant_seller_ref "unique with merchant_id"
      string verified_email
      string verified_phone
      timestamptz linked_at
      enum link_method "INITIAL_VERIFICATION|OTP_MATCH"
    }
    OTP_CHALLENGES {
      uuid id PK
      enum target_type "EMAIL|PHONE"
      string target_value
      enum purpose "SELLER_LINK|SELLER_LOGIN|MERCHANT_REP_VERIFY|MEMBER_REGISTER"
      string code_hash
      timestamptz expires_at
      timestamptz consumed_at
      int attempt_count
    }
    SESSIONS {
      uuid id PK
      uuid user_id FK
      string token_hash
      timestamptz expires_at
      timestamptz revoked_at
    }

users is the umbrella authentication table for both merchant reps and marketplace members, distinguished by auth_type — this avoids two divergent identity tables that would need reconciling later. Every sellers row has a non-null user_id from creation (Phase 22).

Domain: Business (Phase 22)

A Business is the required parent a Merchant (and, Phase 23, a Space account) links to — independently owned/linked, not merged into one mega-model, mirroring how Google links Merchant Center/Business Profile/Ads accounts. See docs/business/business-model.md and docs/phase-plan/roadmap.md Phase 22.

erDiagram
    USERS ||--o{ BUSINESSES : owns
    BUSINESSES ||--o{ BRANCHES : has
    BUSINESSES ||--o{ BUSINESS_MERCHANT_LINKS : has
    MERCHANTS ||--o{ BUSINESS_MERCHANT_LINKS : has

    BUSINESSES {
      uuid id PK
      uuid owner_user_id FK
      string name
      string description
      enum status "ACTIVE|SUSPENDED|DELETED"
      enum verification_status "PENDING|VERIFIED|REJECTED — Phase 34, gates Space creation"
      timestamptz verification_reviewed_at
      string verification_reviewed_by "free-text — no TBBN_OPS principal to record a real FK yet"
      string verification_notes
      timestamptz created_at
      timestamptz updated_at
    }
    BRANCHES {
      uuid id PK
      uuid business_id FK
      string name
      string address_line_1
      string address_line_2
      string city
      string region
      string postal_code
      string country
      decimal latitude "nullable — Phase 31, backs nearby-Space search"
      decimal longitude "nullable — Phase 31"
      json photos "Phase 31 — shown on a Space's listing card"
      string_array amenities "Phase 31 — e.g. parking, indoor, 24/7 access"
      enum space_status "NOT_ENABLED|SPACE_ENABLED"
      timestamptz created_at
    }
    BUSINESS_MERCHANT_LINKS {
      uuid id PK
      uuid business_id FK
      uuid merchant_id FK
      enum status "ACTIVE|REVOKED"
      timestamptz linked_at
      timestamptz unlinked_at
    }

Many-to-many by design (mirrors SELLER_MERCHANT_LINKS's shape) — a Business can run multiple Merchant sub-accounts (Google Merchant Center's multi-client-account pattern), and in principle a Merchant could link to more than one Business. merchant-service's onboarding now requires a businessId (validated via the Postgres FK, not a live cross-service call — same pattern every other join table in this repo already uses).

BUSINESS_USERS (Business-side team roles, mirroring MERCHANT_USERS) and the Phase 34 free-listing-fee/webhook models are diagrammed in the "Space Rooms, Verification, Visibility & Business Webhooks (Phase 34)" domain section below, not repeated here.

Domain: Space (Phase 23, wired for real in Phase 31, rooted at Space not Branch since Phase 34)

TBBN's second real payment flow (docs/business/conflict-resolution-log.md item 19). Originally keyed directly to Branch; as of Phase 34 the actual bookable unit is Space (a room within a Branch — see the Phase 34 domain section below), so SPACE_SERVICE_PRICING and SPACE_BOOKINGS now key to space_id, not branch_id. SPACE_COMMISSION_RATES and SPACE_HOST_CONNECT_ACCOUNTS stay Branch-rooted — commission and the Stripe Connect payout destination are a host/location financial relationship, not per-room. SpaceBooking remains bookable by any Marketplace member for any reason, standalone; tradeSessionId — a nullable forward-compat hook through Phase 23-30 — is finally set for real in Phase 31, once a peer-to-peer TradeSession's TradeSpaceProposal negotiation resolves (see the Phase 26/31 section below). Branch also gained latitude/longitude/photos/amenities in Phase 31 — see that model's full field list in the Business domain section above.

erDiagram
    BRANCHES ||--o| SPACE_COMMISSION_RATES : has
    BRANCHES ||--o| SPACE_HOST_CONNECT_ACCOUNTS : has
    SPACES ||--o{ SPACE_SERVICE_PRICING : sets
    SPACES ||--o{ SPACE_BOOKINGS : hosts
    USERS ||--o{ SPACE_BOOKINGS : books
    SPACE_BOOKINGS ||--o| SPACE_PAYOUTS : triggers

    SPACE_SERVICE_PRICING {
      uuid id PK
      uuid space_id FK "Phase 34 — was branch_id"
      enum service_type "MEETUP|DROPOFF_PICKUP"
      decimal fee_amount
      string fee_currency
      int max_hold_days "DROPOFF_PICKUP only"
      decimal per_day_fee "DROPOFF_PICKUP only, alternative to a flat fee"
    }
    SPACE_COMMISSION_RATES {
      uuid id PK
      uuid branch_id FK "unique — absent row means the platform default applies"
      decimal commission_percent
    }
    SPACE_HOST_CONNECT_ACCOUNTS {
      uuid id PK
      uuid branch_id FK "unique"
      string stripe_connected_account_id
      enum onboarding_status "NOT_STARTED|PENDING|COMPLETE"
    }
    SPACE_BOOKINGS {
      uuid id PK
      uuid space_id FK "Phase 34 — was branch_id"
      enum service_type
      uuid booked_by_user_id "nullable (Phase 32) — null for a headless booking"
      uuid booked_by_business_id "nullable — set instead of booked_by_user_id for a headless booking"
      string external_client_ref "the consuming Business's own opaque client id"
      string payment_link_token "unguessable, backs the public hosted pay/confirm pages"
      boolean consent_required "Phase 34 — set when the headless path requires explicit consent"
      string consent_token "Phase 34 — unguessable, backs the hosted consent page"
      timestamptz consented_at
      uuid trade_session_id "nullable forward-compat hook, wired in Phase 31"
      enum status "AWAITING_CONSENT|PENDING_PAYMENT|PAID|CONFIRMED|COMPLETED|CANCELLED|REFUNDED"
      decimal gross_amount
      decimal commission_amount
      decimal host_payout_amount
      timestamptz host_notified_at "set only once payment clears"
      timestamptz confirmed_at "triggers payout, not payment-clearing"
    }
    SPACE_PAYOUTS {
      uuid id PK
      uuid booking_id FK "unique"
      uuid branch_id FK "denormalized (via booking.space.branch_id) for host payout-history queries"
      decimal amount
      enum status "PENDING|PAID|FAILED"
      string stripe_transfer_id
    }

Domain: Review (Phase 24)

Post-trade 1-5 star reviews (TBBN_Flow.docx §10.2) — user-submitted, distinct from the system-computed Seller.reputationScore in the Identity domain above. Reviews attach to the specific Merchant/Branch used, never directly to a Business — a Business-level rollup is a query (reputation-service's reviews-summary endpoint), not a stored value.

erDiagram
    SELLERS ||--o{ REVIEWS : writes
    MERCHANTS ||--o{ REVIEWS : "reviewed via"
    BRANCHES ||--o{ REVIEWS : "reviewed via"

    REVIEWS {
      uuid id PK
      uuid reviewer_id FK
      enum target_type "MERCHANT|SPACE_BRANCH"
      uuid merchant_id "set when target_type = MERCHANT"
      uuid branch_id "set when target_type = SPACE_BRANCH"
      int rating "1-5"
      string comment
      uuid trade_session_id "provenance — proves a real completed trade"
      uuid space_booking_id "provenance — proves a real completed booking"
    }

One review per completed trade/booking: @@unique([reviewerId, tradeSessionId]) and @@unique([reviewerId, spaceBookingId]), plus a service-layer check that the reviewer was actually a party to that trade/booking and it actually completed.

Domain: Identity reversal — Headless Sellers & OAuth Linking (Phases 27-28)

Reverses part of Phase 22's "every Seller has a User from creation" rule (docs/business/conflict-resolution-log.md items 21-22). SELLERS.user_id is nullable again; a NULL means a "headless" Seller, gated by MERCHANTS.headless_sellers_allowed. The sequential email/phone OTP cross-merchant linking flow (Phase 3) is replaced entirely by an OAuth Authorization Code handshake.

erDiagram
    MERCHANTS ||--o{ OAUTH_CLIENTS : registers
    OAUTH_CLIENTS ||--o{ OAUTH_AUTHORIZATION_CODES : issues
    OAUTH_CLIENTS ||--o{ SELLER_MERCHANT_LINKS : "links via"
    USERS ||--o{ OAUTH_AUTHORIZATION_CODES : approves
    SELLERS ||--o{ OAUTH_AUTHORIZATION_CODES : "links"

    OAUTH_CLIENTS {
      uuid id PK
      uuid merchant_id FK
      string client_id "public, unique — e.g. mc_ab12cd34ef56ab78"
      string client_secret_hash "never the raw secret"
      string_array redirect_uris
      enum status "ACTIVE|REVOKED"
    }
    OAUTH_AUTHORIZATION_CODES {
      uuid id PK
      string code_hash "hashed like an API key, not plaintext"
      uuid client_id FK
      uuid user_id FK
      uuid seller_id FK
      string redirect_uri
      string scope
      string code_challenge "RFC 7636 PKCE, optional"
      string merchant_seller_ref
      enum status "PENDING|CONSUMED|EXPIRED"
      timestamptz expires_at "5 minutes"
      timestamptz consumed_at
    }

SELLER_MERCHANT_LINKS.link_method (Identity domain, above) drops OTP_MATCH, adds OAUTH_AUTHORIZATION and HEADLESS_CREATED; a new nullable oauth_client_id FK records which client an OAUTH_AUTHORIZATION link came through. Deliberately link-only — a successful token exchange only ever creates a SELLER_MERCHANT_LINKS row; no access/refresh token is issued to the Merchant.

Domain: Individual Listings & Peer-to-Peer Trade Completion (Phase 26)

Listing.merchant_id/merchant_listing_ref become nullable — a NULL merchant_id is an individual (non-Merchant) listing, created via POST v1/listings/individual. New listing_type (SALE|TRADE|BOTH) applies uniformly to Merchant and individual listings — a SALE listing is never offer-able (Phase 30). New delivery_method (LOCAL_PICKUP|SHIPPING|BOTH), preferred_space_branch_id (Phase 31), and merchant_item_url (Phase 30's outbound link for a SALE Merchant listing) round out the new fields — see the Listing model's own doc comment in schema.prisma for the raw-SQL partial-unique-index note this nullability required.

TradeSession gains fulfillment_mode (MERCHANT_CHECKOUT|PEER_TO_PEERPEER_TO_PEER only when every listing on both sides has no Merchant) and the peer-to-peer counterpart to WAITING_CHECKOUT_*/CHECKOUT_*_COMPLETE: SCHEDULING_A/SCHEDULING_B, EXCHANGE_A_CONFIRMED/EXCHANGE_B_CONFIRMED, EXCHANGE_FAILED, plus per-side scheduling_*_confirmed_at/confirmed_by_user_id_*/confirmed_by_merchant_user_id_* columns (the latter for a headless Seller's side, which has no User to record).

Domain: TBBN Space Selection & Negotiation (Phase 31)

Implements the founder's listing-time Space picker and "whoever first recommended the space pays" rule (docs/business/conflict-resolution-log.md item 25).

erDiagram
    TRADE_SESSIONS ||--o{ TRADE_SPACE_PROPOSALS : negotiates
    BRANCHES ||--o{ TRADE_SPACE_PROPOSALS : "proposed for"
    SELLERS ||--o{ TRADE_SPACE_PROPOSALS : proposes

    TRADE_SPACE_PROPOSALS {
      uuid id PK
      uuid trade_session_id FK
      uuid branch_id FK
      uuid proposed_by_seller_id FK
      enum status "PROPOSED|ACCEPTED|REJECTED|SUPERSEDED"
      timestamptz proposed_at
      timestamptz responded_at
    }

A listing's preferred_space_branch_id auto-seeds the first PROPOSED row once a peer-to-peer TradeSession starts scheduling; the other party can counter-propose (marking the prior PROPOSED row SUPERSEDED). Accepting a proposal is what finally sets SpaceBooking.trade_session_id (a nullable hook since Phase 23) — SpaceBooking.booked_by_user_id is set from whichever proposal's proposed_by_seller_id traces the accepted row, not necessarily whoever clicked Accept.

Domain: TBBN Space as an API Platform (Phase 32)

Turns Space into "calendly for space" — a standalone product any Business (Phase 22's generic, Merchant-independent owner entity, not Merchant) can integrate against, searching and booking SPACE_ENABLED branches worldwide and, if it holds a valid key, booking on behalf of its own client with no TBBN account at all (docs/business/conflict-resolution-log.md item 26). Deliberately separate from Phase 31's nearbySpaces (200-mile, marketplace-only, unchanged).

erDiagram
    BUSINESSES ||--o{ SPACE_API_KEYS : issues
    SPACE_API_KEYS ||--o| SPACE_API_KEY_CONFIGS : configures
    BUSINESSES ||--o{ SPACE_BOOKINGS : "books headlessly via"

    SPACE_API_KEYS {
      uuid id PK
      uuid business_id FK
      string key_prefix "unique — sk_space_live_/sk_space_sandbox_ + 24 hex, sliced to 24 chars"
      string key_hash "SHA-256, same hashApiKey/verifyApiKey primitives as ApiKey/OAuthClient"
      enum environment "SANDBOX|PRODUCTION"
      enum status "ACTIVE|ROTATED|REVOKED"
      timestamptz last_used_at
      timestamptz created_at
    }
    SPACE_API_KEY_CONFIGS {
      uuid id PK
      uuid space_api_key_id FK "unique"
      string default_country "nullable — applied when the caller omits the filter"
      string default_region "nullable"
      int default_radius_miles "nullable — null means unbounded, unlike nearbySpaces"
      string_array pinned_branch_ids "always sorted first in search results, flagged isPinned"
      timestamptz updated_at
    }

SPACE_BOOKINGS (Space domain, above) gains three fields for the headless path: booked_by_business_id (nullable, set instead of booked_by_user_id), external_client_ref (the consuming Business's own opaque identifier for its client — mirrors merchant_seller_ref's shape, never a TBBN identity), and payment_link_token (unguessable, unique — backs a public hosted payment page at space.tbbnetwork.com/pay/[bookingId]?token=..., the same "a token grants access without a session" pattern as auth-service's existing magic link). booked_by_user_id is now nullable to make room for this — exactly one of booked_by_user_id / (booked_by_business_id + external_client_ref) is set, enforced in the service layer, not a DB constraint (same convention as requested_amount/willing_to_pay).

GET /v1/space/search (space-service) is public and unbounded by default; presenting a valid sk_space_* key (verified in api-gateway, setting spaceApiKeyId/businessId on the internal principal) loads that key's SpaceApiKeyConfig to fill in any filter the caller omitted and to pin pinned_branch_ids first. spaceStatus: SPACE_ENABLED is this pass's de-facto "verified Space" gate — there's no real moderation workflow yet (see docs/legal-flags/open-legal-questions.md item 5 and docs/business/pricing.md's open item on Space API monetization).

Domain: Space Rooms, Verification, Visibility & Business Webhooks (Phase 34)

Turns Space into a real room-booking platform, per a follow-up founder directive (docs/business/conflict-resolution-log.md item 28). SPACES (new — see the Space domain section above for its FK migration) is now the bookable unit; a Business must be VERIFIED before it can create one; a Business gets real team roles instead of a single owner; and a Business can subscribe to outbound webhooks for Space booking events.

SPACES.category/check_in_method (Phase 36, item 29) are a later addition layered on this same model — "Airbnb for Space" on top of "Calendly for Space": category is purely a browsable/ filterable label (a restaurant table or a retail corner fits the same model as a study room, no change to booking mechanics), and check_in_method describes how a guest identifies their reserved spot on arrival. max_concurrent_bookings (already above) already covers "multiple identical spots" (five tables, not one) — a genuinely distinct numbered room is just a separate Space row, as before.

erDiagram
    BRANCHES ||--o{ SPACES : lists
    BUSINESSES ||--o{ BUSINESS_USERS : has
    USERS ||--o{ BUSINESS_USERS : "is a member via"
    BUSINESSES ||--o{ BUSINESS_ONE_TIME_CHARGES : owes
    SPACES ||--o{ BUSINESS_ONE_TIME_CHARGES : "billed for (if free)"
    BUSINESSES ||--o{ BUSINESS_WEBHOOK_SUBSCRIPTIONS : registers
    BUSINESS_WEBHOOK_SUBSCRIPTIONS ||--o{ BUSINESS_WEBHOOK_DELIVERIES : delivers

    SPACES {
      uuid id PK
      uuid branch_id FK
      string name
      string description
      enum category "Phase 36 — STUDY_ROOM|MEETING_ROOM|OFFICE_ROOM|EVENT_SPACE|RESTAURANT_TABLE|RETAIL_CORNER|PARKING_SPOT|OTHER, browsable label only"
      enum check_in_method "Phase 36 — SELF_SERVICE_NUMBERED|SIGNAGE|STAFF_ASSISTED|OTHER, how a guest finds their spot"
      boolean parking_available
      string parking_notes
      jsonb weekly_hours "[] = always bookable; { day, opensAt, closesAt }[]"
      int max_concurrent_bookings "overlapping-booking capacity, not a period cap"
      int default_booking_duration_minutes "MEETUP overlap-check slot length only"
      jsonb photos
      string notes
      enum visibility "PUBLIC|API_ONLY|RESTRICTED_EMAIL"
      string_array allowed_emails "RESTRICTED_EMAIL only — exact match"
      string_array allowed_email_domains "RESTRICTED_EMAIL only — e.g. cornell.edu"
      boolean is_free "drives a BusinessOneTimeCharge at creation"
      enum status "DRAFT|ACTIVE|DISABLED"
    }
    BUSINESS_USERS {
      uuid id PK
      uuid business_id FK
      uuid user_id FK
      enum role "OWNER|ADMIN|DEVELOPER|ACCOUNT_MANAGER"
      uuid invited_by
      timestamptz invited_at
      timestamptz accepted_at
    }
    BUSINESS_ONE_TIME_CHARGES {
      uuid id PK
      uuid business_id FK
      enum kind "SPACE_FREE_LISTING_FEE"
      decimal amount
      string currency
      enum status "PENDING|CHARGED|WAIVED|FAILED"
      uuid related_space_id FK "nullable"
    }
    BUSINESS_WEBHOOK_SUBSCRIPTIONS {
      uuid id PK
      uuid business_id FK
      string url
      string_array events
      string secret
      enum status "ACTIVE|DISABLED"
    }
    BUSINESS_WEBHOOK_DELIVERIES {
      uuid id PK
      uuid subscription_id FK
      string event_type
      jsonb payload
      enum status "PENDING|DELIVERED|FAILED|DEAD_LETTER"
      int attempts
      timestamptz last_attempted_at
    }

BUSINESSES gains verification_status (PENDING|VERIFIED|REJECTED, default PENDING) + verification_reviewed_at/verification_reviewed_by/verification_notes — checked at Space-creation time and, defense-in-depth, when a Branch is flipped SPACE_ENABLED. No real staff-auth exists yet, so review happens via an interim unauthenticated ops-console page (/businesses), same "ops-only in practice" convention as every prior ops action in this repo, pending admin.tbbnetwork.com (Phase 35, planned not built).

BUSINESS_USERS/role mirrors MERCHANT_USERS/MerchantUserRole field-for-field (see the Merchant domain section) — OWNER (via Business.owner_user_id) always implicitly satisfies any role check even without a row here; every Business gets an explicit OWNER row for its creator at creation time.

SPACE_API_KEY_CONFIGS (Phase 32 section above) gains consent_mode (STRAIGHT|REQUIRE_CONSENT) — "third party businesses decide when all required consent has been given... who pays, and when" (founder directive). SPACE_BOOKINGS (Space domain above) gains a new AWAITING_CONSENT status preceding PENDING_PAYMENT, plus consent_required/ consent_token/consented_at, for the headless path only — a token-gated hosted consent page (space.tbbnetwork.com/consent/:id) mirrors payment_link_token's "a token grants access without a session" shape.

PLATFORM_FEE_SCHEDULES (not diagrammed — a flat key/amount/currency singleton-per-key table) backs BUSINESS_ONE_TIME_CHARGES.amount's default: a null-amount row (or no row at all) means "use the coded $20 default." Real and DB-driven — genuinely dynamic per the founder's instruction — but not yet admin-editable through any UI; admin.tbbnetwork.com (Phase 35) is where that gets built. This phase creates the PENDING charge record only — it does not attempt real Stripe collection, per docs/business/pricing.md's "Free-space listing fee" section.

BUSINESS_WEBHOOK_SUBSCRIPTIONS/BUSINESS_WEBHOOK_DELIVERIES mirror WEBHOOK_SUBSCRIPTIONS/WEBHOOK_DELIVERIES (Merchant-rooted, services/webhook-service) field-for-field but keyed to Business — kept as its own model pair per this repo's "each credential/entity type is its own module" convention, while the HMAC signing/retry mechanics are shared via a generalized delivery-kernel.ts rather than reimplemented. A delivery fans out to subscriptions matching either the host's business_id or, for a headless booking, the API integrator's booked_by_business_id — both are legitimate subscribers to the same booking lifecycle. Unlike Merchant's WebhookDelivery, a BusinessWebhookDelivery does not emit a billable UsageEvent — see docs/business/pricing.md.

Domain: Trade session / listing / offer (schema now, service logic in later phases)

Included now because the shape is fully specified by source material and is expensive to retrofit later — see docs/business/conflict-resolution-log.md item 6.

erDiagram
    LISTINGS ||--o{ LISTING_WANTS : has
    LISTINGS ||--o{ OFFER_LISTINGS_A : "appears in"
    LISTINGS ||--o{ OFFER_LISTINGS_B : "appears in"
    OFFERS ||--o{ OFFER_LISTINGS_A : has
    OFFERS ||--o{ OFFER_LISTINGS_B : has
    OFFERS ||--o| TRADE_SESSIONS : "converts to"
    TRADE_SESSIONS ||--o{ TRADE_SESSION_LISTINGS_A : has
    TRADE_SESSIONS ||--o{ TRADE_SESSION_LISTINGS_B : has
    TRADE_SESSIONS ||--o{ TRADE_SESSION_PAYMENTS : has
    TRADE_SESSIONS ||--o{ TRADE_SESSION_RESERVATIONS : has
    MERCHANTS ||--o{ MERCHANT_SUBSCRIPTIONS : has
    MERCHANTS ||--o{ USAGE_EVENTS : generates

    LISTINGS {
      uuid id PK
      uuid merchant_id FK
      string merchant_listing_ref
      uuid seller_id FK
      string title
      string category
      string subcategory
      string brand
      string model
      string size
      string condition
      int year
      decimal original_price
      string currency
      enum visibility "PRIVATE|GLOBAL|BOTH|ENTERPRISE"
      enum status "ACTIVE|RESERVED|SOLD|UNAVAILABLE|DELETED"
      decimal requested_amount "nullable, mutually exclusive with willing_to_pay"
      decimal willing_to_pay "nullable, mutually exclusive with requested_amount"
      text note "moderation-scanned"
      jsonb images
      timestamptz synced_at
    }
    LISTING_WANTS {
      uuid id PK
      uuid listing_id FK
      string category
      string subcategory "nullable"
      string brand "nullable"
    }
    OFFERS {
      uuid id PK
      uuid from_seller_id FK
      uuid to_seller_id FK
      enum status "DRAFT|PENDING|ACCEPTED|REJECTED|CANCELLED|EXPIRED|COUNTERED"
      decimal extra_amount_offered "nullable"
      timestamptz expires_at
    }
    TRADE_SESSIONS {
      uuid id PK
      uuid offer_id FK "nullable"
      uuid seller_a_id FK
      uuid seller_b_id FK
      enum status "see trade-session-state-machine.md"
      decimal trade_value
      string currency
      timestamptz expires_at
      timestamptz locked_at
      timestamptz completed_at
    }
    TRADE_SESSION_PAYMENTS {
      uuid id PK
      uuid trade_session_id FK
      enum side "A|B"
      enum status "PENDING|WAITING|COMPLETE|FAILED"
      string merchant_checkout_session_ref
      decimal amount
      string currency
      timestamptz completed_at
    }
    TRADE_SESSION_RESERVATIONS {
      uuid id PK
      uuid trade_session_id FK
      enum side "A|B"
      uuid listing_id FK
      enum status "PENDING|RESERVED|RELEASED|FAILED"
      timestamptz reserved_at
      timestamptz released_at
    }
    MERCHANT_SUBSCRIPTIONS {
      uuid id PK
      uuid merchant_id FK
      enum tier
      string stripe_subscription_id
      string stripe_customer_id
      enum status
      timestamptz current_period_start
      timestamptz current_period_end
    }
    USAGE_EVENTS {
      uuid id PK
      uuid merchant_id FK
      enum event_type "LISTING_SYNC|SEARCH|OFFER|TRADE_SESSION|WEBHOOK_DELIVERY|CHECKOUT_ORCHESTRATION"
      enum scope "INTRA_MERCHANT|CROSS_MERCHANT"
      int quantity "default 1"
      timestamptz occurred_at
      string billing_period_ref
    }

offer_listings_a/offer_listings_b and trade_session_listings_a/trade_session_listings_b are join tables ({offer_id|trade_session_id, listing_id}) modeling N:M multi-item trades from day one. usage_events.scope is what drives the 4x cross-merchant metering multiplier in docs/business/pricing.md — computed by comparing the two sellers'/listings' merchant_ids at event-emission time, not stored redundantly elsewhere.

Explicitly deferred (not modeled yet)

Search-index tables (OpenSearch owns this, not Postgres), matching-score cache tables, fraud/ moderation signal tables, analytics rollup tables — these are derived/computed data whose shape depends on the algorithm implementation in later phases; committing their schema now would be guessing ahead of the actual Phase 5/7/12/13 work.