Files
marketplaces/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-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

7.5 KiB
Raw Blame History

Phase 2 Backend Contract — Canonical Orders, Event Bus, Notification Center

Companion to PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Phase 2 (Sprints 2.12.2). Depends on Phase 1 (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 for the seller-scoped view).

3. Entities

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) — no facade change needed, only binding a real AdminOrdersApiGateway.

5. Event bus

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 §5 for the messenger-side orchestration).

6. Notification Center

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.