324 lines
15 KiB
Markdown
324 lines
15 KiB
Markdown
|
|
# 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.
|