diff --git a/plans/2026-07-20-supporter-badges-v2-implementation.md b/plans/2026-07-20-supporter-badges-v2-implementation.md index f44c2194d1..e7d07bf2fb 100644 --- a/plans/2026-07-20-supporter-badges-v2-implementation.md +++ b/plans/2026-07-20-supporter-badges-v2-implementation.md @@ -4,7 +4,7 @@ **Status:** implementation-ready **Companion:** [Product and UX plan](2026-07-20-supporter-badges-v2-product.md) -Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance fulfills it. Payment and badge are separate state machines on client and bot. +An **order** (`orderId` = client UUID, authenticated by an Ed25519 `orderKey`) carries a `product` and a `payment`. Payment verification creates a provider-neutral `ServiceGrant`; badge issuance fulfills it. Payment and badge are separate state machines on client and bot. Order / product(badge) / payment are decoupled and product-extensible. ![Architecture](assets/badge-v2-roles.svg) @@ -29,15 +29,15 @@ Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance f ```mermaid flowchart LR - C[Client] <-->|Service RPC| B[Badge bot] + C[Client] <-->|Signed order RPC| B[Badge bot] B <-->|Provider API / webhook| S[Apple / Google / Stripe] ``` | Component | Owns | Must not own | |---|---|---| -| Client payment | BBS master key, purchase UI, cached status, retry schedule | bot/provider truth | -| Client badge | credential receipt and installation | billing state | -| Payment service | BBS owner commitment, proof verification, billing state, grant schedule | raw master key, credential | +| Client order | `orderId`, `orderSk` key, 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, billing state, grant schedule | badge master key, credential | | Service grant | product and eligible monthly grant period | provider proof, credential | | Badge service | signing and idempotent credential cache | provider/billing logic | | Core | signature verification and installed badge | payment status | @@ -47,13 +47,13 @@ Treat these as separate programs with typed interfaces. ### Invariants 1. Provider verification changes payment state only. -2. Before `Prepare`, client generates one `BadgeMasterKey` with `generateMasterKey`; it is 32 random bytes and BBS message 0. -3. Payment stores `BadgeKeyCommitment = SHA-256("SimpleX badge key commitment v1" || BadgeMasterKey)` and every later issue must match it. -4. Only `GrantReady` plus the matching raw master key enters badge signing. -5. Grant fulfillment and cached issuance result are atomic/idempotent. -6. Payment never activates perks; verified credential does. -7. RPC has no caller identity, no bot-issued token, and no bot push. Each request carries its own credential — a fresh provider proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe) — verified against the payment binding. -8. Duplicate RPCs/events return the same result. Unknown states preserve prior state. +2. An order is identified by a client-generated `orderId` (UUID) and authenticated by its Ed25519 `orderKey`, registered on the create request and verified on every request (external envelope). 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. +6. Only `GrantReady` plus the badge product's master key enters badge signing; grant fulfillment and cached issuance are atomic/idempotent. +7. Payment never activates perks; the verified credential does. +8. Duplicate requests/events return the same result. Unknown states preserve prior state. 9. Provider dates create eligibility; retry/request time never changes badge expiry. ### Time and grant @@ -63,18 +63,18 @@ data ServiceGrantState = GrantReady | GrantFulfilled | GrantRevoked data ServiceGrant = ServiceGrant { grantId :: GrantId - , paymentId :: PaymentId + , orderId :: OrderId , productId :: ServiceProductId , grantPeriodStart :: UTCTime , grantState :: ServiceGrantState } ``` -- One-time: one grant at verified purchase time. Reject another one-time prepare while its prior one-time service period is active. +- One-time: one grant at verified purchase time. Reject another one-time order for the same entitlement while its prior one-time service period is active. - Subscription: `grantPeriodStart(n) = addCalendarMonths n verifiedAnchor` when `grantPeriodStart(n) <= now < paidThrough`. - Monthly and yearly plans both expose one grant per eligible month. - Badge service computes expiry as the start of the month two months after `grantPeriodStart`. -- Unique grant: `(payment_id, product_id, grant_period_start)`. +- Unique grant: `(order_id, product_id, grant_period_start)`. - Example: 21 July grant period → badge expires 1 September; monthly billing renews 21 August. Grant eligibility by payment state: @@ -91,7 +91,7 @@ Grant eligibility by payment state: ## 2. State machines -These names are canonical. Every transition is validated against the current constructor. +These names are canonical. Every transition is validated against the current constructor. All five machines hang off one **order** (`orderId`): the bot order aggregates a payment sub-state, a service grant, and a badge sub-state; the client mirrors payment and badge. "Issue" below is the badge-fulfilling step of a `Purchase` operation, not a separate call. ### Client payment @@ -113,7 +113,7 @@ These names are canonical. Every transition is validated against the current con | State | Meaning | |---|---| -| `BPPrepared` | payment/commitment/binding stored | +| `BPPrepared` | order/key/binding stored | | `BPCheckoutOpen` | Stripe Session stored | | `BPAwaitingPayment` | provider not complete | | `BPVerifying` | reconciliation lease active | @@ -222,9 +222,9 @@ Bot payment: | From | On | To | |---|---|---| -| — | `Prepare` | `BPPrepared` | +| — | `Purchase` (create order) | `BPPrepared` | | `BPPrepared` | Stripe Session created | `BPCheckoutOpen` | -| `BPPrepared` | Apple/Google evidence | `BPVerifying` | +| `BPPrepared` | Apple/Google receipt | `BPVerifying` | | `BPCheckoutOpen` | webhook: paid | `BPVerifying` | | `BPCheckoutOpen` | webhook: async pending | `BPAwaitingPayment` | | `BPCheckoutOpen` | Checkout expired | `BPExpired` | @@ -257,7 +257,7 @@ Bot badge: | From | On | To | |---|---|---| -| — | `IssueBadge` on `GrantReady`, key matches commitment | `BBRequested` | +| — | `Purchase` issues on `GrantReady`, badge product master key present | `BBRequested` | | `BBRequested` | claim signing lease | `BBSigning` | | `BBSigning` | signed + cached (marks `GrantFulfilled`) | `BBIssued` | | `BBSigning` | signing unavailable | `BBRetryableFailure` | @@ -276,7 +276,7 @@ Client payment: | `CPStoreReady` | store pending | `CPAwaitingPayment` | | `CPStoreReady` | store canceled | prior state | | `CPCheckoutReady` | checkout opened | `CPAwaitingPayment` | -| `CPAwaitingPayment` | send evidence / `IssueBadge` | `CPVerifying` | +| `CPAwaitingPayment` | send `Purchase` (paid evidence) | `CPVerifying` | | `CPVerifying` | bot paid | `CPEntitled` | | `CPVerifying` | bot renewal-off | `CPEndsAtPeriodEnd` | | `CPVerifying` | bot grace/hold/paused/error | `CPPaymentProblem` | @@ -297,7 +297,7 @@ Client badge: |---|---|---| | `CBNone` | grant available | `CBNeeded` | | `CBInstalled` | new grant period | `CBNeeded` | -| `CBNeeded` | send `IssueBadge` | `CBRequesting` | +| `CBNeeded` | send `Purchase` (issue) | `CBRequesting` | | `CBRequesting` | credential returned | `CBReceived` | | `CBRequesting` | retryable | `CBRetryableFailure` | | `CBRequesting` | final | `CBFinalFailure` | @@ -313,9 +313,9 @@ Bot payment: ```mermaid stateDiagram-v2 - [*] --> BPPrepared: Prepare + [*] --> BPPrepared: Purchase create BPPrepared --> BPCheckoutOpen: stripe session - BPPrepared --> BPVerifying: apple or google evidence + BPPrepared --> BPVerifying: apple or google receipt BPCheckoutOpen --> BPVerifying: webhook paid BPCheckoutOpen --> BPAwaitingPayment: async pending BPCheckoutOpen --> BPExpired: checkout expired @@ -346,7 +346,7 @@ stateDiagram-v2 state "BP paid: OneTime or SubscriptionActive or Grace or EndsAtPeriodEnd" as BPpaid [*] --> BPpaid: payment verified BPpaid --> GrantReady: create due grant - GrantReady --> BBRequested: IssueBadge, key matches + GrantReady --> BBRequested: Purchase issues, key present BBRequested --> BBSigning: claim lease BBSigning --> BBIssued: sign and cache BBIssued --> GrantFulfilled: mark fulfilled @@ -392,7 +392,7 @@ stateDiagram-v2 [*] --> CBNone CBNone --> CBNeeded: grant available CBInstalled --> CBNeeded: new grant period - CBNeeded --> CBRequesting: send IssueBadge + CBNeeded --> CBRequesting: send Purchase CBRequesting --> CBReceived: credential returned CBRequesting --> CBRetryableFailure: retryable CBRequesting --> CBFinalFailure: final @@ -405,82 +405,118 @@ stateDiagram-v2 ## 3. Contracts -### RPC payload +An order is `product + payment`, identified by a client `orderId` and authenticated by an external Ed25519 signature. The command layer never reasons about auth. + +### Envelope (external auth layer) + +Mirrors the SMP signed transmission — **entity id, signature, body**. ```haskell -data ServiceCall = ServiceCall - { requestId :: RequestId - , payment :: PaymentInput - , request :: Maybe ServiceRequest +newtype OrderId = OrderId UUID -- client-generated, e.g. "b1e2…-uuid" +newtype OrderKey = OrderKey C.PublicKeyEd25519 -- order public key, registered on create +type Signature = C.Signature 'C.Ed25519 -- over (orderId ‖ body), made with orderSk + +data ServiceEnvelope = ServiceEnvelope + { orderId :: OrderId + , signature :: Signature + , body :: ServiceRequest + } +``` + +Signing (keygen → sign → verify): + +1. On create, the client generates `(orderSk, orderPk)` and the `orderId` UUID, stores `orderSk` encrypted (backed up with the profile), and puts `orderPk` in `body.orderKey`. +2. The bot, seeing no order for `orderId`, treats it as create: stores `orderPk`, verifies the signature. +3. Every request signs `signature = sign(orderSk, orderId ‖ body)`. Later requests may omit `orderKey`; if present it must equal the stored `orderPk`. Mismatch ⇒ `order_auth_invalid`. + +No `corrId`: `orderId` already correlates request↔response and keys idempotency (the SimpleX transport handles message delivery). Replay is benign — operations are idempotent by `orderId`, and the signature binds to `orderId` so a signed body cannot move to another order. Signing reuses the SMP `authTransmission` Ed25519 path. `orderSk` (order auth) and `BadgeMasterKey` (badge signing) are two distinct secrets. + +### Request / response body + +Every referenced type is defined here; existing `Simplex.Chat.Badges` types are reused, not redefined. + +```haskell +data ServiceRequest = ServiceRequest + { orderKey :: Maybe OrderKey -- required on create; must equal the stored key thereafter + , operation :: Operation + , product :: Product + , payment :: Payment } -data PaymentInput - = Prepare Provider ServiceProductId PurchaseKind BadgeKeyCommitment - | AppleEvidence BadgeKeyCommitment SignedTransactionJWS - | GoogleEvidence BadgeKeyCommitment PurchaseToken - | StripeStatus BadgeKeyProof - | StripeCancel BadgeKeyProof - | StripePortal BadgeKeyProof +data Operation = Purchase | Cancel | Status -- billing period is in the SKU, not here --- BadgeKeyProof is the raw BadgeMasterKey, revealed over the E2E channel; --- the bot re-derives BadgeKeyCommitment = SHA-256(domain || key) and matches. --- This reuses issuance, which already sends the key, so it exposes nothing new. --- --- No PaymentId on the wire: each Prepare uses a fresh BadgeMasterKey, so --- BadgeKeyCommitment is 1:1 with the payment and selects it. Apple/Google --- carry the commitment and authenticate by fresh store proof; Stripe calls --- carry a BadgeKeyProof, which both selects (via commitment) and authenticates. --- PaymentId (below) is an internal primary/foreign key only. -type BadgeKeyProof = BadgeMasterKey -data ServiceRequest = IssueBadge BadgeMasterKey (Maybe GrantId) +newtype ServiceProductId = ServiceProductId Text -- SKU, e.g. "supporter_monthly", "legend_onetime" -data ServiceResponse = ServiceResponse - { requestId :: RequestId - , payment :: PaymentSnapshot - , grant :: Maybe ServiceGrantSummary - , service :: Maybe (Either ServiceError BadgeCredential) - , retryAfter :: Maybe NominalDiffTime +data Product = ProductBadge BadgeProduct -- | ProductName NameProduct ... (future) +data BadgeProduct = BadgeProduct + { plan :: ServiceProductId -- SKU → tier × period (server-authoritative via badge_types[plan]) + , masterKey :: BadgeMasterKey -- BBS delivery key; confined here, consumed at issuance } + +data Payment = PaymentApple AppleOp | PaymentGoogle GoogleOp | PaymentStripe StripeOp +newtype AppleOp = AppleReceipt SignedTransactionJWS +newtype GoogleOp = GoogleReceipt PurchaseToken +data StripeOp = StripeInvoice | StripePaid InvoiceId | StripeManage -- Manage = cancel/status +newtype InvoiceId = InvoiceId Text + +data ServiceResponse + = RspInvoice InvoiceRef -- Stripe Checkout URL, or Apple/Google store binding + | RspCredential BadgeCredential -- reuse Simplex.Chat.Badges.BadgeCredential + | RspStatus OrderStatus + | RspPortal Url -- Stripe cancel-flow / management portal link + | RspError ServiceError +newtype InvoiceRef = InvoiceRef Text +newtype Url = Url Text + +data OrderStatus = OrderStatus + { orderState :: BotPaymentState, badgeState :: Maybe BotBadgeState + , paidThrough :: Maybe UTCTime, willRenew :: Bool } + +data ServiceError = ServiceError { code :: ErrorCode, message :: Text } +data ErrorCode + = OrderAuthInvalid | ProductChanged | ProductMismatch + | PaymentPending | PaymentNotEntitled | ProviderUnavailable | ProviderRateLimited + | UnsupportedVersion | BadRequest | InternalError + +-- The bot assembles the internal BadgeRequest { masterKey, badgeInfo } where +-- badgeInfo = BadgeInfo { badgeType = badge_types[plan] +-- , badgeExpiry = Just end_of_next_month, badgeExtra = "" }. ``` Rules: -- Each `Prepare` uses a fresh `BadgeMasterKey`; renewals of the same subscription reuse it without a new `Prepare`. So `BadgeKeyCommitment` is unique per payment and is the wire selector; `PaymentId` never appears on the wire. -- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeKeyCommitment`, and reuses the key for all renewal badges. -- `Prepare` stores `BadgeKeyCommitment` but cannot issue a badge. -- Apple/Google evidence may include `IssueBadge`. -- Stripe prepare returns Checkout data. The client then sends `IssueBadge`; while payment is pending the bot holds that call and sends no response. -- The waiting call responds once after verified payment and issuance, or with a terminal payment error. Other retryable operations may still return `retryAfter`. -- The `BadgeMasterKey`, its `BadgeKeyProof`, and `BadgeKeyCommitment` never enter Stripe metadata or a return URL. -- `grantId` selects only; bot rechecks payment, owner, product, and eligibility. -- `StripeCancel`/`StripePortal` return a portal URL in the payment snapshot (like the Checkout URL); the bot never cancels silently. `StripeCancel` deep-links to the cancel flow; `StripePortal` opens general management. When the request carries no valid `BadgeKeyProof` (customer cannot be identified), the bot returns the account-wide login page instead of a session. -- There is no bot-issued authorization token and no `PaymentId` on the wire. The payment is selected by `BadgeKeyCommitment` (Apple/Google carry it explicitly; a Stripe `BadgeKeyProof` references it). Each RPC also authenticates: Apple/Google by fresh store proof, Stripe by the `BadgeKeyProof`. The bot resolves and verifies before acting. -- Before signing, orchestrator recomputes `BadgeKeyCommitment` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without fulfilling grant. +- **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. +- **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`. +- Stripe stays event-driven: a `Purchase` with `StripePaid` may hold the call until the webhook confirms; it responds once after verified payment + issuance, or a terminal payment error. +- Stripe `Cancel`/`Status` (`StripeManage`) return a portal URL in `RspPortal`; the bot never cancels silently. When the order can't be identified, it returns the account-wide portal login page. +- The `BadgeMasterKey` never enters Stripe metadata or a return URL. ### Internal interface ```haskell -resolvePayment :: PaymentInput -> Transaction PaymentDecision -fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult +resolveOrder :: OrderId -> ServiceRequest -> Transaction OrderDecision +fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult ``` Order: -1. select the payment by `BadgeKeyCommitment` and authenticate the request credential (store proof, or `BadgeKeyProof` against that commitment); -2. resolve/verify payment; -3. commit payment and create/load due grant; -4. verify the request key matches the payment `BadgeKeyCommitment`; -5. pass only grant + request to badge service; +1. verify the envelope signature against the order's `orderKey` (create registers it); +2. load/pin the order's product; reject on `product_changed`; +3. resolve/verify payment; check the verified SKU equals the declared SKU (`product_mismatch`); +4. commit payment and create/load the due grant; +5. assemble `BadgeRequest` (masterKey + server-derived `badgeInfo`) and pass grant + request to the badge service; 6. cache issuance and mark grant fulfilled atomically; 7. return the single response. ### Idempotency and audit -- `requestId` binds to canonical request hash. Same body returns stored response; different body returns `idempotency_mismatch`. +- `orderId` keys idempotency: the same operation on the same order returns the stored response; a changed product returns `product_changed`. - Transport replay dedupe is separate and shorter-lived. -- Stripe mutation idempotency key derives from request ID + operation. -- Developer Tools → Chat Console records start/result, request ID, method, payment suffix, before/after states, retry class, and duration. -- Redact JWS/token, `BadgeKeyProof`, Checkout query/return token, master key, credential, and provider/customer IDs. +- 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. +- Redact the Ed25519 signature, JWS/token, Checkout query/return token, `BadgeMasterKey`, credential, and provider/customer IDs. ## 4. Provider flows @@ -493,11 +529,11 @@ sequenceDiagram participant C as Client participant B as Bot Note over C: CPVerifying + CBRequesting - C->>B: RPC payment status + IssueBadge + C->>B: Purchase (paid evidence), signed order Note over B: Payment: BPPaidOneTime / BPSubscriptionActive / BPGrace / BPEndsAtPeriodEnd B->>B: Create or load eligible grant period Note over B: Grant: GrantReady - B->>B: Verify BadgeMasterKey matches BadgeKeyCommitment + B->>B: Verify SKU, assemble BadgeRequest from product master key B->>B: Create issuance Note over B: Badge: BBRequested B->>B: Claim signing @@ -522,7 +558,7 @@ sequenceDiagram participant B as Bot participant A as Apple Note over C: CPVerifying - C->>B: RPC AppleEvidence + optional IssueBadge + C->>B: Purchase (AppleReceipt), signed order Note over B: Payment: BPVerifying B->>B: Verify signed transaction offline Note over B: Payment: BPPaidOneTime or BPSubscriptionActive @@ -541,7 +577,7 @@ sequenceDiagram participant B as Bot participant G as Google Note over C: CPVerifying - C->>B: RPC GoogleEvidence + optional IssueBadge + C->>B: Purchase (GoogleReceipt), signed order Note over B: Payment: BPVerifying B->>G: Verify purchase token G-->>B: Canonical purchase @@ -552,7 +588,7 @@ sequenceDiagram Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never create a service grant from the notification payload. -### Stripe Checkout and waiting `IssueBadge` +### Stripe Checkout and waiting `Purchase` ```mermaid sequenceDiagram @@ -560,16 +596,16 @@ sequenceDiagram participant B as Bot participant S as Stripe Note over C: CPPreparing - C->>B: RPC Prepare Stripe + C->>B: Purchase (StripeInvoice), create order Note over B: BPPrepared B->>S: Create Checkout Session S-->>B: Session ID + URL Note over B: BPCheckoutOpen - B-->>C: RPC Checkout URL + B-->>C: RspInvoice (Checkout URL) Note over C: CPCheckoutReady C->>S: Open Checkout Note over C: CPAwaitingPayment - C->>B: RPC IssueBadge(requestId, key) + C->>B: Purchase (StripePaid), same orderId Note over C: CPAwaitingPayment + CBRequesting B->>B: Register waiter and recheck payment under lock Note over B: BPAwaitingPayment
No response yet @@ -586,11 +622,11 @@ sequenceDiagram Note over C: CBInstalled ``` -The second RPC has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the payment lock prevents a webhook/request race. If the webhook completed first, `IssueBadge` responds immediately. +The second `Purchase` (`StripePaid`) has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the order lock prevents a webhook/request race. If the webhook completed first, the `Purchase` responds immediately. If Stripe retrieval fails transiently after the webhook, the bot keeps the call open and retries internally. It does not send an intermediate response; only the RPC deadline or a terminal payment result ends the wait. -Persist request ID, hash, and result state; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after `GrantReady` is durable. After bot restart, the repeated request rechecks persisted payment/grant state and either returns immediately or installs a new waiter. +Persist `orderId`, request hash, and result state; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after `GrantReady` is durable. After bot restart, the repeated `Purchase` rechecks persisted payment/grant state and either returns immediately or installs a new waiter. ### Stripe wait interruption @@ -600,7 +636,7 @@ sequenceDiagram participant B as Bot participant S as Stripe Note over C: CPAwaitingPayment + CBRequesting - C->>B: RPC IssueBadge with stable requestId + C->>B: Purchase (StripePaid), stable orderId B->>B: Register waiter, payment still pending Note over B: Hold call, no response Note over C: RPC deadline / app restart @@ -609,12 +645,12 @@ sequenceDiagram Note over B: Remove waiter, preserve payment state S-->>B: Webhook may complete payment later Note over B: Persist payment + GrantReady - C->>B: Repeat same IssueBadge on foreground + C->>B: Repeat same Purchase on foreground Note over C: CPAwaitingPayment + CBRequesting B-->>C: Credential immediately if ready
otherwise hold this call ``` -The client persists `requestId` and `BadgeMasterKey` before opening Checkout. It retries 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 `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. ### Cancellation @@ -630,10 +666,10 @@ Failure preserves previous state; client shows Retry and still says **Renews on* | Client presents | Portal link the bot returns | |---|---| -| valid `BadgeKeyProof` (matches stored `BadgeKeyCommitment`) | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code | -| no valid proof (master key lost with the app) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP | +| a `Cancel` (`StripeManage`) on a known order (valid `orderKey` signature) | 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 | -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 Stripe status responses so a cancel path is always reachable. Because possession of the `BadgeMasterKey` is the sole client credential, there is no intermediate "capability lost" state: the client either can prove key possession (session) or cannot (login page). +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. The `orderKey` signature is the sole client credential: the client either can sign for the order (session) or cannot (login page). ## 5. Persistence and `CallState` pattern @@ -654,20 +690,21 @@ Define five separate sums (the `ClientPaymentState` / `BotPaymentState` / `Servi ### Client tables -`badge_payments`: provider/product/plan, payment state payload, encrypted `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version. +`orders`: `order_id` (client UUID), encrypted `order_sk` + `order_key`, provider/product/plan, payment state payload, `provider_ref`, `paid_through`, `will_renew`, checked/retry time, version. -`badges`: payment/grant/grant-period/key hash, badge state payload, cached credential, expiry, attempt/error, version. +`badges`: order id + grant-period + key hash, badge state payload, encrypted `badge_master_key`, cached credential, expiry, attempt/error, version. -Join by payment/grant ID only. Update active profile only after core installation. +Join by order id and grant period only (the bot's `GrantId` is internal). Update active profile only after core installation. ### Bot tables | Table | Unique key / purpose | |---|---| -| `payments` | provider-object ownership + `BadgeKeyCommitment`; canonical payment sum | -| `service_grants` | payment + product + grant period | +| `orders` | `order_id` (client UUID); `order_key`, pinned product SKU; canonical payment sum | +| `entitlements` | provider-object binding (`sub_`/`cus_` \| original-transaction \| token) → order; per-period re-issue counter | +| `service_grants` | order + product + grant period | | `badge_issuances` | grant + master-key hash; cached credential | -| `rpc_requests` | request ID; pending waiter/result + request hash + response | +| `rpc_requests` | order id + operation; pending waiter/result + request hash + response | | `provider_events` | provider event ID; dedupe/result | | `outbox` | acknowledge, consume, reconciliation, cleanup | @@ -678,40 +715,39 @@ Provider calls/signing run outside long transactions. Leases and compare-and-swa Shared identifiers and enums: ```haskell -newtype PaymentId = PaymentId Int64 -newtype GrantId = GrantId Int64 -newtype RequestId = RequestId ByteString -newtype BadgeKeyCommitment = BadgeKeyCommitment ByteString -- 32-byte SHA-256 -newtype BadgeMasterKey = BadgeMasterKey ByteString -- 32 random bytes -type BadgeKeyProof = BadgeMasterKey +newtype OrderId = OrderId UUID +newtype GrantId = GrantId Int64 +newtype ServiceProductId = ServiceProductId Text -- SKU +newtype BadgeMasterKey = BadgeMasterKey ByteString -- 32 random bytes; badge product only -data Provider = Apple | Google | Stripe -data PurchaseKind = OneTime | Monthly | Yearly +data Provider = Apple | Google | Stripe ``` Bot tables (**PK** bold, → foreign key, ⊤ unique). The `state` column is a tag plus state-specific fields for the sum named in brackets. -- **`payments`** — one row per purchase. `payment_id` **PK** · `provider` · `product_id` · `purchase_kind` · `badge_key_commitment` ⊤ · `provider_ref` (`sub_`/`cus_` | original-transaction | token; encrypted) ⊤(`provider`,`provider_ref`) · `state` [`BotPaymentState`] · `paid_through` · `will_renew` · `version` -- **`service_grants`** — `grant_id` **PK** · `payment_id` → `payments` · `product_id` · `grant_period_start` · `state` [`ServiceGrantState`] · ⊤(`payment_id`,`product_id`,`grant_period_start`) +- **`orders`** — one row per order. `order_id` **PK** (client UUID) · `order_key` (Ed25519 pub) · `provider` · `product_sku` (pinned) · `master_key` (badge product, encrypted) · `state` [`BotPaymentState`] · `paid_through` · `will_renew` · `version` +- **`entitlements`** — `entitlement_id` **PK** · `provider` · `provider_ref` (encrypted) ⊤(`provider`,`provider_ref`) · `order_id` → `orders` · `reissue_count` per (`provider_ref`, period) — bounds total-loss re-binds +- **`service_grants`** — `grant_id` **PK** · `order_id` → `orders` · `product_id` · `grant_period_start` · `state` [`ServiceGrantState`] · ⊤(`order_id`,`product_id`,`grant_period_start`) - **`badge_issuances`** — `issuance_id` **PK** · `grant_id` → `service_grants` · `master_key_hash` · `state` [`BotBadgeState`] · `credential` (cached) · ⊤(`grant_id`,`master_key_hash`) -- **`rpc_requests`** — `request_id` **PK** · `request_hash` · `state` (pending waiter | result) · `response` (cached) +- **`rpc_requests`** — (`order_id`,`operation`) **PK** · `request_hash` · `state` (pending waiter | result) · `response` (cached) - **`provider_events`** — `event_id` **PK** · `kind` · `result` (dedupe) - **`outbox`** — `id` **PK** · `kind` (ack | consume | reconcile | cleanup) · `payload` · `status` · `attempts` -Client tables (local ids; never the bot's `PaymentId`): +Client tables (local; the bot's `order_id` is the shared key): -- **`badge_payments`** — `id` **PK** · `provider` · `product_id` · `plan` · `state` [`ClientPaymentState`] · `badge_key_commitment` · `badge_master_key` (encrypted) · `provider_ref` · `paid_through` · `will_renew` · `checked_at` · `retry_at` · `version` -- **`badges`** — `id` **PK** · `payment_id` → `badge_payments` · `grant_id` · `grant_period_start` · `master_key_hash` · `state` [`ClientBadgeState`] · `credential` (cached) · `expiry` · `attempt` · `error` · `version` +- **`orders`** — `order_id` **PK** · `order_sk` (encrypted) · `order_key` · `provider` · `product_sku` · `state` [`ClientPaymentState`] · `provider_ref` · `paid_through` · `will_renew` · `checked_at` · `retry_at` · `version` +- **`badges`** — `id` **PK** · `order_id` → `orders` · `grant_period_start` · `master_key_hash` · `state` [`ClientBadgeState`] · `badge_master_key` (encrypted) · `credential` (cached) · `expiry` · `attempt` · `error` · `version` — the client keys a badge by `(order_id, grant_period_start)`; the bot's `GrantId` never crosses the wire -The client encrypts `badge_master_key` at rest; the bot stores only `badge_key_commitment` and never the key. +The client encrypts `order_sk` and `badge_master_key` at rest; the bot stores the `order_key` (public) and the `master_key` only as badge-signing input, never as an identifier. ```mermaid erDiagram - payments ||--o{ service_grants : has + orders ||--o{ entitlements : binds + orders ||--o{ service_grants : has service_grants ||--o| badge_issuances : "fulfilled by" ``` -`rpc_requests`, `provider_events`, and `outbox` are keyed by their own ids and reference a payment only where relevant. +`rpc_requests`, `provider_events`, and `outbox` are keyed by their own ids and reference an order only where relevant. ## 6. Reconciliation and errors @@ -720,13 +756,13 @@ erDiagram Triggers: launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and date boundaries. ```text -reconcile(payment): +reconcile(order): coalesce to one worker render cached payment + installed badge - submit unseen Apple/Google evidence - for pending Stripe Checkout: ensure one IssueBadge call is waiting - otherwise request status for nonterminal payment - if GrantReady and no badge covers its grant period: CBNeeded -> request IssueBadge + submit a signed Purchase for unseen Apple/Google receipts + for pending Stripe Checkout: ensure one Purchase(StripePaid) is waiting + otherwise send Status for a nonterminal order + if GrantReady and no badge covers its grant period: CBNeeded -> Purchase (issue) if credential returned: CBReceived -> CBInstalling -> CBInstalled schedule next check ``` @@ -744,14 +780,14 @@ Every input is one of: | Input/result | Class | Client | Bot | |---|---|---|---| -| Stripe awaiting webhook | wait | `CPAwaitingPayment` + `CBRequesting` | hold `IssueBadge`; no response | +| Stripe awaiting webhook | wait | `CPAwaitingPayment` + `CBRequesting` | hold `Purchase`; no response | | timeout/429/5xx in a non-waiting operation | retry | `CPPaymentProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` | -| Stripe verification timeout/429/5xx while `IssueBadge` waits | wait; retry internally | `CPAwaitingPayment` + `CBRequesting` | preserve canonical payment state; retry Stripe; send no response | -| deadline/restart/lost response | retry on foreground | preserve state; repeat same ID/body | remove waiter; return cached result or wait again | +| Stripe verification timeout/429/5xx while `Purchase` waits | wait; retry internally | `CPAwaitingPayment` + `CBRequesting` | preserve canonical payment state; retry Stripe; send no response | +| deadline/restart/lost response | retry on foreground | preserve state; repeat same signed order/op | remove waiter; return cached result or wait again | | duplicate event/request | idempotent | accept same state/result | preserve state; dedupe/re-fetch | -| ID reused with new body | reject | preserve state; new ID only for new action | preserve state; telemetry | -| invalid proof/binding | reject | preserve state; restore/support | preserve state; rate-limit | -| invalid proof/product | reject | `CPPaymentProblem`; no blind retry | preserve `BP…`; quarantine/alert | +| product changed for an order | reject | preserve state; a new product needs a new order | `product_changed`; preserve state; telemetry | +| invalid order signature | reject | preserve state; restore/support | `order_auth_invalid`; preserve state; rate-limit | +| declared SKU ≠ verified SKU | reject | `CPPaymentProblem`; no blind retry | `product_mismatch`; preserve `BP…`; quarantine/alert | | unknown provider state | quarantine | `CPPaymentProblem`; retry later | preserve `BP…`; re-fetch, never guess | | `GrantReady` | apply | `CBNeeded` → `CBRequesting` | `BBRequested` when requested | | `GrantFulfilled` | idempotent | `CBReceived` → install | return cached `BBIssued` | @@ -765,12 +801,12 @@ Every input is one of: | refund/revocation | apply | `CPExpired`; signed badge survives to expiry | `BPRefunded`/`BPRevoked`; mark unused grants `GrantRevoked` | | webhook DB failure | retry delivery | no transition | no transition; non-2xx | -Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_not_entitled`, `ownership_conflict`, `proof_invalid`, `provider_rate_limited`, `provider_unavailable`, `idempotency_mismatch`, `badge_already_issued`, `signing_failed`, `internal_error`. +Stable codes: `bad_request`, `unsupported_version`, `order_auth_invalid`, `product_changed`, `product_mismatch`, `payment_pending`, `payment_not_entitled`, `provider_rate_limited`, `provider_unavailable`, `badge_already_issued`, `signing_failed`, `internal_error`. ### Crash recovery - Before provider call: repeat request. -- Waiting `IssueBadge` lost on deadline/restart: remove waiter; repeat the same logical request on foreground. +- Waiting `Purchase` lost on deadline/restart: remove waiter; repeat the same signed order request on foreground. - Provider succeeds before commit: retrieve by idempotency key/object binding. - Payment committed before issuance: `GrantReady` remains unchanged. - Credential cached before response loss: repeat returns it. @@ -807,61 +843,55 @@ Google linked-token replacement changes subscription identity/period data, then Rules: - Google initial subscription acknowledgement and one-time consumption run from durable outbox. -- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, 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=orderId`, metadata, redirect URLs, and collects customer email so the hosted portal login works. - Stripe subscription grant 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. ## 8. Recovery -Recovery re-establishes payment control and the badge after reinstall, device transfer, or local data loss. There is no bot-issued token and no caller identity, so a reinstalled client is a new contact; it re-attaches by presenting a credential the bot matches to a stored payment. +Recovery re-establishes order control and the badge after reinstall, device transfer, or local data loss. There is no bot-issued token and no caller identity, so a reinstalled client is a new contact. There are two tiers, by what survives. ### State ownership | Side | Durable | Lost on client wipe without backup | |---|---|---| -| Bot | payment (keyed by `BadgeKeyCommitment`), provider bindings, `sub_`/`cus_`, grants, cached credential | — | -| Client | — | `BadgeMasterKey` (commitment re-derives from it), cached credential, installed badge | +| Bot | order (keyed by `order_id`), `order_key`, entitlement bindings, `sub_`/`cus_`, grants, cached credential | — | +| Client | — | `order_id`, `order_sk`, `BadgeMasterKey`, cached credential, installed badge | -The bot never loses the payment. `BadgeMasterKey` is the only client secret to protect; all other client state is re-derivable. +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. -### Restore from backup +### Tier 1 — restore from backup (normal path) -SimpleX encrypted-profile backup or migration restores `BadgeMasterKey`, `BadgeKeyCommitment`, and the cached badge. No recovery RPC runs. This is the primary 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/grant re-issues to it. Nothing new is minted. -### Re-attach when only the master key survives +### Tier 2 — total loss (no backup) -With `BadgeMasterKey` retained, the client re-derives `BadgeKeyCommitment` and re-attaches by presenting a provider credential (the store proof for Apple/Google, a key-possession proof for Stripe): +`order_id`/`order_sk`/`BadgeMasterKey` are all gone. **Apple/Google are still recoverable** because the entitlement lives in the store: -| Provider | Credential | Bot match | Result | -|---|---|---|---| -| Apple | signed transaction (`Transaction.currentEntitlements`) | original-transaction binding | re-attached; status/grant refreshed | -| Google | purchase token (`queryPurchases`) | linked-token/order binding | re-attached; status/grant refreshed | -| Stripe | `BadgeKeyProof` | `BadgeKeyCommitment` → `payments` row | re-attached; status/grant refreshed (portal session if requested) | +1. The client makes a **fresh order** (new `order_id`/`order_sk`/`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. + +This requires the bot to let a *new* order claim an entitlement a prior order already bound. The `entitlements` table binds each provider object to an order but permits re-bind to a new order on a fresh verified receipt, **capped by the per-period `reissue_count` and rate-limited** (BBS badges can't be revoked, so bound the over-issue). + +**Stripe has no tier-2 badge recovery** — there is no client-side re-presentable receipt. The hosted portal login (email OTP) can only cancel; a new badge requires a new order/purchase. ### Badge re-issuance -Re-attaching does not install a badge. Reconciliation then issues from the eligible or fulfilled grant. Issuance is idempotent on `(grant, master-key hash)`: the same `BadgeMasterKey` returns the same cached credential — no new charge, no duplicate. A one-time grant already `GrantFulfilled` returns the cached credential. - -### Lost master key - -If `BadgeMasterKey` is lost with no backup, the payment cannot be re-attached: - -- Apple/Google: unaffected; the client re-presents store proof and cancellation is store-side. -- Stripe: cancellation falls back to the hosted portal login page (email OTP); billing continues until canceled there or the card lapses. -- Installed badges remain valid to signed expiry; no new issuance against the lost key. +Issuance is idempotent on `(grant, master-key hash)`: within one order the same `BadgeMasterKey` returns the same cached credential — no new charge, no duplicate. A one-time grant already `GrantFulfilled` returns the cached credential. ### Abuse controls -- Verify the credential before re-attaching; never re-attach on an unauthenticated selector such as a bare `BadgeKeyCommitment`. -- Rate-limit attempts per `BadgeKeyCommitment` and per provider binding; the `BadgeKeyProof` nonce prevents replay. -- Re-attaching changes only the client association; it never mutates provider or billing state. +- Verify the order signature (tier 1) or the provider receipt (tier 2) before acting; never act on an unsigned request. +- Rate-limit re-binds per entitlement and per provider object; enforce the per-period `reissue_count` cap. +- Re-binding changes only which order owns an entitlement; it never mutates provider or billing state. ## 9. Security and concurrency - Verify provider signatures/objects server-side; never trust decoded client/redirect fields. -- Encrypt retained proofs/provider IDs; rotate keys. Select the payment by `BadgeKeyCommitment` and authorize only on a verified proof (store proof or `BadgeKeyProof`); never act on an unauthenticated selector. -- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during ownership verification/signing; persist only domain-separated `BadgeKeyCommitment`. +- Encrypt retained proofs/provider IDs; rotate keys. Authenticate every request by the order's Ed25519 signature and verify provider proof server-side; never act on an unsigned request. +- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during signing; the bot stores the `order_key` (public) and never keeps the master key as an identifier. - Allowlist product, app/package, environment, currency/price, and account binding. - Rate-limit operation/payment and cap payload sizes. - Serialize payment mutations with lock/version; events and RPC use the same transitions. @@ -872,7 +902,7 @@ If `BadgeMasterKey` is lost with no backup, the payment cannot be re-attached: 1. **Schema/protocol:** five sums/codecs, migrations, grant boundary, request ledger, Chat Console audit, core install API. 2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI. -3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation. +3. **Stripe:** Checkout, waiting `Purchase(StripePaid)`, webhook wake-up, reconciliation, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation. 4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup. Tests: @@ -884,7 +914,7 @@ Tests: - Stripe async payment, invoice renewal, cancellation, closed app/browser, delayed/duplicate/reordered webhook; - monthly/yearly grant periods and 21 July → 31 August expiry; - crash/replay at every side-effect boundary; -- `BadgeKeyProof`/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection; +- order-signature/grant/BBS-owner isolation, product-pin/SKU-match checks, and wrong-`BadgeMasterKey` rejection; - Chat Console coverage and redaction snapshots. Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, store-policy review, complete error handling, operational dashboards. @@ -893,7 +923,7 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, | Location | Change | |---|---| -| new `Simplex.Chat.Badges.Lifecycle` | client sums/transitions/reconciliation; call `generateMasterKey` before prepare | +| new `Simplex.Chat.Badges.Lifecycle` | client sums/transitions/reconciliation; generate order key + `generateMasterKey` before order create | | `Library.Commands.addUserBadge` | non-CLI verified install API | | RPC/controller/console | calls, response handling, redacted audit | | client store/migrations | separate payment and badge stores | @@ -916,4 +946,6 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, ## 12. Open questions -**Lost key.** Recovery §8 defines the behavior when `BadgeMasterKey` is lost with no backup (Stripe cancellation falls back to the portal login page; badge expires normally; Apple/Google unaffected). Decision: accept this, or add an optional user-held recovery code as a second Stripe recovery credential. +**Stripe total loss.** Recovery §8 tier 2 shows Apple/Google recover after a full wipe (store re-presents the entitlement) but Stripe cannot — the hosted portal login only cancels, and a new badge needs a new order. Decision: accept this, or add an optional user-held recovery code as a second Stripe order-recovery credential. + +**Re-issue cap value.** The per-entitlement, per-period `reissue_count` bounds tier-2 over-issue. Decision: pick the cap (e.g. 2–3 per period) and the rate-limit window. diff --git a/plans/2026-07-20-supporter-badges-v2-product.md b/plans/2026-07-20-supporter-badges-v2-product.md index 6c28ea1b91..6aa0cda19a 100644 --- a/plans/2026-07-20-supporter-badges-v2-product.md +++ b/plans/2026-07-20-supporter-badges-v2-product.md @@ -4,7 +4,7 @@ **Status:** implementation-ready **Companion:** [Implementation plan](2026-07-20-supporter-badges-v2-implementation.md) -Payment and badge are separate: payment creates a service grant for an eligible monthly grant period; the grant authorizes one badge issuance; core verifies and installs it. +An **order** carries a plan (the product) and a payment. Payment creates a service grant for an eligible monthly grant period; the grant authorizes one badge issuance; core verifies and installs it. Order, payment, and badge are separate. ![Lifecycle](assets/badge-v2-e2e.svg) @@ -35,6 +35,10 @@ Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action. - Cancellation stops renewal. It does not shorten an issued badge. - Stripe uses the system browser. No localhost service is required. +### Tiers + +Supporter and investor grant the same functional perk (2 GB file uploads); legend raises it to 5 GB; investor is otherwise cosmetic (badge color). The tier is fixed by the plan purchased (its SKU), not chosen afterward. Badge expiry is a shared end-of-month boundary, so everyone renewing in a month shares one anonymity set. + ### Dates | Payment event | Billing | Badge | @@ -50,9 +54,9 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends - Core signature and expiry decide badge validity. - Bot/provider verification decides payment status and grant eligibility. - Store state and Stripe redirects are hints only. -- Each client RPC call receives exactly one bot response; the bot never initiates a call. -- Before payment, the client generates one 32-byte BBS `BadgeMasterKey`. The payment and every badge issued from it are bound to that key; renewals reuse it. -- There is no bot-issued token. Each request carries its own credential — a fresh store proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe). The raw BBS key and provider proofs are redacted. +- 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. +- 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 @@ -103,13 +107,13 @@ sequenceDiagram participant C as Client participant A as StoreKit participant B as Bot - C->>B: Prepare Apple payment - B-->>C: Account binding + C->>B: Purchase (Apple), signed order + B-->>C: Order created + binding Note over C: Store ready C->>A: Purchase A-->>C: Signed transaction Note over C: Verifying - C->>B: Transaction + badge request + C->>B: Purchase (Apple receipt) Note over B: Payment entitled, badge issued B-->>C: Status + badge Note over C: Entitled, badge installed @@ -122,8 +126,8 @@ sequenceDiagram participant C as Client participant A as StoreKit participant B as Bot - C->>B: Prepare Apple payment - B-->>C: Account binding + C->>B: Purchase (Apple), signed order + B-->>C: Order created + binding C->>A: Purchase A-->>C: Pending Note over C: Payment pending, badge unchanged @@ -137,8 +141,8 @@ sequenceDiagram participant C as Client participant A as StoreKit participant B as Bot - C->>B: Prepare Apple payment - B-->>C: Account binding + C->>B: Purchase (Apple), signed order + B-->>C: Order created + binding C->>A: Purchase A-->>C: User canceled Note over C: Previous state @@ -156,12 +160,12 @@ sequenceDiagram participant C as Client participant G as Google Play participant B as Bot - C->>B: Prepare Google payment - B-->>C: Account binding + C->>B: Purchase (Google), signed order + B-->>C: Order created + binding C->>G: Purchase G-->>C: Purchase token Note over C: Verifying - C->>B: Token + badge request + C->>B: Purchase (Google receipt) B->>G: Verify with Publisher API G-->>B: Paid period Note over B: Payment entitled, badge issued @@ -176,8 +180,8 @@ sequenceDiagram participant C as Client participant G as Google Play participant B as Bot - C->>B: Prepare Google payment - B-->>C: Account binding + C->>B: Purchase (Google), signed order + B-->>C: Order created + binding C->>G: Purchase G-->>C: Pending Note over C: Payment pending, badge unchanged @@ -191,8 +195,8 @@ sequenceDiagram participant C as Client participant G as Google Play participant B as Bot - C->>B: Prepare Google payment - B-->>C: Account binding + C->>B: Purchase (Google), signed order + B-->>C: Order created + binding C->>G: Purchase G-->>C: User canceled Note over C: Previous state @@ -208,18 +212,18 @@ sequenceDiagram participant C as Client participant B as Bot participant S as Stripe - C->>B: RPC Prepare Stripe + C->>B: Purchase (StripeInvoice), create order B->>S: Create Checkout Session - B-->>C: RPC Checkout URL + B-->>C: RspInvoice (Checkout URL) C->>S: Open Checkout Note over C: CPAwaitingPayment - C->>B: RPC IssueBadge + C->>B: Purchase (StripePaid), same order Note over B: Hold call, no response yet S-->>B: Signed webhook B->>S: Retrieve and verify payment S-->>B: Paid Note over B: GrantReady → badge issued - B-->>C: RPC badge credential + B-->>C: RspCredential Note over C: CPEntitled + CBReceived C->>C: Verify and install Note over C: CBInstalled @@ -231,13 +235,13 @@ sequenceDiagram sequenceDiagram participant C as Client participant B as Bot - C->>B: RPC IssueBadge + C->>B: Purchase (StripePaid) B->>B: Persisted payment is still pending Note over B: Hold call, no response Note over C: RPC deadline / app closes C-xB: Cancel waiting call Note over C: CPAwaitingPayment + CBRetryableFailure - C->>B: Repeat same IssueBadge on foreground + C->>B: Repeat same Purchase on foreground Note over C: CPAwaitingPayment + CBRequesting Note over B: Respond immediately if webhook already completed
otherwise wait again ``` @@ -249,15 +253,15 @@ sequenceDiagram participant C as Client participant B as Bot participant S as Stripe - C->>B: RPC IssueBadge + C->>B: Purchase (StripePaid) Note over B: Hold call S-->>B: Signed Checkout expired event Note over B: BPExpired, no grant - B-->>C: RPC checkout expired + B-->>C: RspError checkout expired Note over C: CPExpired, new Checkout requires user action ``` -There is no payment polling. The pending `IssueBadge` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof. +There is no payment polling. The pending `Purchase` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof. ### Cancel subscription @@ -320,8 +324,8 @@ Stripe cancellation always happens in the browser Customer Portal (the portal ca | Client still holds | Cancel link the bot sends | |---|---| -| the master key (proves ownership) | authenticated portal session — opens straight to the cancel flow, no email code | -| nothing (key lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) | +| the order (can sign with its order key) | authenticated portal session — opens straight to the cancel flow, no email code | +| nothing (order lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) | ## 5. Refresh and errors @@ -332,12 +336,12 @@ If `GrantReady` exists without the current badge, request issuance. Cache the re | Condition | Client action | |---|---| | Store canceled | restore previous screen | -| Payment pending | keep `IssueBadge` waiting; retry the same call after deadline/restart | +| Payment pending | keep `Purchase` waiting; retry the same signed call after deadline/restart | | Network/provider failure | keep cached state and active badge; retry | | Paid, issuance failed | show “Payment confirmed. Badge is being prepared”; retry | | Cancel failed | keep **Renews on**; retry | | Payment issue | show Fix payment / Manage | -| Ownership/proof failure | restore/support; no sensitive details | +| Order signature / proof failure | restore/support; no sensitive details | | Unknown issuer/protocol | require update | | Invalid credential | reject; retain old badge; retry/support | | Duplicate/lost response | repeat same request; no duplicate charge/badge | @@ -352,7 +356,8 @@ Errors preserve the last payment snapshot and installed badge. The implementatio - Apple, Google, and Stripe have separate linear outcomes. - Payment verification creates a provider-neutral service grant; badge service has no provider logic. - Client and bot payment/badge states are separate. -- RPC is client-request/bot-response only and idempotent. +- Requests are client-signed (order key), one bot response each, idempotent by order id. +- 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. - RPC attempts/results appear redacted in Developer Tools → Chat Console. diff --git a/plans/assets/badge-v2-e2e.svg b/plans/assets/badge-v2-e2e.svg index 98a7304563..afd08371e8 100644 --- a/plans/assets/badge-v2-e2e.svg +++ b/plans/assets/badge-v2-e2e.svg @@ -2,5 +2,5 @@ CLIENTPayment stateprepare · pending · refreshBadge statereceive · install BOTPayment serviceverify provider entitlementServiceGrantready · fulfilled · revokedBadge servicerequest · sign · cache PROVIDERApple / Google / Stripecharge · renew · refund -RPC evidence/statusAPI / webhookverified grant periodinternal contractRPC resultnext refresh +signed order (Purchase/Status)API / webhookverified grant periodinternal contractorder responsenext refresh Payment never installs a badge. Badge issuance never interprets provider state. \ No newline at end of file diff --git a/plans/assets/badge-v2-roles.svg b/plans/assets/badge-v2-roles.svg index 050beb2d64..540b062a2b 100644 --- a/plans/assets/badge-v2-roles.svg +++ b/plans/assets/badge-v2-roles.svg @@ -1,6 +1,6 @@ Responsibility and trust boundaries -CLIENT ORCHESTRATOR• separate payment + badge machines• native store / hosted browser UI• owns master key (sole credential)• asks through one-off RPC• core verifies and installs badge• reconciliation and local audit -PAYMENT SERVICE• store proof / key-possession auth• provider verification/status• billing and grant schedule• cancellation where supported• never sees badge master key +CLIENT ORCHESTRATOR• separate payment + badge machines• native store / hosted browser UI• owns order id + order key• asks through one-off RPC• core verifies and installs badge• reconciliation and local audit +PAYMENT SERVICE• order signature (Ed25519) auth• provider verification/status• billing and grant schedule• cancellation where supported• never sees badge master key SERVICE GRANT• provider-neutral authorization• one monthly grant period BADGE SERVICE• accepts grant + badge request• signs and caches credential• fulfills grant atomically• never imports provider logic• never changes payment state PROVIDER ADAPTERSApple · Google · Stripe