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_PEER — PEER_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.