docs: backend contracts for Phases 2-4 (orders/notifications, catalog/offer, connectors)

Continues the Phase 1 contract doc with the same wire-contract-only style.
All three build on Sprint 0.1's answered decisions - no further business
input needed to start implementation once backend ownership is confirmed:

- Phase 2: canonical Order/OrderLine/Fulfillment/OrderEvent per the unified
  multi-seller decision (one Order, per-seller Fulfillment groups), event
  bus, Notification Center contract.
- Phase 3: Product/Offer split, InventoryRecord, publish-time executability
  validation (the mechanism behind "no branch may distinguish an inspector
  from a normal buyer").
- Phase 4: generic config-driven connector framework per the "no fixed
  marketplace list" decision - onboarding a new partner is configuration
  against a fixed pipeline, not a bespoke integration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-17 23:02:52 +04:00
parent 8d9eb97e9e
commit c91b75c036
3 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
# 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.12.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
}
```
## 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.

View File

@@ -0,0 +1,144 @@
# 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.
## 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.
## 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.

View File

@@ -0,0 +1,123 @@
# Phase 4 Backend Contract — External Order Connector Framework
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 4 (Sprint 4.1, generic framework; Sprint 4.2 retired as "per named marketplace"). Depends on [Phase 3](PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) (`Offer`/`sellerSku` must exist to map onto) and [Phase 2](PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md) (`Order` canonical model).
**Status: ready to build, generic by design.** Sprint 0.1 decision (2026-08-17): no fixed marketplace list — "our new ones, partners, new, etc." This contract specifies a config-driven framework, not a per-provider integration. Zero of this exists in the codebase today (`reconcil*`, `idempot*`, `hostinger` all return 0 hits).
---
## 1. Design principle
**A new partner connector is an onboarding action against this framework, not a code change.** Auth type, field mapping, and rate limits are configuration; the pipeline (ingest → normalize → map → idempotency-check → create/update order → notify) is fixed and shared across every connector.
## 2. Entities
```ts
interface Connector {
id: string;
marketplaceId: string;
provider: string; // free-text label, e.g. "ozon", "wildberries" - not an enum, new values need no code change
authType: 'webhook_signed' | 'api_key' | 'oauth2';
credentialRef: string; // pointer into secret storage, never the secret itself
pollingIntervalSeconds?: number; // set only when the provider has no webhook
cursorState?: string; // opaque, connector-specific pagination/since cursor
status: 'active' | 'paused' | 'error';
}
interface RawExternalEvent {
id: string;
connectorId: string;
payload: unknown; // stored verbatim, before any parsing - the traceability anchor
receivedAt: string;
processedAt?: string;
}
interface ExternalOrderMapping {
connectorId: string;
externalSellerId: string;
externalProductId: string;
externalSku: string;
internalSellerId: string;
internalOfferId: string; // references Phase 3's Offer
}
interface DeadLetter {
id: string;
connectorId: string;
rawEventId: string;
reason: string;
retryCount: number;
lastAttemptAt: string;
resolvedAt?: string;
}
```
## 3. Pipeline (fixed, shared across every connector)
```
1. Connector receives webhook, or polling finds a new event via cursorState.
2. Signature/auth verified. Idempotency key = connectorId + externalOrderId/eventId.
3. Payload persisted as RawExternalEvent BEFORE any parsing.
4. Normalizer maps payload -> canonical ExternalOrderEvent shape (fixed schema, see §4).
5. SKU mapping resolves externalSku -> internal Offer via ExternalOrderMapping.
No mapping found -> event goes to the Unmatched queue (§5), does NOT fail silently.
6. Order created/updated via the Phase 2 Order API, source: 'external', externalOrderRef set.
7. external_order.imported and order.created events emitted (Phase 2 event bus).
8. Fulfillment/status changes pushed back to the external marketplace if its API supports it.
```
## 4. Canonical external order event (what the normalizer produces)
```ts
interface ExternalOrderEvent {
connectorId: string;
externalOrderId: string;
externalCreatedAt: string;
customer: { name?: string; contact?: string };
lines: Array<{ externalSku: string; qty: number; unitPriceMinor: number; currency: string }>;
totalMinor: number;
currency: string;
rawEventId: string; // traceability back to §2
}
```
Every provider's adapter is responsible only for producing this shape from its own payload — everything downstream (§3 steps 58) is provider-agnostic.
## 5. Unmatched queue + retry
```
GET /api/admin/v2/integrations/{connectorId}/unmatched
POST /api/admin/v2/integrations/{connectorId}/unmatched/{eventId}/resolve { internalOfferId }
POST /api/admin/v2/integrations/{connectorId}/dead-letter/{id}/replay
```
Retry policy: exponential backoff, capped attempts, then `DeadLetter` with manual replay from backoffice. No connector is allowed to silently drop an event.
## 6. Connector-agnostic SLA (applies to every provider, per plan §5.2)
- Webhook source: 99% of valid events processed in under 60 seconds.
- Polling source: delay no worse than `pollingIntervalSeconds + 60`.
- **Zero** duplicate orders on repeated event delivery (guaranteed by the idempotency key in §3 step 2).
- Every connector error carries a trace id, visible in backoffice.
## 7. Endpoints
```
POST /api/providers/v1/{connector}/webhook -- generic entrypoint, connector resolved by path + auth
GET /api/admin/v2/integrations -- list all connectors + health (last success, lag, errors, backlog)
POST /api/admin/v2/integrations -- onboard a new connector: { provider, authType, credentialRef, marketplaceId }
PATCH /api/admin/v2/integrations/{id} -- pause/resume, update mapping config
```
## 8. Onboarding a new partner (replaces the old "one sprint per named marketplace")
1. Register credentials in secret storage, scoped to marketplace/seller.
2. `POST /api/admin/v2/integrations` with the provider's auth type and mapping config.
3. Write the provider-specific adapter (payload → §4 canonical shape) — the only genuinely bespoke piece per partner.
4. Verify in sandbox against the fixed pipeline (§3) — nothing else changes.
## 9. What the frontend will start doing once this ships
- Build the backoffice **Integrations** section (missing from admin nav today): connector list, health (last success/lag/errors/backlog/unmatched), FX sources, messaging providers.
- Trace-id surfacing on connector errors.
- Unmatched-queue resolution UI.