Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -27,7 +27,7 @@ Companion to [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](PRODUCT-PLAN-v3.1-GAP-ANALYSIS.
|
|||||||
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
|
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
|
||||||
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
|
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
|
||||||
|
|
||||||
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
|
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **P** Partner provisioning API (P1–P3 gate Phase 1) · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ Nothing downstream can be honestly estimated until this closes. Two sprints: one
|
|||||||
|
|
||||||
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
|
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
|
||||||
|
|
||||||
- [x] **Backend ownership.** *Still open — user flagged the question itself as unclear on first pass; re-ask in plain terms before Phase 1 implementation starts (not just contract-writing).* Nothing downstream is blocked by this being open — the Phase 1 backend contract doc exists regardless of who builds against it.
|
- [x] **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository's team owns the frontend and the contract set itself, which is why the contracts are the primary handoff artifact rather than a side deliverable.
|
||||||
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed once backend ownership is confirmed.
|
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed.
|
||||||
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
|
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
|
||||||
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
|
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
|
||||||
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
|
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
|
||||||
@@ -395,6 +395,23 @@ Only after Commerce Core is real. The plan is explicit that Gorbushka does not d
|
|||||||
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
|
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
|
||||||
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
|
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
|
||||||
|
|
||||||
|
### Track P — Partner provisioning API (added 2026-08-18)
|
||||||
|
|
||||||
|
Inbound partner API for programmatic merchant-hierarchy management. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](backend/PARTNER-PROVISIONING-API-CONTRACT.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
|
||||||
|
|
||||||
|
**P1–P3 gate Phase 1.** They change the payments and tenant schemas, so they must land before Phase 1 is implemented — retrofitting a routing dimension onto a populated payments table costs far more than carrying it from the first row. P4 onward can run any time after.
|
||||||
|
|
||||||
|
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md)). Frozen at checkout-session creation, immutable after. **M**
|
||||||
|
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). **M**
|
||||||
|
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md). **M**
|
||||||
|
- [ ] **P4** Provisioning endpoints: create/read/status/disable for project, store, payment point, with cascading disable. **L**
|
||||||
|
- [ ] **P5** Idempotency-Key handling: replay on identical body, `409` on same key + different body, in-flight collision, 24h retention. **M**
|
||||||
|
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/TRACK-S-SECURITY-RBAC-CONTRACT.md)). **L**
|
||||||
|
- [ ] **P7** Read surfaces: full-hierarchy fetch, `externalReference` lookup, partner-scoped audit query. **M**
|
||||||
|
- [ ] **P8** `PartnerProfile` config: required levels, level aliases, routing field names, rate tier, rotation window. Onboarding a partner must be a config row, not a deployment. **M**
|
||||||
|
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
|
||||||
|
- [ ] **P10** Partner OpenAPI spec generated from the implementation, plus documented error codes and published rate limits. **M**
|
||||||
|
|
||||||
### Track Q — QA & E2E (§13)
|
### Track Q — QA & E2E (§13)
|
||||||
|
|
||||||
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
|
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ Not gaps — assets and risks they should know about before sequencing:
|
|||||||
|
|
||||||
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
|
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
|
||||||
|
|
||||||
1. **Backend ownership — still open.** First-pass question wasn't understood; needs re-asking in plain terms before Phase 1 implementation (not just contract-writing) starts.
|
1. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository owns the frontend and the contract set.
|
||||||
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
|
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
|
||||||
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
|
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
|
||||||
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
|
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
[../../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
|
## 7. Known open decisions
|
||||||
|
|
||||||
- Registry reachability for CI (reverse proxy + TLS, or a different registry entirely).
|
- 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).
|
- 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).
|
- 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.
|
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.
|
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.
|
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
|
## 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.
|
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`.
|
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.
|
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';
|
status: 'requested' | 'approved' | 'processing' | 'completed' | 'failed';
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
completedAt?: 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 }
|
POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }
|
||||||
GET /api/admin/v2/orders/{orderId}/refunds
|
GET /api/admin/v2/orders/{orderId}/refunds
|
||||||
@@ -43,6 +46,7 @@ interface ReconciliationRecord {
|
|||||||
resolvedBy?: string;
|
resolvedBy?: string;
|
||||||
resolvedAt?: string;
|
resolvedAt?: string;
|
||||||
resolutionNote?: 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 }
|
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
|
## 3. Settlements
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -78,9 +84,23 @@ interface Settlement {
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /api/seller/v1/finance/settlements
|
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)
|
## 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.
|
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
|
## 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
|
```ts
|
||||||
interface Marketplace {
|
interface Marketplace {
|
||||||
id: string;
|
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;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
type: 'commerce' | 'mall_directory' | 'hybrid' | 'single_brand';
|
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.
|
**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
|
## 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-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 |
|
| [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
|
## 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
|
## 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.
|
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
|
## 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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
id: ADR-0003
|
||||||
|
title: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||||
|
status: active
|
||||||
|
date: 2026-08-18
|
||||||
|
supersedes: []
|
||||||
|
tags: [architecture, api, payments, multi-tenant, security, decision]
|
||||||
|
---
|
||||||
|
|
||||||
|
# ADR-0003: Build partner merchant-provisioning as a generic API, not a per-partner integration
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A partner asked (2026-08-18) for an API to programmatically manage a merchant hierarchy — Company → Project → Store → PaymentPoint — with idempotent provisioning, `externalReference` lookup, TEST/LIVE separation, public-key credentials with scoped authority and rotation, and payment/callback fields that route a payment unambiguously to one store.
|
||||||
|
|
||||||
|
Their request arrived written in their own vocabulary. Building against that vocabulary directly would produce a partner-shaped API, and the next partner asking for the same capability with different level names would either get a second parallel surface or force a rename through our schema.
|
||||||
|
|
||||||
|
Three facts about our current model made the ask non-trivial:
|
||||||
|
|
||||||
|
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md)). No company, no project.
|
||||||
|
2. Payments carry no store dimension ([Phase 1](../../backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
|
||||||
|
3. We have no partner-facing write API at all. [Phase 4](../../backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md) is outbound/ingest — the opposite direction.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Build one generic partner provisioning API. Contract: [PARTNER-PROVISIONING-API-CONTRACT.md](../../backend/PARTNER-PROVISIONING-API-CONTRACT.md).
|
||||||
|
|
||||||
|
### 1. Partner-specific behaviour is config, never schema
|
||||||
|
|
||||||
|
No partner name appears in any entity, field, endpoint, or status value. Everything partner-varying lives in a `PartnerProfile` row: which levels are required, level name aliases, routing field names, rate-limit tier, key rotation window, webhook field map. **Onboarding a partner is a config row, not a deployment.**
|
||||||
|
|
||||||
|
Deliberately *not* configurable, because configurability there breaks reconciliation or safety: status values and transitions, idempotency semantics, environment partitioning, signature scheme, the four-level ceiling.
|
||||||
|
|
||||||
|
### 2. Fixed four levels with optional middles, not a free-form tree
|
||||||
|
|
||||||
|
`company → project → store → payment_point`. Middle levels are omittable per partner profile; depth is never partner-defined. An arbitrary-depth tree would push every downstream consumer — routing, reconciliation, settlement, audit — into handling shapes no partner actually has.
|
||||||
|
|
||||||
|
### 3. Credentials are node-scoped
|
||||||
|
|
||||||
|
The partner asked us to choose between per-company, per-project, and per-store credentials. We answer all three with one mechanism: a credential binds to **any single node**, and its authority is that node's subtree. Partner keypairs are partner-generated; we hold only the public key. Rotation runs with a bounded overlap; revocation is immediate and irreversible.
|
||||||
|
|
||||||
|
### 4. Level mapping onto our model
|
||||||
|
|
||||||
|
| Partner level | Our entity |
|
||||||
|
|---|---|
|
||||||
|
| `company` | new, thin |
|
||||||
|
| `project` | new, thin — a product line (e.g. `marketplaces`) |
|
||||||
|
| `store` | `Marketplace` (Phase 9), gains `companyId`/`projectId`/`externalReference` |
|
||||||
|
| `payment_point` | new — one payment method accepted at one marketplace (`qr`, `card`; both ship today) |
|
||||||
|
|
||||||
|
`PaymentPoint` is an acceptance channel, not a physical till and not a settlement account. Registering one never enables real money — financial enablement is a separate approved flow that sets `providerAccountRef`.
|
||||||
|
|
||||||
|
### 5. Seller is excluded from the hierarchy
|
||||||
|
|
||||||
|
`Seller` ([Phase 5](../../backend/PHASE-5-SELLER-PORTAL-CONTRACT.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
|
||||||
|
|
||||||
|
### 6. RoutingContext lands in Phase 1 before implementation, not after
|
||||||
|
|
||||||
|
`RoutingContext` (companyId, routingPath, leafNodeId, environment, merchantReference, providerPaymentId) is required on `CheckoutSession`, `PaymentIntent`, `Payment`, `Refund`, `ReconciliationRecord`. Frozen at checkout-session creation, immutable thereafter.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Cost now:** two new entities (`Company`, `Project`), one new leaf (`PaymentPoint`), three amended contracts (Phases 1, 7, 9) plus Track S §4.1, and a backfill for existing marketplaces (Phase 9 §1.2).
|
||||||
|
|
||||||
|
**Cost avoided:** retrofitting a routing dimension onto a populated payments table after launch; a second parallel provisioning surface for partner number two.
|
||||||
|
|
||||||
|
**Accepted limits:**
|
||||||
|
- A partner needing more than four levels cannot be served without a contract change. Judged unlikely enough to be worth the simplicity.
|
||||||
|
- Backfilled rows carry a synthetic company and project. `externalReference` stays null for them.
|
||||||
|
- Partners cannot create companies through the API — company creation stays a commercial, out-of-band action.
|
||||||
|
|
||||||
|
**Unaffected:** the `@marketplaces/auth` / `@marketplaces/payment` package split ([ADR-0001](ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md)). The provisioning API is backend-side; nothing about it belongs in a frontend package.
|
||||||
@@ -5,3 +5,9 @@
|
|||||||
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
|
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
|
||||||
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
|
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
|
||||||
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}
|
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}
|
||||||
|
{"id":"PV-20260818T104000Z-c7d1","subject":"partner-merchant-provisioning","predicate":"is-decided-to-build-as","object":"one generic inbound API where all partner-specific behaviour is a PartnerProfile config row (required levels, level aliases, routing field names, rate tier); no partner name appears in any entity, field, endpoint or status value","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:40:00Z","confidence":"high","tags":["architecture","api","partner","decision"]}
|
||||||
|
{"id":"PV-20260818T104100Z-e2b8","subject":"partner-hierarchy-levels","predicate":"map-onto","object":"company and project are new thin entities above Marketplace; store IS Marketplace (Phase 9); payment_point is new and equals one payment method accepted at one marketplace (qr, card)","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:41:00Z","confidence":"high","tags":["architecture","multi-tenant","payments","decision"]}
|
||||||
|
{"id":"PV-20260818T104200Z-f5a9","subject":"Seller","predicate":"is-excluded-from","object":"the partner provisioning hierarchy; a payment routes to exactly one payment point, is reconciled there, and only then splits across sellers in Phase 7 settlement","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:42:00Z","confidence":"high","tags":["payments","reconciliation","sellers"]}
|
||||||
|
{"id":"PV-20260818T104300Z-b3c4","subject":"RoutingContext","predicate":"is-required-on","object":"CheckoutSession, PaymentIntent, Payment, Refund and ReconciliationRecord; frozen at checkout-session creation and immutable thereafter, so a payment is always attributable to exactly one payment point","src":["docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:43:00Z","confidence":"high","tags":["payments","reconciliation","contract"]}
|
||||||
|
{"id":"PV-20260818T104400Z-d9e2","subject":"partner-api-credentials","predicate":"are-scoped-by","object":"a single node whose subtree defines authority; we hold only the partner-generated public key, rotation runs on a bounded overlap window and revocation is immediate and irreversible","src":["docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md","docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:44:00Z","confidence":"high","tags":["security","credentials","partner"]}
|
||||||
|
{"id":"PV-20260818T104500Z-a6f7","subject":"checkout-payment-methods","predicate":"already-support","object":"both qr and card end to end in src/app/pages/cart/cart.component.ts (separate create paths and separate status pollers); card is not an outstanding gap","src":["src/app/pages/cart/cart.component.ts","src/app/services/api.service.ts"],"status":"active","kind":"implemented","updated_at":"2026-08-18T10:45:00Z","confidence":"high","tags":["payments","frontend"]}
|
||||||
|
|||||||
Reference in New Issue
Block a user