Files
marketplaces/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md
sdarbinyan f9e09b1757 docs(backend): harvest platform mechanisms into the contracts (Wave 2, FH-E.1-E.4)
Writes the 14 harvested mechanisms from FORK-ANALYSIS-2026-08-21.md into
the backend contracts. Each section is dated 2026-08-21 and tagged FH-*
so any wording traces back to why it is worded that way.

The through-line: several contracts stated correctness as behaviour
("the webhook must be idempotent"). Behaviour written as an if-statement
gets deleted by a refactor and the failure mode is a double charge. These
sections restate it as schema and mechanism.

PHASE-3  3.1 conditional-write reservation, 409 on zero rows, cart-wide
             rollback, 15 min TTL
         3.2 InventoryMovement append-only journal with resultingAvailable
         6   bulk import idempotent by SKU, rollback while unsold
         6a  digital code pools, revealed only when paid
PHASE-7  5   unique constraints for payment idempotency and webhook
             replay, insert-first handling, signature over raw body,
             24h poll as reconciliation not primary
TRACK-S  2.1 session model - 32 bytes stored as SHA-256 only, HttpOnly,
             one cookie per contour, Argon2id params, mandatory TOTP
         2.2 origin allowlist ahead of routing on every cookie mutation
         4.2 AES-256-GCM envelope for stored secrets, HMAC fingerprints
         8a  order manager as a separate contour, scoped by membership
             rows rather than by configuration
PHASE-9  5.1 revision immutability, version = max+1, pointer flipped
             in-transaction, operational state does not travel
         5.2 clone carry / no-carry list, inventory to zero
         5.3 signed read-only preview, non-GET 404s while previewing
         6   host normalization, verifiedAt required, cache invalidation
PHASE-10 3a  server re-runs the editor's validation, clamp-and-fallback
PHASE-2  3.1 order publicToken, snapshot completeness, never updated

FH-2.12 rejected on the merits: our marketplace lifecycle state machine
is richer than theirs, adopting it would be a downgrade. Recorded in the
TODO so it is not raised again.

Also adds BACKEND-HANDOFF.md sections 0 and 0a - nine falsifiable
invariants as a release gate, each cross-referenced to the contract that
specifies it, plus PR and release discipline. And ADR-0006 recording what
we take, what we reject, what we keep because ours is better, and the
organizational question it deliberately does not settle.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:12:05 +04:00

