diff --git a/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md b/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md new file mode 100644 index 0000000..42b4fea --- /dev/null +++ b/docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md @@ -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.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; +} + +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. diff --git a/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md b/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md new file mode 100644 index 0000000..1a10ac7 --- /dev/null +++ b/docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md @@ -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.1–3.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; + media: string[]; + status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived'; +} + +interface Variant { + id: string; + productId: string; + sku: string; + barcode?: string; + optionValues: Record; // 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; + 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. diff --git a/docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md b/docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md new file mode 100644 index 0000000..91713c5 --- /dev/null +++ b/docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md @@ -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 5–8) 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.