docs: partner provisioning API contract, routing context, Track P
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
A partner integration request landed for programmatic merchant-hierarchy management (Company/Project/Store/PaymentPoint). Built the answer generically: partner-specific behaviour is a PartnerProfile config row, and no partner name appears in any entity, field, endpoint or status value. New: - docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md - hierarchy, idempotency, node-scoped public-key credentials, TEST/LIVE partition, routing context - docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md Amended, because the schema impact must land before Phase 1 is implemented: - Phase 1 gains RoutingContext on CheckoutSession/PaymentIntent/Payment, frozen at checkout-session creation and immutable after - Phase 7 gains routing on Refund/ReconciliationRecord, plus the rule that seller settlement splits happen after routing, never as a hierarchy level - Phase 9 gains Company/Project above Marketplace and PaymentPoint below it, with a backfill sequence for existing marketplaces - Track S gains partner credentials: public key only, node-scoped authority, rotation with overlap, immediate revoke, audit coverage Also: Track P (P1-P10) in the delivery plan, and backend ownership closed as answered across the contract set. Card payment was checked, not added - qr and card both already ship in cart.component.ts with separate create paths and status pollers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ Constraints already fixed by the frontend design (see the platform-vision facts
|
||||
4. [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) — **gates the launch.** Today the admin role model is decorative: nothing server-side enforces any permission. §8 covers per-marketplace bootstrap admin accounts and self-service sub-admin management.
|
||||
5. [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) — longest lead time, start it in parallel with Phase 1.
|
||||
6. Phases 5→10 — post-launch-gate.
|
||||
7. [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) — the inbound partner API. Read it **before implementing Phase 1**, not after: it adds `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and two levels above `Marketplace` ([Phase 9 §1](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). Building the partner API itself can wait; carrying its routing dimension in the payments tables cannot.
|
||||
|
||||
[../../BACKEND-API-REFERENCE.md](../../BACKEND-API-REFERENCE.md) documents the *current* live API surface (legacy endpoints, error envelope, mock-only areas). New endpoints use `/api/v2/...` namespaces; legacy endpoints are not being migrated.
|
||||
|
||||
@@ -90,6 +91,7 @@ Angular 22, Node 20+. nginx serves `/srv/marketplaces/current/frontend`, so depl
|
||||
## 7. Known open decisions
|
||||
|
||||
- Registry reachability for CI (reverse proxy + TLS, or a different registry entirely).
|
||||
- Backend ownership was still unnamed as of Sprint 0.1.
|
||||
- ~~Backend ownership.~~ Answered 2026-08-18: implemented by a separate backend developer against this contract set.
|
||||
- Additional payment providers (wallets, BNPL) — [Phase 7 §4](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md).
|
||||
- Per-connector marketplace adapters — written per partner at onboarding, [Phase 4 §8](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md).
|
||||
- Backfill of `Company`/`Project`/`PaymentPoint` for existing marketplaces — sequence specified in [Phase 9 §1.2](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md), not yet scheduled.
|
||||
|
||||
323
docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md
Normal file
323
docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Partner Provisioning API Contract — Merchant Hierarchy, Credentials, Routing
|
||||
|
||||
Cross-cutting contract. Partner-facing, **inbound**: external partners call us. Distinct from [Phase 4](PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md), which is outbound/ingest.
|
||||
|
||||
Depends on [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) (payment state machine), [Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md) (seller org), [Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md) (marketplace registry), [Track S](TRACK-S-SECURITY-RBAC-CONTRACT.md) (keys, audit, rate limiting).
|
||||
|
||||
**Status: draft — level mapping decided (§10), two new entities required.**
|
||||
|
||||
Origin: a partner integration request (2026-08-18). **This contract is deliberately generic.** No partner name appears in any entity, field, endpoint, or status value. Partner-specific behaviour lives entirely in a `PartnerProfile` config row (§8). A second partner asking for the same thing must require zero schema and zero endpoint change.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
Partners need to provision and manage their own merchant hierarchy programmatically, then have payments route unambiguously back to the correct leaf. Today we have no partner-facing write API at all, no entity above `Marketplace`, and payments carry no store dimension — reconciliation cannot attribute a payment to a store.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hierarchy model
|
||||
|
||||
Four levels, fixed. Middle levels are **optional per partner**, never free-form depth.
|
||||
|
||||
```
|
||||
Company -> Project -> Store -> PaymentPoint
|
||||
```
|
||||
|
||||
```ts
|
||||
type NodeLevel = 'company' | 'project' | 'store' | 'payment_point';
|
||||
|
||||
interface ProvisioningNode {
|
||||
id: string; // stable, opaque, never reused
|
||||
level: NodeLevel;
|
||||
parentId: string | null; // null only for level 'company'
|
||||
companyId: string; // denormalized root, present on every node
|
||||
path: string[]; // ordered ancestor ids, root first, inclusive of self
|
||||
environment: Environment;
|
||||
status: NodeStatus;
|
||||
externalReference: string; // partner's own id for this node
|
||||
displayName: string;
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
}
|
||||
|
||||
type Environment = 'TEST' | 'LIVE';
|
||||
type NodeStatus = 'active' | 'suspended' | 'disabled';
|
||||
```
|
||||
|
||||
### Invariants
|
||||
|
||||
1. `parentId` must be the immediately preceding **enabled** level in the partner's profile. Skipping a required level is `422`.
|
||||
2. `companyId` and `environment` are inherited from the parent and are immutable.
|
||||
3. `externalReference` is unique per `(companyId, environment, level)`. Collision is `409`.
|
||||
4. `path` is server-computed. Never accepted from the client.
|
||||
5. A node cannot be re-parented. Ever. Move = disable + create new.
|
||||
6. Creating any node **never** enables a financial capability, never creates a payment, never opens a settlement account. Financial enablement is a separate, explicitly approved flow outside this contract.
|
||||
|
||||
### Status semantics
|
||||
|
||||
| Status | Meaning | Accepts payments | Reversible |
|
||||
|---|---|---|---|
|
||||
| `active` | normal | yes | — |
|
||||
| `suspended` | temporarily halted | no | yes, back to `active` |
|
||||
| `disabled` | terminal | no | no |
|
||||
|
||||
- Disabling a node cascades `disabled` to every descendant, atomically.
|
||||
- Suspending a node cascades `suspended` to descendants; un-suspending restores **only** descendants that were suspended by that same cascade (tracked by cascade id), never descendants suspended independently.
|
||||
- `disabled` never returns to any other status. Re-provisioning creates a new node with a new id.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environments
|
||||
|
||||
`TEST` and `LIVE` are a hard partition:
|
||||
|
||||
- Separate credentials. A `TEST` key can never address a `LIVE` node, and vice versa — cross-environment access is `403`, not `404`.
|
||||
- Node ids never collide across environments and are never transferable.
|
||||
- `externalReference` uniqueness is scoped per environment — the same partner reference may exist once in each.
|
||||
- No data, config, or hierarchy copy between environments in this API.
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
Namespace `/api/partner/v1/`. All timestamps ISO 8601 UTC.
|
||||
|
||||
### 4.1 Write
|
||||
|
||||
```
|
||||
POST /api/partner/v1/companies/{companyId}/projects
|
||||
POST /api/partner/v1/projects/{projectId}/stores
|
||||
POST /api/partner/v1/stores/{storeId}/payment-points
|
||||
|
||||
PATCH /api/partner/v1/nodes/{nodeId}/status -- { status: 'active' | 'suspended', reason?: string }
|
||||
POST /api/partner/v1/nodes/{nodeId}/disable -- terminal, cascading
|
||||
```
|
||||
|
||||
Creation body:
|
||||
|
||||
```ts
|
||||
interface CreateNodeRequest {
|
||||
externalReference: string;
|
||||
displayName: string;
|
||||
metadata?: Record<string, string>; // opaque to us, echoed back, never interpreted
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Read
|
||||
|
||||
```
|
||||
GET /api/partner/v1/nodes/{nodeId}
|
||||
GET /api/partner/v1/companies/{companyId}/hierarchy?environment=TEST|LIVE&status=...&depth=...
|
||||
GET /api/partner/v1/nodes/lookup?externalReference=...&level=...&environment=...
|
||||
GET /api/partner/v1/companies/{companyId}/audit?from=...&to=...&cursor=...
|
||||
```
|
||||
|
||||
- `hierarchy` returns the full tree with current statuses, one call, cursor-paginated over nodes when large.
|
||||
- `lookup` is the `externalReference` resolver. Returns `404` when unmatched — never a partial or fuzzy match.
|
||||
- Read endpoints are the partner's own verification surface for what was actually created. They read from the same store as writes — never a cache that can lag behind a create.
|
||||
|
||||
### 4.3 Not in this API
|
||||
|
||||
Company creation. A `Company` is created by us during commercial onboarding, out of band. Partners provision **inside** a company they already have.
|
||||
|
||||
---
|
||||
|
||||
## 5. Idempotency
|
||||
|
||||
Every `POST` requires an `Idempotency-Key` header. `PATCH` status changes accept one optionally.
|
||||
|
||||
```
|
||||
Idempotency-Key: <partner-generated, opaque, <=255 chars>
|
||||
```
|
||||
|
||||
Rules, in order:
|
||||
|
||||
1. Key scope is `(partnerId, endpoint, key)`. Two partners may use the same key string without interference.
|
||||
2. Same key + byte-identical request body → the **original stored response** is replayed, with the original status code. No new node.
|
||||
3. Same key + different body → `409 Conflict`, error code `idempotency_key_reuse`. Nothing is created or modified.
|
||||
4. Retention: 24 hours from first use. After expiry the key is free again — partners must not rely on replay beyond 24h.
|
||||
5. A request that arrives while an identical key is still in flight returns `409` with `idempotency_request_in_progress`. Partner retries after a short backoff.
|
||||
6. **No partial hierarchy.** A creation request either commits its node fully or commits nothing. If a partner creates project → store → payment point in three calls and the third fails, the first two remain — that is three operations, each atomic. A single call is never partially applied.
|
||||
|
||||
Body comparison uses a canonical hash (sorted keys, normalized whitespace) so key ordering does not cause a false `409`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Credentials and key management
|
||||
|
||||
### 6.1 Model — answered generically
|
||||
|
||||
Partners asked whether a credential is per-company, per-project, or per-store. **All three, one mechanism:** a credential is bound to **any single node**, and its authority is that node's subtree.
|
||||
|
||||
```ts
|
||||
interface PartnerCredential {
|
||||
partnerId: string;
|
||||
keyId: string;
|
||||
scopeNodeId: string; // credential may act on this node and all descendants
|
||||
environment: Environment;
|
||||
algorithm: 'ed25519' | 'rsa-pss-sha256';
|
||||
publicKey: string; // PEM or base64 raw, per algorithm
|
||||
status: 'active' | 'rotating' | 'revoked';
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- Partner generates the keypair. The **private key never leaves the partner** and is never transmitted to us, never logged, never accepted by any endpoint.
|
||||
- Partner registers the public key; we return `partnerId` + `keyId`.
|
||||
- Authority is strictly the `scopeNodeId` subtree. Any request touching a node outside it is `403`.
|
||||
- A credential can never widen its own scope, register another credential at a wider scope, or create a node above its scope.
|
||||
|
||||
### 6.2 Endpoints
|
||||
|
||||
```
|
||||
POST /api/partner/v1/credentials -- register public key, returns partnerId + keyId
|
||||
GET /api/partner/v1/credentials
|
||||
POST /api/partner/v1/credentials/{keyId}/rotate -- register successor public key, overlap window
|
||||
DELETE /api/partner/v1/credentials/{keyId} -- revoke, effective immediately
|
||||
```
|
||||
|
||||
- **Rotation:** the successor key is registered while the current key stays valid for a bounded overlap (default 7 days, configurable per profile). Both keys verify during overlap. The predecessor auto-revokes at window end.
|
||||
- **Revocation is immediate and irreversible.** In-flight requests signed with a revoked key fail. Revoking a credential does not touch any node it created.
|
||||
- Registration, rotation, and revocation each emit a Track S audit event. Key lifecycle actions are always attributable to a named actor.
|
||||
|
||||
### 6.3 Request authentication
|
||||
|
||||
Requests are signed, not bearer-token'd:
|
||||
|
||||
- Signature covers: HTTP method, path, canonical body hash, `Idempotency-Key` (when present), and a timestamp.
|
||||
- Timestamp skew tolerance ±5 minutes. Outside that → `401`.
|
||||
- Signature replay within the window is rejected by nonce tracking → `401`.
|
||||
- `keyId` travels in the signature header so we select the right public key without trusting the body.
|
||||
|
||||
---
|
||||
|
||||
## 7. Payment routing
|
||||
|
||||
Every payment, callback, refund, and settlement row carries a routing context.
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf, resolves to exactly one leaf
|
||||
leafNodeId: string; // convenience: last element of routingPath
|
||||
environment: Environment;
|
||||
merchantReference: string; // partner-supplied, opaque to us, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
### Invariants
|
||||
|
||||
1. `routingPath` must resolve to exactly one leaf node. Ambiguous or unresolvable → the payment is rejected at creation, never accepted and reconciled later.
|
||||
2. `merchantReference` is stored verbatim and echoed on **every** downstream event: payment status change, refund, settlement line, webhook.
|
||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
||||
4. Routing context is immutable for the life of the payment. Node status changes afterwards never rewrite it.
|
||||
|
||||
### Contract amendments this requires
|
||||
|
||||
`RoutingContext` must be added to:
|
||||
|
||||
- [Phase 1](PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) — `Payment`, `PaymentEvent`, checkout session
|
||||
- [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) — refund, reconciliation row, settlement line
|
||||
|
||||
**Do this before backend implements Phase 1.** Retrofitting a routing dimension onto a live payments table is materially more expensive than adding it now.
|
||||
|
||||
Partner-facing serialization uses the partner's own field names (§8) — `routingPath` is emitted as `projectId`/`storeId`/`paymentPointId` for a partner using those terms, without the core model knowing those words.
|
||||
|
||||
---
|
||||
|
||||
## 8. Partner profile — the only partner-specific surface
|
||||
|
||||
```ts
|
||||
interface PartnerProfile {
|
||||
partnerId: string;
|
||||
requiredLevels: NodeLevel[]; // subset; 'company' and the leaf are always required
|
||||
levelAliases: Record<NodeLevel, string>; // e.g. { project: 'Project', store: 'Store' }
|
||||
routingFieldNames: Record<NodeLevel, string>; // e.g. { store: 'storeId' }
|
||||
rateLimitTier: string;
|
||||
keyRotationOverlapDays: number;
|
||||
webhookFieldMap?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
A partner with no "project" concept omits it from `requiredLevels`; their stores hang directly off the company and the hierarchy still validates. A partner calling stores "branches" changes one alias. **Adding a partner is a config row, not a deployment.**
|
||||
|
||||
What is deliberately **not** configurable, because configurability here breaks reconciliation or safety:
|
||||
|
||||
- `NodeStatus` values and their transition rules
|
||||
- Idempotency semantics
|
||||
- Environment partitioning
|
||||
- Signature scheme and skew tolerance
|
||||
- The four-level ceiling
|
||||
|
||||
---
|
||||
|
||||
## 9. Operational requirements
|
||||
|
||||
| Requirement | Contract |
|
||||
|---|---|
|
||||
| OpenAPI | Machine-readable spec published per version, generated from the implementation, never hand-maintained |
|
||||
| Sandbox | `TEST` environment is the sandbox. Same code path as `LIVE`, isolated data, no real money |
|
||||
| Error codes | Stable string codes, documented, never renamed. HTTP status + `code` + human `message` + `requestId` |
|
||||
| Rate limits | Per `partnerId`, per tier. `429` with `Retry-After`. Limits published in the spec, per Track S |
|
||||
| Audit | Every write is an audit event: actor (`keyId`), action, target node, before/after status, `requestId`, timestamp. Immutable, queryable via §4.2 |
|
||||
| Idempotency observability | `Idempotency-Replayed: true` response header when a stored response is replayed |
|
||||
|
||||
### Error codes
|
||||
|
||||
```
|
||||
validation_failed 422
|
||||
parent_not_found 404
|
||||
level_skipped 422
|
||||
external_reference_conflict 409
|
||||
idempotency_key_reuse 409
|
||||
idempotency_request_in_progress 409
|
||||
scope_forbidden 403
|
||||
environment_mismatch 403
|
||||
node_disabled 409
|
||||
signature_invalid 401
|
||||
signature_expired 401
|
||||
rate_limited 429
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Mapping onto our model
|
||||
|
||||
Decided 2026-08-18.
|
||||
|
||||
| Partner level | Our entity | State |
|
||||
|---|---|---|
|
||||
| `company` | — | **New.** No entity above `Marketplace` exists today. Legal/commercial owner, created out of band (§4.3). |
|
||||
| `project` | — | **New.** A product line, e.g. `marketplaces`. One company runs several. Not the same thing as a `Marketplace`. |
|
||||
| `store` | `Marketplace` ([Phase 9](PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)) | Exists. Gains `companyId`, `projectId`, `externalReference`. |
|
||||
| `payment_point` | — | **New.** An acceptance channel: one payment method bound to one store. Many per store. |
|
||||
|
||||
### 10.1 PaymentPoint = acceptance channel
|
||||
|
||||
A `PaymentPoint` is a payment method enabled on a store, not a physical location and not a settlement account.
|
||||
|
||||
```ts
|
||||
interface PaymentPointConfig {
|
||||
method: PaymentMethod; // 'qr' | 'card', extensible
|
||||
currencies: string[]; // ISO 4217 subset the channel accepts
|
||||
providerAccountRef?: string; // opaque provider-side binding, set during financial enablement
|
||||
}
|
||||
```
|
||||
|
||||
Both current methods ship today — `src/app/pages/cart/cart.component.ts` (`PaymentMethod = 'qr' | 'card'`, separate create + status-poll paths per method). A store accepting both has two payment points.
|
||||
|
||||
Creating a payment point registers the channel. It does **not** enable it for real money — §2 invariant 6 still holds. Financial enablement sets `providerAccountRef` through a separate approved flow.
|
||||
|
||||
### 10.2 Seller is orthogonal
|
||||
|
||||
`Seller` ([Phase 5](PHASE-5-SELLER-PORTAL-CONTRACT.md)) is **not** a level in this hierarchy. A multi-seller marketplace is one `store` with many sellers underneath; seller-level settlement splitting happens in [Phase 7](PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) reconciliation, after the payment has already been routed to the store. Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most will not have.
|
||||
|
||||
### 10.3 Consequences
|
||||
|
||||
1. Two new entities: `Company`, `Project`. Both are thin — id, name, `externalReference`, status, timestamps — and both sit above `Marketplace`.
|
||||
2. `Marketplace` gains `companyId` + `projectId`. Existing marketplaces need a backfill company and project.
|
||||
3. `PaymentPoint` is new and is what `routingPath` terminates at (§7).
|
||||
4. §7's contract amendments to Phases 1 and 7 do not depend on any of the above — start them now.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Phase 1 (Sprints 1.1–1.4) and [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](../PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md) §3.3/§3.5/§3.6.
|
||||
|
||||
**Status: unblocked (2026-08-17).** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) previously marked the cart/payment call chain frozen. Per the delivery plan's Sprint 0.1 decision, the freeze is lifted — this contract can move to implementation once backend ownership (also Sprint 0.1, still open) is confirmed.
|
||||
**Status: unblocked (2026-08-17).** [BACKEND-API-REFERENCE.md §7](../../BACKEND-API-REFERENCE.md) previously marked the cart/payment call chain frozen. Per the delivery plan's Sprint 0.1 decision, the freeze is lifted — this contract can move to implementation. Backend ownership was answered 2026-08-18 — a separate backend developer builds against it.
|
||||
|
||||
This doc is the frontend's ask, in the same style as `BACKEND-API-REFERENCE.md`. It does not prescribe backend implementation (DB schema, service boundaries) — only the wire contract and the invariants the frontend needs to hold.
|
||||
|
||||
@@ -204,6 +204,33 @@ Idempotency-Key: <checkoutSessionId>
|
||||
|
||||
A retried call with the same `checkoutSessionId` must return the existing order, not create a second one. This is the mechanism that makes "double-click doesn't create two orders" true regardless of frontend debouncing.
|
||||
|
||||
### 6.5 Routing context
|
||||
|
||||
Added 2026-08-18. Full definition in [PARTNER-PROVISIONING-API-CONTRACT.md §7](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
```ts
|
||||
interface RoutingContext {
|
||||
companyId: string;
|
||||
routingPath: string[]; // ordered node ids, root -> leaf
|
||||
leafNodeId: string; // the payment point money is accepted at
|
||||
environment: 'TEST' | 'LIVE';
|
||||
merchantReference: string; // partner-supplied, opaque, echoed on every related event
|
||||
providerPaymentId: string; // our payment id, stable, unique
|
||||
}
|
||||
```
|
||||
|
||||
`RoutingContext` is a **required** field on `CheckoutSession`, `PaymentIntent`, and `Payment`. `PaymentEvent` does not carry its own copy — it inherits via `paymentIntentId` — but every event **emitted** to the bus or to a partner must include the resolved context so consumers never need a second lookup.
|
||||
|
||||
Invariants:
|
||||
|
||||
1. Resolved and frozen at checkout-session creation. Immutable for the life of the payment. Later node status changes never rewrite it.
|
||||
2. `routingPath` must resolve to exactly one leaf. Ambiguous or unresolvable → reject at creation. Never accept a payment and resolve routing during reconciliation.
|
||||
3. A payment whose leaf node is `suspended` or `disabled` is rejected at creation.
|
||||
4. `merchantReference` is stored verbatim, never parsed, never normalized.
|
||||
5. `environment` must match the credential's environment. Mismatch is `403`.
|
||||
|
||||
**This is why it lands now, not later.** Without it, a payment cannot be attributed to a store, and §5's reconciliation goal — reconstructing why a given amount was charged — stops one level short of who it was charged for. Adding a routing dimension to a populated payments table after launch is materially more expensive than carrying it from the first row.
|
||||
|
||||
---
|
||||
|
||||
## 7. What the frontend will stop doing once this ships
|
||||
@@ -226,4 +253,4 @@ A retried call with the same `checkoutSessionId` must return the existing order,
|
||||
1. **Payment chain freeze — lifted.** §5 can proceed.
|
||||
2. **FX rate source/provider — ours, in-house, as the default (not just a fallback).** No external provider committed. Backend computes and serves the quote itself; the `source` field in §3.1 can legitimately read `"internal"` as the normal case. Revisit if an external provider is chosen later — the contract shape doesn't need to change, only the value of `source`.
|
||||
3. **Backend-converted prices vs. frontend-requested display currency — still open, needs confirmation before implementation.** This doc's §5.2 models the frontend sending a target `currency` and the backend returning the converted total. Confirm this is the intended flow before backend implementation starts.
|
||||
4. **Backend ownership — still open.** This contract is ready regardless of who builds against it, but implementation can't be scheduled until this is answered.
|
||||
4. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against this contract. Note §6.5: `RoutingContext` must be carried from the first payment row, not retrofitted.
|
||||
|
||||
@@ -19,9 +19,12 @@ interface Refund {
|
||||
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
routing: RoutingContext; // copied verbatim from the original Payment, never recomputed
|
||||
}
|
||||
```
|
||||
|
||||
A refund always carries the routing context of the payment it reverses. It is copied, not re-resolved — a store suspended after the payment must still be refundable.
|
||||
|
||||
```
|
||||
POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }
|
||||
GET /api/admin/v2/orders/{orderId}/refunds
|
||||
@@ -43,6 +46,7 @@ interface ReconciliationRecord {
|
||||
resolvedBy?: string;
|
||||
resolvedAt?: string;
|
||||
resolutionNote?: string;
|
||||
routing: RoutingContext; // from the Payment; makes every row attributable to one payment point
|
||||
}
|
||||
```
|
||||
|
||||
@@ -56,10 +60,12 @@ Process (per plan §7.3):
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/admin/v2/reconciliation/queue?marketplaceId=&result=
|
||||
GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=
|
||||
POST /api/admin/v2/reconciliation/{id}/resolve { note }
|
||||
```
|
||||
|
||||
Step 3's `merchant_reference` strategy matches on `RoutingContext.merchantReference` — the partner-supplied value, stored verbatim (Phase 1 §6.5). The queue is filterable at every hierarchy level so an unmatched set can be narrowed to one payment point without a join the backoffice has to build itself.
|
||||
|
||||
## 3. Settlements
|
||||
|
||||
```ts
|
||||
@@ -78,9 +84,23 @@ interface Settlement {
|
||||
|
||||
```
|
||||
GET /api/seller/v1/finance/settlements
|
||||
GET /api/admin/v2/finance/settlements?sellerId=&period=
|
||||
GET /api/admin/v2/finance/settlements?sellerId=&companyId=&projectId=&storeId=&period=
|
||||
```
|
||||
|
||||
### 3.1 Seller split happens after routing
|
||||
|
||||
Added 2026-08-18. `Seller` is deliberately **not** a level in the partner hierarchy ([PARTNER-PROVISIONING-API-CONTRACT.md §10.2](PARTNER-PROVISIONING-API-CONTRACT.md)). Order of operations:
|
||||
|
||||
```
|
||||
payment -> routed to exactly one payment point (Phase 1 §6.5, frozen at checkout)
|
||||
-> reconciled at that payment point
|
||||
-> split across the sellers whose lines the order contains (this phase)
|
||||
```
|
||||
|
||||
- A `Settlement` belongs to one seller **within one store**. A seller trading in two stores gets two settlements per period, never one merged row.
|
||||
- Splitting never rewrites `RoutingContext`. The money arrived at one payment point; the split decides who is owed from it.
|
||||
- `grossAmount` summed across a store's settlements for a period must reconcile against that store's matched reconciliation rows for the same period. A mismatch is a reconciliation defect, not a rounding tolerance.
|
||||
|
||||
## 4. Provider breadth (open business question)
|
||||
|
||||
Current flow supports QR and card only, via one custom provider integration. Adding wallets/BNPL is an explicit open business decision (not answered in Sprint 0.1) — this contract's `PaymentIntent`/`Payment` shapes from Phase 1 §6 are provider-agnostic already, so a new provider is a new adapter behind the same state machine, not a schema change. No action needed here until that business decision is made.
|
||||
|
||||
@@ -8,9 +8,39 @@ Companion to [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](../PRODUCT-PLAN-v3.1-DELIVERY-
|
||||
|
||||
## 1. Entities
|
||||
|
||||
Added 2026-08-18: two levels now sit **above** `Marketplace`, introduced by [PARTNER-PROVISIONING-API-CONTRACT.md §10](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
```ts
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
externalReference?: string; // partner's own id, when provisioned via the partner API
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string; // a product line, e.g. "marketplaces"
|
||||
externalReference?: string;
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Both are deliberately thin — they exist to scope ownership, credentials, and payment routing, not to hold configuration. All marketplace configuration stays on `Marketplace` below.
|
||||
|
||||
A `Marketplace` **is** the partner hierarchy's `store` level. One project holds many marketplaces; one marketplace holds many sellers (Phase 5), and sellers are not part of that hierarchy.
|
||||
|
||||
```ts
|
||||
interface Marketplace {
|
||||
id: string;
|
||||
companyId: string; // added 2026-08-18
|
||||
projectId: string; // added 2026-08-18
|
||||
externalReference?: string; // added 2026-08-18, partner's own id for this store
|
||||
name: string;
|
||||
code: string;
|
||||
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
|
||||
@@ -49,6 +79,42 @@ interface MarketplaceRevision {
|
||||
|
||||
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every financial ledger row are **not part of a `MarketplaceRevision`**. Rolling back a storefront design revision must never touch commerce data.
|
||||
|
||||
### 1.1 PaymentPoint
|
||||
|
||||
Added 2026-08-18. The leaf of the partner hierarchy: one payment method accepted at one marketplace. A marketplace taking both QR and card has two payment points.
|
||||
|
||||
```ts
|
||||
interface PaymentPoint {
|
||||
id: string;
|
||||
marketplaceId: string;
|
||||
method: 'qr' | 'card'; // extensible; both ship today
|
||||
currencies: string[]; // ISO 4217 subset this channel accepts
|
||||
externalReference?: string;
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
providerAccountRef?: string; // set only by financial enablement, never by provisioning
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
- Creating a payment point registers the channel. It does **not** enable real money — that requires `providerAccountRef`, set through a separate approved flow.
|
||||
- A payment point is what `RoutingContext.leafNodeId` points at (Phase 1 §6.5).
|
||||
- `MarketplaceFeatureSet.features.payments` gates whether the marketplace may have enabled payment points at all; the payment point gates which method.
|
||||
|
||||
### 1.2 Backfill
|
||||
|
||||
Existing marketplaces predate `Company` and `Project`. Migration, in this order:
|
||||
|
||||
```
|
||||
1. Create one Company for the current owning entity.
|
||||
2. Create one Project ("marketplaces") under it.
|
||||
3. Set companyId + projectId on every existing Marketplace.
|
||||
4. Create PaymentPoints for the methods each marketplace already accepts (qr, card).
|
||||
5. Make companyId and projectId non-nullable only after 3 completes.
|
||||
```
|
||||
|
||||
`externalReference` stays null for backfilled rows — it is only meaningful for partner-provisioned nodes.
|
||||
|
||||
## 2. Lifecycle state machine
|
||||
|
||||
```
|
||||
|
||||
@@ -32,6 +32,7 @@ This directory is the complete set of wire contracts for building the backend be
|
||||
|---|---|---|
|
||||
| [TRACK-A-ANALYTICS-CONTRACT.md](TRACK-A-ANALYTICS-CONTRACT.md) | Event pipeline, funnel, operational/quality metrics, synthetic-traffic separation | Ready — start alongside Phase 1, longest lead time |
|
||||
| [TRACK-S-SECURITY-RBAC-CONTRACT.md](TRACK-S-SECURITY-RBAC-CONTRACT.md) | 17 roles/3 scopes, enforcement, audit log, secrets, rate limiting, step-up auth | Ready — gates the launch |
|
||||
| [PARTNER-PROVISIONING-API-CONTRACT.md](PARTNER-PROVISIONING-API-CONTRACT.md) | Inbound partner API: merchant hierarchy provisioning, idempotency, public-key credentials, payment routing context | Draft — mapping decided, needs Company/Project entities |
|
||||
|
||||
## What is deliberately not in this directory
|
||||
|
||||
@@ -41,4 +42,4 @@ This directory is the complete set of wire contracts for building the backend be
|
||||
|
||||
## One open item across all of these
|
||||
|
||||
**Backend ownership is still unanswered** (Sprint 0.1). Every contract above is ready to hand to whoever builds it — that person/team just hasn't been named yet.
|
||||
**Backend ownership — answered 2026-08-18.** A separate backend developer implements against these contracts. This repository's team owns the frontend and owns *this contract set* — the docs here are the handoff surface between the two, so a change to any contract is a change both sides must see. Keep them current; they are not a one-time deliverable.
|
||||
|
||||
@@ -59,6 +59,20 @@ GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=
|
||||
|
||||
All provider/connector credentials (payment providers, external marketplace connectors, VK/MAX/Telegram bot tokens, FX source keys) live in dedicated secret storage, referenced by opaque `credentialRef` strings in every other contract in this series — never returned in any API response body, never logged in plaintext.
|
||||
|
||||
### 4.1 Partner credentials (inbound)
|
||||
|
||||
Added 2026-08-18. Partners calling our API authenticate with signed requests, not bearer tokens. Full contract: [PARTNER-PROVISIONING-API-CONTRACT.md §6](PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||
|
||||
These are the opposite direction from the rest of §4 and follow a different rule:
|
||||
|
||||
- We hold only the partner's **public** key. The private key is generated by the partner and never transmitted to us, never accepted by any endpoint, never logged. There is nothing to store in secret storage on our side.
|
||||
- Authority is node-scoped: a credential may act on its `scopeNodeId` and that node's descendants, nothing above or beside it. This is a separate axis from the 17 roles in §1 — partner credentials never map onto a human role, and a partner credential can never be granted an admin role.
|
||||
- `TEST` and `LIVE` credentials are disjoint. A `TEST` key addressing a `LIVE` node is `403`.
|
||||
- Rotation runs with a bounded overlap window (default 7 days) during which both keys verify. Revocation is immediate and irreversible.
|
||||
- A credential can never widen its own scope or register another credential at a wider scope.
|
||||
|
||||
Audit coverage (§3) extends to: `partner_credential.registered`, `partner_credential.rotated`, `partner_credential.revoked`, and every partner-initiated node write, with `actor` set to the `keyId` that signed the request.
|
||||
|
||||
## 5. Rate limiting
|
||||
|
||||
```
|
||||
@@ -67,6 +81,8 @@ All provider/connector credentials (payment providers, external marketplace conn
|
||||
|
||||
Applies to storefront/auth/provider endpoints. Frontend currently has **zero** 429 handling anywhere — see [BACKEND-API-REFERENCE.md §5](../../BACKEND-API-REFERENCE.md) for the full error-envelope contract this should follow.
|
||||
|
||||
Partner API limits are per `partnerId`, by tier, with the tier set on `PartnerProfile`. Published in the partner OpenAPI spec — a partner must be able to read its own limit rather than discover it by getting `429`.
|
||||
|
||||
## 6. Step-up authentication
|
||||
|
||||
Required before: bank/payment detail changes (Phase 5 §5), production launch (Phase 9 §3 step 8), role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, and any manual financial override (refund approval outside normal flow, price override on a live order).
|
||||
|
||||
Reference in New Issue
Block a user