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