docs: partner provisioning API contract, routing context, Track P
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:
sdarbinyan
2026-08-18 11:22:24 +04:00
parent 551a22a245
commit 71da5a8d80
11 changed files with 560 additions and 10 deletions

View 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.