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>
162 lines
7.5 KiB
Markdown
162 lines
7.5 KiB
Markdown
# Phase 2 Backend Contract — Canonical Orders, Event Bus, Notification Center
|
||
|
||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 2 (Sprints 2.1–2.2). Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (Money/PriceSnapshot/PaymentIntent) being implemented first — an Order line references a `priceSnapshotId` from that contract.
|
||
|
||
**Status: ready to build.** No open decisions block this phase.
|
||
|
||
---
|
||
|
||
## 1. Why this exists
|
||
|
||
Today `AdminOrdersLocalGateway` is a static 24-row in-memory seed with no create path — a real order can never appear. `AdminOrderWatcherService` already polls for new orders to toast/badge the admin, but is functionally inert against the mock. This contract makes both real.
|
||
|
||
## 2. Multi-seller model — Sprint 0.1 decision: unified
|
||
|
||
**One `Order` per checkout, regardless of how many sellers are represented.** Lines are grouped into per-seller `Fulfillment` entries internally. There is no parent/child order splitting, no separate order-per-seller. A seller only ever sees their own `Fulfillment` group within a shared order (see [Phase 5 contract](PHASE-5-SELLER-PORTAL-CONTRACT.md) for the seller-scoped view).
|
||
|
||
## 3. Entities
|
||
|
||
```ts
|
||
interface Order {
|
||
id: string;
|
||
marketplaceId: string;
|
||
source: 'storefront' | 'external' | 'backoffice' | 'api_partner';
|
||
externalOrderRef?: string; // set when source === 'external', see Phase 4
|
||
customerId?: string;
|
||
currency: string;
|
||
subtotal: Money;
|
||
discount: Money;
|
||
delivery: Money;
|
||
total: Money;
|
||
paymentStatus: 'pending_payment' | 'paid' | 'failed' | 'refunded' | 'partially_refunded';
|
||
orderStatus: 'pending_payment' | 'paid' | 'processing' | 'fulfilled' | 'completed' | 'cancelled';
|
||
createdAt: string;
|
||
paidAt?: string;
|
||
}
|
||
|
||
interface OrderLine {
|
||
id: string;
|
||
orderId: string;
|
||
offerId: string; // see Phase 3 contract
|
||
sellerId: string;
|
||
skuSnapshot: string;
|
||
titleSnapshot: string;
|
||
qty: number;
|
||
unitPrice: Money;
|
||
lineTotal: Money;
|
||
priceSnapshotId: string; // references Phase 1's PriceSnapshot
|
||
}
|
||
|
||
interface Fulfillment {
|
||
id: string;
|
||
orderId: string;
|
||
sellerId: string; // the seller-scoping unit for the unified-order model
|
||
type: 'manual' | 'warehouse' | 'pickup' | 'digital';
|
||
status: 'pending' | 'assigned' | 'in_progress' | 'issued' | 'shipped' | 'cancelled';
|
||
assignedTo?: string;
|
||
issuedAt?: string;
|
||
shippedAt?: string;
|
||
evidence?: { type: string; url: string }[]; // e.g. shipment proof, digital delivery receipt
|
||
}
|
||
|
||
interface OrderEvent {
|
||
id: string;
|
||
orderId: string;
|
||
type: 'created' | 'paid' | 'seller_notified' | 'accepted' | 'fulfilled' | 'cancelled' | 'refunded';
|
||
actor?: string; // user/system id, null for automated system events
|
||
occurredAt: string;
|
||
metadata?: Record<string, unknown>;
|
||
}
|
||
|
||
interface OrderContactSnapshot {
|
||
orderId: string;
|
||
name: string;
|
||
email?: string;
|
||
phone?: string;
|
||
preferredChannel?: 'telegram' | 'vk' | 'max' | 'email' | 'sms';
|
||
capturedAt: string; // immutable after order creation, independent of later Customer profile edits
|
||
}
|
||
```
|
||
|
||
### 3.1 Public addressing and snapshot completeness
|
||
|
||
Added 2026-08-21 (FH-2.13).
|
||
|
||
- `Order` carries a `publicToken`: at least 24 random bytes, base64url, unique. **Every customer-facing route addresses an order by this token, never by `id`.** Order confirmation links, status polling, and support lookups all use it. A sequential or guessable public identifier turns "check my order" into an enumeration of the tenant's order book.
|
||
- `GET /api/v2/storefront/orders/{publicToken}` is scoped to the resolved tenant. A valid token from a different marketplace is `404`.
|
||
- `OrderLine` already snapshots SKU, title and price. Extend that to **everything that must survive a later edit**: currency, per-line discount, delivery option and price, and the tax/fee components. Together with `OrderContactSnapshot`, an order must be fully reconstructable from its own rows — renaming an offer, changing a price, or deleting a delivery option must not alter what a historical order says was bought and charged.
|
||
- Snapshot fields are written once at order creation and never updated. A correction is a new order event, a refund, or an amendment record — never an in-place rewrite of what the customer agreed to.
|
||
|
||
## 4. Endpoints
|
||
|
||
```
|
||
GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=
|
||
GET /api/admin/v2/orders/{id}
|
||
PATCH /api/admin/v2/orders/{id}/status { status }
|
||
POST /api/admin/v2/orders/{id}/refund-request { reason }
|
||
POST /api/admin/v2/orders/{id}/notes { note, internal: boolean }
|
||
POST /api/admin/v2/orders/{id}/archive
|
||
POST /api/admin/v2/orders/{id}/restore
|
||
DELETE /api/admin/v2/orders/{id}
|
||
|
||
GET /api/seller/v1/orders?fulfillmentStatus=&page=&pageSize=
|
||
-> returns Order + only the Fulfillment groups belonging to the authenticated seller,
|
||
OrderLines filtered to that seller's lines. Never the full order's other-seller lines.
|
||
```
|
||
|
||
Replaces `AdminOrdersLocalGateway` behind the `ADMIN_ORDERS_GATEWAY` token already wired this session (see [BACKEND-API-REFERENCE.md §8](../../BACKEND-API-REFERENCE.md)) — no facade change needed, only binding a real `AdminOrdersApiGateway`.
|
||
|
||
## 5. Event bus
|
||
|
||
```ts
|
||
type PlatformEvent =
|
||
| { type: 'order.created'; orderId: string; marketplaceId: string }
|
||
| { type: 'order.paid'; orderId: string; marketplaceId: string }
|
||
| { type: 'payment.failed'; orderId: string; reason: string }
|
||
| { type: 'webhook.error'; source: string; traceId: string }
|
||
| { type: 'stock.low'; offerId: string; available: number }
|
||
| { type: 'oversell'; offerId: string; requested: number; available: number }
|
||
| { type: 'refund.requested'; orderId: string; refundId: string }
|
||
| { type: 'refund.completed'; orderId: string; refundId: string }
|
||
| { type: 'external_order.imported'; orderId: string; connectorId: string };
|
||
```
|
||
|
||
Backend owns the bus implementation (queue, pub/sub, whatever fits existing infra). Frontend's only contract: the Notification entity below, and the requirement that `order.paid` always produces a backoffice notification **even if every external channel is down** (see [Phase 8](PHASE-8-IDENTITY-MESSAGING-CONTRACT.md) §5 for the messenger-side orchestration).
|
||
|
||
## 6. Notification Center
|
||
|
||
```ts
|
||
interface Notification {
|
||
id: string;
|
||
marketplaceId: string;
|
||
entityType: 'order' | 'payment' | 'offer' | 'connector' | 'refund';
|
||
entityId: string;
|
||
severity: 'info' | 'warning' | 'critical';
|
||
eventType: PlatformEvent['type'];
|
||
read: boolean;
|
||
deepLink: string; // e.g. /admin/orders/{id}
|
||
createdAt: string;
|
||
}
|
||
|
||
interface DeliveryAttempt {
|
||
notificationId: string;
|
||
channel: 'telegram' | 'email' | 'sms' | 'vk' | 'max';
|
||
status: 'sent' | 'failed';
|
||
error?: string;
|
||
attemptedAt: string;
|
||
}
|
||
```
|
||
|
||
```
|
||
GET /api/admin/v2/notifications?marketplaceId=&unreadOnly=&eventType=
|
||
PATCH /api/admin/v2/notifications/{id}/read
|
||
```
|
||
|
||
Invariant: a `DeliveryAttempt` failure on an external channel **never** prevents the `Notification` row itself from being created and visible in the backoffice unread queue.
|
||
|
||
## 7. What the frontend will start doing once this ships
|
||
|
||
- Repoint `AdminOrderWatcherService` from polling `AdminOrdersLocalGateway` to the event stream / `GET /api/admin/v2/notifications?unreadOnly=true`.
|
||
- Build the backoffice **Notifications** section (unread queue, severity, marketplace/event-type filter) — currently missing from admin nav entirely.
|
||
- Wire admin order actions (assign, resend notification, replay sync, cancel/refund, comment, export) to the endpoints in §4.
|