226 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3 Backend Contract — Product/Offer Split, Inventory, Executability
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 3 (Sprints 3.13.3). The largest structural change in the programme — nothing about multi-seller commerce works without it.
**Status: ready to build.** No open decisions block this phase.
---
## 1. Why this exists
Today price, stock and currency hang directly off a single admin `Product` mock domain, unrelated to the live storefront `Item` domain. A product cannot have two sellers, two prices, or two stock levels. `Offer/Listing` does not exist in any form.
## 2. The two-layer split
`Product` describes the item itself (content). `Offer` describes one seller's commercial proposition against that product (price, stock, currency, status). One product, many offers.
```ts
interface Product {
id: string;
marketplaceId: string;
categoryId: string;
brand?: string;
title: string;
description: string;
attributes: Record<string, unknown>;
media: string[];
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
}
interface Variant {
id: string;
productId: string;
sku: string;
barcode?: string;
optionValues: Record<string, string>; // e.g. { color: 'red', size: 'M' }
dimensions?: { weight?: number; length?: number; width?: number; height?: number };
}
interface Category {
id: string;
marketplaceId: string;
parentId: string | null;
slug: string;
attributesSchema: Record<string, unknown>;
order: number;
seo: { title?: string; description?: string };
}
interface Offer {
id: string;
marketplaceId: string;
sellerId: string;
variantId: string;
sellerSku: string;
price: Money; // Money type from Phase 1 contract
stockPolicy: 'track' | 'no_track' | 'preorder';
status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived';
publishedAt?: string;
executabilityChecked: boolean; // see §5
}
interface PriceHistory {
offerId: string;
price: Money;
changedBy: string; // user id or 'sync:{connectorId}'
changedAt: string;
}
```
## 3. Inventory
```ts
interface InventoryRecord {
offerId: string;
available: number;
reserved: number;
sold: number;
warehouse?: string;
source: 'manual' | 'feed_sync' | 'connector';
}
interface StockReservation {
id: string;
offerId: string;
qty: number;
reason: 'checkout' | 'pre_payment';
expiresAt: string; // TTL
released: boolean;
}
```
Invariants:
- `available`, `reserved`, `sold` are counted separately, never derived from one another implicitly.
- Reservations are created at checkout or pre-payment (tenant-configurable strategy) and expire by TTL, releasing `reserved` back to `available`.
- Seller feed stock updates are an **idempotent upsert** — a repeated webhook must not double-decrement.
- Oversell (a sale exceeding `available`) routes to a dedicated incident queue, never silently hidden or auto-corrected.
### 3.1 Reservation must be atomic — the mechanism, not just the intent
Added 2026-08-21 (FH-2.1). The invariants above say reservations exist; they do not say how two buyers racing for the last unit are separated. Specify the mechanism, because "check availability, then reserve" is a read-then-write race and will oversell under load no matter how the code above it is written.
Reserve with a single conditional write that both tests and updates:
```sql
UPDATE inventory
SET reserved = reserved + :qty
WHERE offer_id = :offerId
AND (available - reserved) >= :qty
RETURNING id
```
- **Zero rows returned means insufficient stock.** Respond `409` with the offending offer, do not retry, do not partially reserve. A multi-line cart reserves every line inside one transaction; any line returning zero rows rolls back all of them.
- No `SELECT` before the `UPDATE`. No advisory lock. No application-level retry loop. The `WHERE` clause is the concurrency control.
- Reservation TTL is 15 minutes from checkout-session creation. Expiry releases `reserved` back to `available` and writes a journal row (§3.2).
- The same rule governs release and consumption: one conditional statement, never read-modify-write.
**Acceptance:** two concurrent checkouts for the last unit produce exactly one payable order and one clean `409`. This is scenario 3 of the acceptance list in [FORK-HARVEST-TODO.md](../FORK-HARVEST-TODO.md) and is a required e2e test, not a code-review item.
### 3.2 Inventory movements are an append-only journal
Added 2026-08-21 (FH-2.8). Every change to `available`/`reserved`/`sold` writes one immutable row:
```ts
interface InventoryMovement {
id: string;
offerId: string;
deltaAvailable: number;
deltaReserved: number;
deltaSold: number;
reason: 'checkout_reservation' | 'reservation_expired' | 'reservation_released'
| 'payment_confirmed' | 'manual_adjustment' | 'feed_sync' | 'connector_sync'
| 'refund_restock' | 'oversell_correction';
referenceType?: 'reservation' | 'order' | 'import' | 'connector';
referenceId?: string;
actor?: string; // user id for manual adjustments, null for system
resultingAvailable: number; // balance after this movement, not recomputed later
occurredAt: string;
}
```
Rules:
- Rows are never updated or deleted. A correction is a new compensating row.
- `resultingAvailable` is written at the time of the movement. Replaying the journal must reproduce the current record exactly; a divergence is a defect to investigate, not a number to overwrite.
- A manual adjustment without an `actor` is rejected.
**Why this is in the contract rather than left to implementation.** Product Plan v3.1 §10.2 opens with the complaint that our numbers cannot be explained. A quantity you cannot reconstruct is a quantity you cannot defend to a bank, an inspector, or a seller disputing a payout. This journal is what turns "the stock says 3" into "the stock says 3, and here is every movement that made it 3."
## 4. Lifecycle
```
draft -> moderation -> published -> paused/archived
```
Applies independently to both `Product` and `Offer`. Wires to the already-existing (mock) Admin Moderation module — no new frontend module needed, just a real gateway behind `ADMIN_MODERATION_GATEWAY` (token already added this session).
## 5. Publish-time executability
**An offer that cannot actually be fulfilled must not be publishable.** Before allowing `status: 'published'`, the backend validates:
- The offer has a valid `Fulfillment` type it can realistically satisfy (see [Phase 2 contract](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) `Fulfillment.type`).
- Stock policy is `track` with `available > 0`, or `no_track`/`preorder` explicitly.
- Required attributes for the offer's category (`Category.attributesSchema`) are present.
This is the mechanism behind the plan's §3.6/§10.2 requirement: **no branch anywhere may distinguish a normal buyer from an inspector.** The only way to guarantee that is to make every published offer genuinely executable at publish time, not to special-case checkout behavior later.
## 6. Bulk import
```
POST /api/admin/v2/products/bulk-import
Content-Type: multipart/form-data (CSV) or application/json (array)
```
Response returns a **preview** of validation errors before anything is applied — required-field validation, category-attribute validation, duplicate-SKU detection — with a separate `POST .../bulk-import/{importId}/apply` to commit after review.
Added 2026-08-21 (FH-2.15): the import is idempotent by SKU/external key, so re-running the same file updates rather than duplicating. A row-level error never publishes a partial result. An applied import can be rolled back **as long as none of its products have appeared on a paid order** — after that, archive rather than delete.
## 6a. Digital fulfilment — code pools
Added 2026-08-21 (FH-2.11). We have no digital-goods story today, and it is one table.
```ts
type FulfillmentMode = 'manual' | 'code_pool';
interface DigitalCode {
id: string;
marketplaceId: string;
offerId: string;
encryptedValue: string; // see Track S §4.2 envelope
valueHash: string; // unique per (marketplaceId, offerId)
status: 'available' | 'reserved' | 'assigned' | 'revoked';
orderLineId?: string;
createdAt: string;
assignedAt?: string;
}
```
Rules:
- `FulfillmentMode` is a property of the offer. `code_pool` offers derive `available` from the count of `available` codes — the two must not be maintained independently.
- `valueHash` is unique per `(marketplaceId, offerId)`, so importing the same code twice is refused by the database rather than by a check somebody can forget.
- A code moves `available → reserved` under the same conditional-write rule as §3.1, and `reserved → assigned` only on confirmed payment.
- **A code is returned to the browser only when the order is `paid`, `processing`, or `fulfilled`.** Any earlier state returns the line with an empty code list — not a masked value, not a placeholder.
- Revocation is terminal and audited (Track S §3).
**Acceptance:** an unpaid order never yields a code, through the UI or through a direct API call with a valid session.
## 7. Endpoints
```
GET /api/admin/v2/products?marketplaceId=&status=&search=&page=&pageSize=
GET /api/admin/v2/products/{id}
POST /api/admin/v2/products
PATCH /api/admin/v2/products/{id}
GET /api/admin/v2/offers?productId=&sellerId=&status=
POST /api/admin/v2/offers
PATCH /api/admin/v2/offers/{id}
POST /api/admin/v2/offers/{id}/publish -> runs §5 executability check, 422 with details[] on failure
GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId= -- "find any offer by internal SKU, seller SKU, product ID, or external mapping" per plan §2.1
```
Replaces `AdminProductsLocalGateway` behind `ADMIN_PRODUCTS_GATEWAY` (token already wired this session).
## 8. What the frontend will start doing once this ships
- Unify the admin mock product domain with the live storefront `Item` domain — currently two unrelated shapes.
- Multi-seller product page: same product card, multiple offers/sellers/prices — undefined behaviour today.
- Wire the Moderation module to real lifecycle transitions instead of mock data.