mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-15 04:50:08 +00:00
docs: identify orders by order key, drop orderId
The transport-verified Ed25519 signer key already uniquely identifies and authenticates an order, so the separate client-generated orderId is redundant. Remove it from the wire, DB, and recovery: orders are keyed by order_key; signing becomes mandatory (no key = no order); the client backs up only order_sk (order_key is its public part). Two Stripe steps correlate by the shared signer key; Stripe client_reference_id=order_key.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
**Status:** implementation-ready
|
||||
**Companion:** [Product and UX plan](2026-07-20-supporter-badges-v2-product.md)
|
||||
|
||||
An **order** (`orderId` = client UUID, authenticated by an Ed25519 `orderKey`) carries a `product` and a `payment`. It has three persisted states — **requesting → completed | failed** — and issued badges are rows in a separate `badges` ledger (one per paid period). Current provider/subscription status is re-derived on each request, never stored. Order / product(badge) / payment are decoupled and product-extensible.
|
||||
An **order** (identified and authenticated by its Ed25519 `orderKey` — the transport's verified signer key; a new key is a new order) carries a `product` and a `payment`. It has three persisted states — **requesting → completed | failed** — and issued badges are rows in a separate `badges` ledger (one per paid period). Current provider/subscription status is re-derived on each request, never stored. Order / product(badge) / payment are decoupled and product-extensible.
|
||||
|
||||

|
||||
|
||||
@@ -35,7 +35,7 @@ flowchart LR
|
||||
|
||||
| Component | Owns | Must not own |
|
||||
|---|---|---|
|
||||
| Client order | `orderId`, `orderSk` key, purchase UI, cached status, retry schedule | bot/provider truth |
|
||||
| Client order | `orderSk` key (its public part is the order identity), purchase UI, cached status, retry schedule | bot/provider truth |
|
||||
| Client badge | BBS master key, credential receipt and installation | billing state |
|
||||
| Order service | order key registration, product pin, proof verification, order state, provider binding | badge master key, credential |
|
||||
| Badge ledger | one issued badge per paid period (the "grant" is just a row) | provider/billing logic |
|
||||
@@ -47,7 +47,7 @@ Treat these as separate programs with typed interfaces.
|
||||
### Invariants
|
||||
|
||||
1. Provider verification changes the order's payment outcome only; it never signs or installs the badge.
|
||||
2. An order is identified by a client-generated `orderId` (UUID) and authenticated by its `orderKey` — the transport's agent-verified Ed25519 signer key, registered on create and equality-checked on every request. The bot does no signature crypto. No caller identity, no bot-issued token, no bot push.
|
||||
2. An order is identified **and** authenticated by its `orderKey` — the transport's agent-verified Ed25519 signer key; the first request creates it, later requests resolve by the same key. A new key is a new order; signing is mandatory. The bot does no signature crypto. No caller identity, no bot-issued token, no bot push.
|
||||
3. The `product` is immutable per order: pinned on the first request; a differing product later is rejected.
|
||||
4. For a badge product, the client supplies only the SKU (`plan`) and `BadgeMasterKey`; the bot derives tier/expiry and assembles `BadgeInfo`. The declared SKU must equal the SKU verified in `payment`.
|
||||
5. `BadgeMasterKey` (32 random bytes, BBS message 0 via `generateMasterKey`) lives only inside the badge product; it signs the badge and is never an identifier or an auth credential.
|
||||
@@ -64,7 +64,7 @@ A paid order yields one badge per eligible month. Eligibility is **derived from
|
||||
- Subscription: period `n` starts at `addCalendarMonths n verifiedAnchor`, eligible while `periodStart(n) <= now < paidThrough`.
|
||||
- Monthly and yearly plans both expose one period per eligible month.
|
||||
- Badge expiry is the start of the month two months after the period start.
|
||||
- A `badges` row is unique on `(order_id, period_start)`; the master-key hash binds it to the client key.
|
||||
- A `badges` row is unique on `(order_key, period_start)`; the master-key hash binds it to the client key.
|
||||
- Example: 21 July period → badge expires 1 September; monthly billing renews 21 August.
|
||||
|
||||
On each request the bot asks the provider which periods are paid, then issues a badge for any eligible period with no row yet. Refund/revoke simply removes future eligibility (an already-issued BBS badge stands to its expiry); it is never a stored order state.
|
||||
@@ -157,7 +157,7 @@ stateDiagram-v2
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
An order is `product + payment`, identified by a client `orderId`. **Authentication is provided by the transport — we define no envelope and do no signature cryptography.**
|
||||
An order is `product + payment`, identified and authenticated by its `orderKey` — the transport's verified Ed25519 signer key. **We define no envelope and do no signature cryptography; a new key is a new order.**
|
||||
|
||||
### Transport and auth
|
||||
|
||||
@@ -165,27 +165,25 @@ The bot runs over the SimpleX **service RPC** (branch `rpc`, merged `d2b63cd46`;
|
||||
|
||||
**Prerequisites.** The bot publishes a double-ratchet address (`/_address … pq_ratchet=on`) and starts with service processing enabled (`/_start … service_requests=on`); a plain address yields `ASENotDRAddress` and an instance with processing off silently drops the request. The bot may rotate its address ratchet keys (`APIRotateAddressRatchetKeys`) without changing the address identity or invalidating stored `orderKey`s.
|
||||
|
||||
**Timing.** The client blocks up to `requestTimeout` (agent default 30 s, overridable per call); the bot must answer within `serviceResponseTimeout` (agent default 180 s, operator-tunable) or the request is discarded and the reply fails. This bounds any held call (Stripe, §4). The bot answers with `APISendServiceResponse` → `CRServiceReplyAccepted {connectionId}`, and learns delivery via `CEvtServiceReplySent {connectionId}`. The transport carries no order identity across requests — only the per-request `AgentInvId` routes one response — so the body's `orderId` is the sole cross-request correlation key.
|
||||
**Timing.** The client blocks up to `requestTimeout` (agent default 30 s, overridable per call); the bot must answer within `serviceResponseTimeout` (agent default 180 s, operator-tunable) or the request is discarded and the reply fails. This bounds any held call (Stripe, §4). The bot answers with `APISendServiceResponse` → `CRServiceReplyAccepted {connectionId}`, and learns delivery via `CEvtServiceReplySent {connectionId}`. The transport carries no order identity across requests — only the per-request `AgentInvId` routes one response — so the verified signer key is the sole cross-request identity: every request signed by the same `orderSk` is the same order.
|
||||
|
||||
```haskell
|
||||
newtype OrderId = OrderId UUID -- client-generated, carried in the request body
|
||||
type OrderKey = C.PublicKeyEd25519 -- the transport's verified signer key; NOT a body field
|
||||
type OrderKey = C.PublicKeyEd25519 -- the transport's verified signer key = the order identity
|
||||
```
|
||||
|
||||
- We never define or transmit a signature field and never verify one. `orderKey` is just the verified `signerKey` the transport delivers.
|
||||
- The client holds the matching private key (`orderSk`) and gives it to its agent to sign each request (`APISendServiceRequest.signKey`). `orderSk` is a client secret, backed up with the profile.
|
||||
- **Auth = key equality.** On the first (create) request the bot records the order's `orderKey`. Every later request must arrive with a signer key equal to the stored one — a plain equality check, no crypto. A mismatch, or a missing key on a management operation, gives `order_auth_invalid`.
|
||||
- `orderKey` is **optional**: an unsigned order (`signerKey = Nothing`) is allowed for a one-shot purchase but cannot be managed or recovered.
|
||||
- `orderId` (in the body) is the correlation + idempotency key; it lets the bot pair the two Stripe steps (invoice then paid) even when unsigned. The transport's per-request id (`AgentInvId`) routes the single response; there is no `corrId` of ours.
|
||||
- We never define or transmit a signature or an id field, and never verify a signature. `orderKey` is just the verified `signerKey` the transport delivers; it identifies **and** authenticates the order.
|
||||
- The client generates an Ed25519 keypair per order and gives the private key (`orderSk`) to its agent to sign each request (`APISendServiceRequest.signKey`). `orderSk` is a client secret, backed up with the profile; `orderKey = publicKey orderSk`.
|
||||
- **Identity = the key.** The first request creates an order keyed by `orderKey`; every later request signed by the same `orderSk` arrives with the same signer key and resolves to the same order — a plain lookup, no crypto, no correlation id. A new key is a new order.
|
||||
- **Signing is mandatory.** An unsigned request (`signerKey = Nothing`) has no identity, so it is not an order — the bot rejects it with `order_auth_invalid`. The two Stripe steps (invoice then paid) are paired by the shared signer key.
|
||||
- The transport's per-request id (`AgentInvId`) routes the single response; there is no `corrId` and no body-level id of ours.
|
||||
|
||||
### Request / response body
|
||||
|
||||
Every referenced type is defined here; existing `Simplex.Chat.Badges` types are reused, not redefined.
|
||||
|
||||
```haskell
|
||||
data ServiceRequest = ServiceRequest
|
||||
{ orderId :: OrderId -- client UUID; correlation + idempotency (auth is the transport signer key)
|
||||
, operation :: Operation
|
||||
data ServiceRequest = ServiceRequest -- the order is the verified signer key; no id in the body
|
||||
{ operation :: Operation
|
||||
, product :: Product
|
||||
, payment :: Payment
|
||||
}
|
||||
@@ -233,7 +231,7 @@ data ErrorCode
|
||||
|
||||
Rules:
|
||||
|
||||
- **Invoice-or-badge, one order.** A `Purchase` with `StripeInvoice` returns `RspInvoice`; the same `orderId` with `StripePaid`/`AppleReceipt`/`GoogleReceipt` verifies and returns `RspCredential`. Renewals are later `Purchase`/`Status` requests on the same order.
|
||||
- **Invoice-or-badge, one order.** A `Purchase` with `StripeInvoice` returns `RspInvoice`; a later `Purchase` with `StripePaid`/`AppleReceipt`/`GoogleReceipt`, signed by the same key, verifies and returns `RspCredential`. Renewals are later `Purchase`/`Status` requests signed by the same key.
|
||||
- **Product is pinned per order.** The bot fixes `product` on the first request; a later differing product ⇒ `product_changed` ("invoice for A then claim paid on B ⇒ get lost").
|
||||
- **Tier/period/expiry are server-derived; only `masterKey` is client-authoritative.** The bot resolves the tier from `badge_types[plan]`, sets `badgeExpiry = end_of_next_month`, `badgeExtra = ""`, and assembles the internal `BadgeRequest`. `badgeType` is never on the wire.
|
||||
- **Declared SKU must equal the verified SKU** proven by `Payment` (Apple/Google receipt `productId`, Stripe `plan`); divergence ⇒ `product_mismatch`.
|
||||
@@ -245,24 +243,24 @@ Rules:
|
||||
|
||||
```haskell
|
||||
-- signerKey is the transport-verified key (from CEvtServiceRequest.signerKey), not a body field
|
||||
resolveOrder :: Maybe OrderKey -> ServiceRequest -> Transaction OrderDecision
|
||||
issueBadge :: OrderId -> UTCTime -> BadgeRequest -> Transaction BadgeResult -- period; idempotent
|
||||
resolveOrder :: Maybe OrderKey -> ServiceRequest -> Transaction OrderDecision -- Nothing ⇒ reject
|
||||
issueBadge :: OrderKey -> UTCTime -> BadgeRequest -> Transaction BadgeResult -- period; idempotent
|
||||
```
|
||||
|
||||
Order:
|
||||
|
||||
1. match the transport-verified signer key to the order's stored `orderKey` by equality (create registers it); reject on mismatch/absence;
|
||||
1. resolve the order by the transport-verified signer key (the first request creates it); reject an unsigned request (`order_auth_invalid`);
|
||||
2. load/pin the order's product; reject on `product_changed`;
|
||||
3. resolve/verify payment with the provider; check the verified SKU equals the declared SKU (`product_mismatch`);
|
||||
4. for each eligible unissued period (§1), assemble `BadgeRequest` (masterKey + server-derived `badgeInfo`) and issue a badge into the `badges` ledger — idempotent on `(order_id, period, master-key hash)` — and set `BOCompleted`;
|
||||
4. for each eligible unissued period (§1), assemble `BadgeRequest` (masterKey + server-derived `badgeInfo`) and issue a badge into the `badges` ledger — idempotent on `(order_key, period, master-key hash)` — and set `BOCompleted`;
|
||||
5. return the single response (credential + derived status).
|
||||
|
||||
### Idempotency and audit
|
||||
|
||||
- `orderId` keys idempotency: the same operation on the same order returns the stored response; a changed product returns `product_changed`.
|
||||
- The order key identifies the order; the per-period `badges` ledger is the real dedup, so a repeated operation re-derives the same result. A changed product returns `product_changed`.
|
||||
- Transport replay dedupe is separate and shorter-lived.
|
||||
- Stripe mutation idempotency key derives from `orderId` + operation.
|
||||
- Developer Tools → Chat Console records start/result, order id suffix, operation, before/after states, retry class, and duration.
|
||||
- Stripe mutation idempotency key derives from the order key + operation.
|
||||
- Developer Tools → Chat Console records start/result, order-key suffix, operation, before/after states, retry class, and duration.
|
||||
- Redact JWS/token, Checkout query/return token, `BadgeMasterKey`, credential, and provider/customer IDs. (The order signer key is public; the signature is handled by the transport, not logged here.)
|
||||
|
||||
## 4. Provider flows
|
||||
@@ -338,7 +336,7 @@ sequenceDiagram
|
||||
Note over C: CORequesting (checkout ready)
|
||||
C->>S: Open Checkout
|
||||
Note over C: COPaid
|
||||
C->>B: Purchase (StripePaid), same orderId
|
||||
C->>B: Purchase (StripePaid), same key
|
||||
B->>B: Register waiter and recheck payment under lock
|
||||
Note over B: BORequesting<br/>No response yet
|
||||
S-->>B: Signed webhook
|
||||
@@ -365,7 +363,7 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
Note over C: COPaid
|
||||
C->>B: Purchase (StripePaid), stable orderId
|
||||
C->>B: Purchase (StripePaid), same key
|
||||
B->>B: Register waiter, payment still pending
|
||||
Note over B: Hold call (≤180 s), no response
|
||||
Note over C: RPC deadline / app restart
|
||||
@@ -378,7 +376,7 @@ sequenceDiagram
|
||||
B-->>C: Credential immediately if ready<br/>otherwise hold this call
|
||||
```
|
||||
|
||||
The client persists `orderId`, `orderSk`, and `BadgeMasterKey` before opening Checkout. It retries the same signed `Purchase` only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
|
||||
The client persists `orderSk` and `BadgeMasterKey` before opening Checkout. It retries the same signed `Purchase` only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -395,7 +393,7 @@ Failure preserves previous state; client shows Retry and still says **Renews on*
|
||||
| Client presents | Portal link the bot returns |
|
||||
|---|---|
|
||||
| a `Cancel` (`StripeManage`) whose signer key matches the order's `orderKey` | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code |
|
||||
| no identifiable order (total loss — `orderId`/`orderSk` gone) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP |
|
||||
| no identifiable order (total loss — `orderSk` gone) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP |
|
||||
|
||||
The authenticated session link is short-lived and per-customer; the login page is the operator-config account-wide URL and returns no per-customer secret. The bot carries whichever link applies in `RspStatus` so a cancel path is always reachable. Signing for the order (holding `orderSk`, verified by the transport) is the sole client credential: the client either can sign as the order's `orderKey` (session) or cannot (login page).
|
||||
|
||||
@@ -415,7 +413,7 @@ Two tables per side; a badge is a row, not a state.
|
||||
### Shared identifiers
|
||||
|
||||
```haskell
|
||||
newtype OrderId = OrderId UUID
|
||||
type OrderKey = C.PublicKeyEd25519 -- order identity = verified signer key
|
||||
newtype ServiceProductId = ServiceProductId Text -- SKU
|
||||
newtype BadgeMasterKey = BadgeMasterKey ByteString -- 32 random bytes; badge product only
|
||||
data Provider = Apple | Google | Stripe
|
||||
@@ -423,15 +421,15 @@ data Provider = Apple | Google | Stripe
|
||||
|
||||
### Bot tables (**PK** bold, → foreign key, ⊤ unique)
|
||||
|
||||
- **`orders`** — `order_id` **PK** (client UUID) · `order_key` (Ed25519 pub, null = unsigned one-shot) · `order_type` (`badge`) · `product_sku` (pinned) · `provider` · `provider_ref` (encrypted binding: original-transaction | purchase token | Stripe sub/customer; null until known) · `state` [`BotOrderState`] · `paid_through` · `created_at`/`updated_at` — no master key: the client re-sends it per `Purchase`; the bot uses it in memory only
|
||||
- **`badges`** — `order_id` → `orders` · `period_start` · `master_key_hash` · `credential` (cached BBS credential) · `issued_at` · ⊤(`order_id`,`period_start`) — the issuance ledger; the "grant" is this row
|
||||
- **`orders`** — `order_key` **PK** (Ed25519 pub = verified signer key) · `order_type` (`badge`) · `product_sku` (pinned) · `provider` · `provider_ref` (encrypted binding: original-transaction | purchase token | Stripe sub/customer; null until known) · `state` [`BotOrderState`] · `paid_through` · `created_at`/`updated_at` — no master key: the client re-sends it per `Purchase`; the bot uses it in memory only
|
||||
- **`badges`** — `order_key` → `orders` · `period_start` · `master_key_hash` · `credential` (cached BBS credential) · `issued_at` · ⊤(`order_key`,`period_start`) — the issuance ledger; the "grant" is this row
|
||||
|
||||
`provider_ref` doubles as the entitlement binding: to check whether a provider object is already bound, query `orders` by `(provider, provider_ref)`. Webhook/RTDN dedupe and Google acknowledge/consume use a small auxiliary bookkeeping table (`provider_events`), not order state; provider calls/signing run outside long transactions. Product detail is keyed by `order_type` — for `badge` it is the `badges` ledger; a future product type adds its own detail table without touching `orders`.
|
||||
|
||||
### Client tables (local; `order_id` is the shared key)
|
||||
### Client tables (local; `order_key` is the shared identity)
|
||||
|
||||
- **`orders`** — `order_id` **PK** · `order_sk` (encrypted) · `order_key` · `badge_master_key` (encrypted, one per order, reused each renewal) · `provider` · `product_sku` · `state` [`ClientOrderState`] · `provider_ref` (receipt/token, to re-claim) · `paid_through` · `created_at`/`updated_at`
|
||||
- **`badges`** — `order_id` → `orders` · `period_start` · `master_key_hash` · `credential` (cached) · `expiry` · `installed` (bool) · ⊤(`order_id`,`period_start`)
|
||||
- **`orders`** — `order_key` **PK** (= public part of `order_sk`) · `order_sk` (encrypted) · `badge_master_key` (encrypted, one per order, reused each renewal) · `provider` · `product_sku` · `state` [`ClientOrderState`] · `provider_ref` (receipt/token, to re-claim) · `paid_through` · `created_at`/`updated_at`
|
||||
- **`badges`** — `order_key` → `orders` · `period_start` · `master_key_hash` · `credential` (cached) · `expiry` · `installed` (bool) · ⊤(`order_key`,`period_start`)
|
||||
|
||||
The client encrypts `order_sk` and `badge_master_key` at rest; the bot stores only the public `order_key` and never persists the master key. Update the active profile only after core installs the credential.
|
||||
|
||||
@@ -529,7 +527,7 @@ Google linked-token replacement changes subscription identity/period data, then
|
||||
Rules:
|
||||
|
||||
- Google initial subscription acknowledgement and one-time consumption run from durable retry (the auxiliary `provider_events` bookkeeping), not from order state.
|
||||
- Stripe uses server-selected Price (from `badge_types`/`stripe.plans[plan]`), mode, Customer, `client_reference_id=orderId`, metadata, redirect URLs, and collects customer email so the hosted portal login works.
|
||||
- Stripe uses server-selected Price (from `badge_types`/`stripe.plans[plan]`), mode, Customer, `client_reference_id=order_key` (so the webhook maps back to the order), metadata, redirect URLs, and collects customer email so the hosted portal login works.
|
||||
- Stripe issuance requires a paid invoice, not merely active Subscription status.
|
||||
- Webhook/status/completion page use one reconciliation function; redirects never fulfill.
|
||||
- All Stripe cancellation, invoices, and payment methods go through the browser Customer Portal — an authenticated `billing_portal.Session` when the customer is identifiable, else the account-wide login page (email OTP) which is also the app-removed path; the bot reconciles portal cancellation from the webhook. Apple/Google normal cancellation is store UI.
|
||||
@@ -542,20 +540,20 @@ Recovery re-establishes order control and the badge after reinstall, device tran
|
||||
|
||||
| Side | Durable | Lost on client wipe without backup |
|
||||
|---|---|---|
|
||||
| Bot | order (keyed by `order_id`), `order_key`, provider binding (`provider_ref`), issued badges (ledger) | — |
|
||||
| Client | — | `order_id`, `order_sk`, `BadgeMasterKey`, cached credential, installed badge |
|
||||
| Bot | order (keyed by `order_key`), provider binding (`provider_ref`), issued badges (ledger) | — |
|
||||
| Client | — | `order_sk`, `BadgeMasterKey`, cached credential, installed badge |
|
||||
|
||||
The bot never loses the order. The client's durable secrets are `order_sk` (order auth) and `BadgeMasterKey` (badge); both belong in the profile backup.
|
||||
The bot never loses the order. The client's durable secrets are `order_sk` (order identity + auth; `order_key` is its public part) and `BadgeMasterKey` (badge); both belong in the profile backup.
|
||||
|
||||
### Tier 1 — restore from backup (normal path)
|
||||
|
||||
SimpleX encrypted-profile backup or migration restores `order_id`, `order_sk`, `BadgeMasterKey`, and the cached badge. The client re-attaches to the same order by signing a `Status` request with `order_sk`; if the master key also survived, the cached badge re-installs. Nothing new is minted.
|
||||
SimpleX encrypted-profile backup or migration restores `order_sk`, `BadgeMasterKey`, and the cached badge. The client re-attaches to the same order by signing a `Status` request with `order_sk` (its `order_key` resolves the order); if the master key also survived, the cached badge re-installs. Nothing new is minted.
|
||||
|
||||
### Tier 2 — total loss (no backup)
|
||||
|
||||
`order_id`/`order_sk`/`BadgeMasterKey` are all gone. **Apple/Google are still recoverable** because the entitlement lives in the store:
|
||||
`order_sk`/`BadgeMasterKey` are gone (so is the order identity). **Apple/Google are still recoverable** because the entitlement lives in the store:
|
||||
|
||||
1. The client makes a **fresh order** (new `order_id`/`order_sk`/`BadgeMasterKey`).
|
||||
1. The client makes a **fresh order** (new `order_sk` → new `order_key`, new `BadgeMasterKey`).
|
||||
2. It re-queries StoreKit `Transaction.currentEntitlements` / Play `queryPurchases` for a fresh receipt of the active subscription.
|
||||
3. It sends a signed `Purchase` with that receipt → the bot verifies and issues a badge bound to the new master key. The abandoned order/badge expires unused.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
|
||||
- Bot/provider verification decides payment status and issuance eligibility.
|
||||
- Store state and Stripe redirects are hints only.
|
||||
- Each client request receives exactly one bot response; the bot never initiates a call.
|
||||
- Before buying, the client creates an order: a client-generated id, an Ed25519 order key whose signature authenticates every request, and one 32-byte BBS `BadgeMasterKey` for the badge. Renewals reuse the same order and keys.
|
||||
- Before buying, the client creates an order: an Ed25519 order key that both identifies and authenticates it (a new key is a new order), and one 32-byte BBS `BadgeMasterKey` for the badge. Renewals reuse the same order and keys.
|
||||
- There is no bot-issued token and no caller identity. Every request is signed by the order key; payment additionally carries a fresh store proof (Apple/Google) or is confirmed by webhook (Stripe). The order key, BBS key, and provider proofs are redacted.
|
||||
|
||||
## 2. UX states
|
||||
@@ -354,7 +354,7 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
|
||||
- Apple, Google, and Stripe have separate linear outcomes.
|
||||
- Payment verification issues into a provider-neutral badge ledger; badge service has no provider logic.
|
||||
- Client and bot order/badge state are separate.
|
||||
- Requests are client-signed (order key), one bot response each, idempotent by order id.
|
||||
- Requests are client-signed with the order key (which also identifies the order), one bot response each; the per-period badge ledger dedups repeats.
|
||||
- Tier and billing period are fixed by the plan (SKU); the client cannot request a higher tier or longer life than it paid for.
|
||||
- Stripe needs no localhost/deep-link success; cancellation is always via the browser Customer Portal — an authenticated session, or the login page when the client cannot identify the payment.
|
||||
- Every error category has an owner, state-preserving action, and retry/final result.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="500" viewBox="0 0 1100 500"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="500" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">Responsibility and trust boundaries</text>
|
||||
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns order id + order key</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
|
||||
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns the order key (= identity)</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
|
||||
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">• order signature (Ed25519) auth</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• order state + provider binding</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
|
||||
<rect class="box grant" rx="10" x="595" y="120" width="180" height="130"/><text x="685" y="151" text-anchor="middle" class="h">BADGE LEDGER</text><text x="615" y="188" class="b">• one row per paid period</text><text x="615" y="217" class="b">• the "grant" is just a row</text>
|
||||
<rect class="box badge" rx="10" x="830" y="75" width="235" height="220"/><text x="947" y="106" text-anchor="middle" class="h">BADGE SERVICE</text><text x="850" y="143" class="b">• accepts period + badge request</text><text x="850" y="172" class="b">• signs and caches credential</text><text x="850" y="201" class="b">• issues one row per period</text><text x="850" y="230" class="b">• never imports provider logic</text><text x="850" y="259" class="b">• never changes payment state</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Reference in New Issue
Block a user