From 62d00380705ec64e47dbf4e58362f00e507e5e25 Mon Sep 17 00:00:00 2001 From: shum Date: Tue, 21 Jul 2026 14:25:15 +0000 Subject: [PATCH] docs: streamline badge lifecycle plans --- ...7-20-supporter-badges-v2-implementation.md | 875 ++++++++---------- .../2026-07-20-supporter-badges-v2-product.md | 485 +++++----- 2 files changed, 630 insertions(+), 730 deletions(-) diff --git a/plans/2026-07-20-supporter-badges-v2-implementation.md b/plans/2026-07-20-supporter-badges-v2-implementation.md index 347701caf0..45f8df409b 100644 --- a/plans/2026-07-20-supporter-badges-v2-implementation.md +++ b/plans/2026-07-20-supporter-badges-v2-implementation.md @@ -1,78 +1,66 @@ # Supporter Badges v2 — Implementation Plan **Date:** 2026-07-21 -**Status:** implementation-ready design +**Status:** implementation-ready **Companion:** [Product and UX plan](2026-07-20-supporter-badges-v2-product.md) -Payment and badge issuance are separate state machines. Provider verification produces a provider-neutral `PaymentCredit`; badge issuance consumes it. Every RPC below names the client and bot state transition caused by that message. +Payment verification creates a provider-neutral `PaymentCredit`. Badge issuance consumes it. Payment and badge are separate state machines on client and bot. -![Architecture and ownership](assets/badge-v2-roles.svg) +![Architecture](assets/badge-v2-roles.svg) ## Contents -- [1. Decisions and current gaps](#1-decisions-and-current-gaps) -- [2. Boundaries and invariants](#2-boundaries-and-invariants) -- [3. Canonical states](#3-canonical-states) -- [4. RPC and internal contracts](#4-rpc-and-internal-contracts) -- [5. Message-driven lifecycles](#5-message-driven-lifecycles) -- [6. Persistence and `CallState` machinery](#6-persistence-and-callstate-machinery) -- [7. Reconciliation, errors, and retries](#7-reconciliation-errors-and-retries) -- [8. Provider rules](#8-provider-rules) -- [9. Security and concurrency](#9-security-and-concurrency) -- [10. Delivery and tests](#10-delivery-and-tests) -- [11. Code integration](#11-code-integration) -- [12. API references](#12-api-references) +- [1. Architecture](#1-architecture) +- [2. State machines](#2-state-machines) +- [3. Contracts](#3-contracts) +- [4. Provider flows](#4-provider-flows) +- [5. Persistence and `CallState` pattern](#5-persistence-and-callstate-pattern) +- [6. Reconciliation and errors](#6-reconciliation-and-errors) +- [7. Provider rules](#7-provider-rules) +- [8. Security and concurrency](#8-security-and-concurrency) +- [9. Delivery and tests](#9-delivery-and-tests) +- [10. API references](#10-api-references) -## 1. Decisions and current gaps +## 1. Architecture -The current `badge-service` is an issuance prototype, not a lifecycle service. - -| Current code | Keep | Change | -|---|---|---| -| Apple JWS verification in `apple.py` | offline certificate/JWS verification | validate account binding/product/environment; add subscription status and notifications | -| Google `subscriptionsv2.get` in `google.py` | server verification | model every provider state; add products v2, acknowledgement/consume, RTDN | -| Stripe Payment Links and polling | configured price IDs | use per-attempt Checkout Sessions, signed webhooks, status and cancel APIs | -| `customData` state in `state.py` | request-hash idea | use transactional payment, credit, and badge tables | -| issued badge keyed by transaction/token | cached idempotent response | key by monthly credit and master-key hash; a Google token may survive renewals | -| wire request/reply | discriminated union | carry one application request and one final response over service RPC | -| CLI-only badge install | verification logic | expose a non-CLI core install command | - -Provider research confirms the design used below: - -- the backend, not local store state or a redirect, owns entitlement truth; -- Apple initial proof is verified offline; Google initial proof requires an Android Publisher API request; -- provider notifications/webhooks only update bot payment state; this RPC transport cannot push to a client; -- Stripe Checkout fulfillment requires a webhook or later API reconciliation; the success redirect is routing only; -- Apple/Google cancellation uses store UI; Stripe cancellation uses the bot RPC. - -## 2. Boundaries and invariants - -### 2.1 Components +### Responsibilities ```mermaid flowchart LR - C[Client orchestrator] -->|one request / one final response| R[SimpleX service RPC] - R --> P[Bot payment service] - P --> A[Apple adapter] - P --> G[Google adapter] - P --> S[Stripe adapter] - A & G & S --> P - P -->|PaymentCredit| O[Bot service orchestrator] + C[Client] -->|one request| R[Service RPC] + R --> O[Bot orchestrator] + O --> P[Payment service] + P --> V[Provider adapter] + P --> X[PaymentCredit] O --> B[Badge service] - B -->|signed credential| R - R --> C + B -->|credential| R + R -->|one final response| C C --> K[Core verify and install] ``` -Treat each box as a separate program with a typed interface: +| Component | Owns | Must not own | +|---|---|---| +| Client payment | capability, purchase UI, cached status, retry schedule | bot/provider truth | +| Client badge | credential receipt and installation | billing state | +| Payment service | proof verification, billing state, credit schedule | master key, credential | +| Payment credit | product and eligible monthly slot | provider proof, credential | +| Badge service | signing and idempotent credential cache | provider/billing logic | +| Core | signature verification and installed badge | payment status | -- **Payment service** knows provider proof, product ID, billing status, and service-credit schedule. It never sees the badge master key or credential. -- **Badge service** accepts a valid credit plus a badge request. It never calls a payment provider or interprets billing state. -- **Orchestrator** resolves payment first, then optionally fulfills the requested service in the same RPC. -- **Client payment machine** and **client badge machine** persist and transition independently. -- **Bot payment machine**, **payment credit machine**, and **bot badge machine** persist and transition independently. +Treat these as separate programs with typed interfaces. -### 2.2 `PaymentCredit` +### Invariants + +1. Provider verification changes payment state only. +2. Only verified entitlement creates a credit. +3. Only `CreditAvailable` plus a master key enters badge signing. +4. Credit consumption and cached issuance result are atomic/idempotent. +5. Payment never activates perks; verified credential does. +6. RPC has no caller identity or bot push. Capability authorizes each payment request. +7. Duplicate RPCs/events return the same result. Unknown states preserve prior state. +8. Provider dates create eligibility; retry/request time never changes badge expiry. + +### Time and credit ```haskell data PaymentCreditState = CreditAvailable | CreditConsumed | CreditVoided @@ -86,118 +74,93 @@ data PaymentCredit = PaymentCredit } ``` -A credit means only: “this verified payment may request this product once for this monthly slot.” It contains no badge fields, master key, or credential and does not activate perks. The badge service maps `productId` and `slotStart` to its credential policy and expiry. +- One-time: one credit at verified purchase time. Reject another one-time prepare while its badge is active. +- Subscription: `slotStart(n) = addCalendarMonths n verifiedAnchor` when `slotStart <= now < paidThrough`. +- Monthly and yearly plans both expose one credit per eligible month. +- Badge service computes expiry as the start of the month two months after `slotStart`. +- Unique credit: `(payment_id, product_id, slot_start)`. +- Example: 21 July slot → badge expires 1 September; monthly billing renews 21 August. -- One-time: one credit at verified purchase time. -- Monthly subscription: one credit per paid monthly period. -- Yearly subscription: one credit becomes available per monthly slot inside the paid annual period. -- Unique key: `(payment_id, product_id, slot_start)`. -- Consuming a credit and recording the badge result are one idempotent operation keyed by `(credit_id, master_key_hash)`. -- Refund/revocation voids unused credits. v2 does not revoke already-issued credentials; their short expiry bounds exposure. +Credit eligibility by payment state: -### 2.3 Time rules - -Billing and badge clocks are deliberately different. - -- `paidThrough` is the provider billing-period end. -- For subscriptions, `slotStart(n) = addCalendarMonths(n, verifiedSubscriptionAnchor)` and a slot is eligible only when `slotStart <= now < paidThrough`. -- The badge service computes `badgeExpiresAt = startOfMonth(addCalendarMonths(2, slotStart))`. -- Example: pay **21 July** → next monthly bill **21 August**, but the badge expires **1 September 00:00 UTC** and is displayed as valid through **31 August**. -- The **21 August** paid slot produces a badge through **30 September**. A yearly plan still renews billing next July, while credits become available monthly. - -### 2.4 Invariants - -1. A provider adapter can transition only payment state and create/void credits. -2. Badge signing requires `CreditAvailable` and a client-supplied master key. -3. Payment state never contains signing, delivery, or installation state. Badge state never contains renewal or provider state. -4. Credential signature, issuer key, and expiry are the only badge-validity truth. -5. Provider object IDs bind to exactly one prepared payment capability. -6. Every mutation is idempotent; duplicate RPCs, notifications, and webhooks produce the same state/result. -7. Unknown provider values and illegal transitions preserve the previous state and return a typed error; they are never guessed. -8. RPC has no stable caller identity and no bot-initiated message. Every operation supplies the payment capability and receives exactly one final response. - -![Separate state machines](assets/badge-v2-states.svg) - -## 3. Canonical states - -These tables are the single state catalog. Every persisted transition and every state marker in section 5 uses these names. - -### 3.1 Client payment state - -| State | Meaning | Leaves on | -|---|---|---| -| `CPNone` | no local payment | user starts purchase | -| `CPPreparing` | prepare RPC in flight | prepare response/error | -| `CPStoreReady` | Apple/Google binding received | native purchase result | -| `CPCheckoutReady` | Stripe URL and checkout ID received | browser opened/checkout replaced | -| `CPProviderPending` | store approval or Stripe completion pending | proof/status result | -| `CPVerifying` | proof/status RPC in flight | canonical response/error | -| `CPEntitled` | last bot snapshot is paid/eligible | refresh, cancel, provider change | -| `CPCanceling` | Stripe cancel RPC in flight or store UI open | refreshed snapshot/error | -| `CPCancelAtEnd` | renewal off; paid through a future date | resubscribe/expiry | -| `CPProblem` | grace/on-hold/provider/transient failure | retry/recovery | -| `CPExpired` | no remaining entitlement | new purchase | - -`CPProblem` stores the last canonical snapshot, typed error, and `nextRetryAt`; it does not erase an active badge. - -### 3.2 Bot payment state - -| State | Meaning | Typical entry | -|---|---|---| -| `BPPrepared` | payment ID, capability, plan, and account binding stored | prepare RPC | -| `BPCheckoutOpen` | live Stripe Checkout Session stored | Stripe Session creation | -| `BPPendingProvider` | approval/asynchronous payment pending | provider status | -| `BPVerifying` | provider reconciliation claimed by one worker | proof/status/webhook | -| `BPPaidOneTime` | one-time payment verified | provider verification | -| `BPActive` | subscription paid through `periodEnd`, renewal on | provider verification | -| `BPGrace` | provider explicitly grants grace | provider verification | -| `BPOnHold` | payment failed; no new credit | provider verification | -| `BPPaused` | provider paused entitlement | provider verification | -| `BPCancelAtEnd` | renewal off, still paid through `periodEnd` | store status/cancel RPC | -| `BPExpired` | paid period ended | reconciliation | -| `BPRefunded` | verified refund/chargeback | provider event/API | -| `BPRevoked` | provider revoked entitlement | provider event/API | - -`BPVerifying` is a recoverable work marker with `previousState`, lease owner, and lease expiry. A crash returns to reconciliation without losing the prior canonical snapshot. - -### 3.3 Credit state - -| State | Transition | +| State | New credit | |---|---| -| `CreditAvailable` | created only from a verified eligible period/slot | -| `CreditConsumed` | badge result durably recorded for the credit/key pair | -| `CreditVoided` | unused credit invalidated by verified refund/revocation | +| `BPPaidOneTime` | its single unissued credit | +| `BPActive` | current due slot through `paidThrough` | +| `BPGrace` | only while the provider explicitly reports entitlement | +| `BPCancelAtEnd` | due slots until `paidThrough` | +| all other states | none | -### 3.4 Client badge state +![State machines](assets/badge-v2-states.svg) + +## 2. State machines + +These names are canonical. Every transition is validated against the current constructor. + +### Client payment | State | Meaning | |---|---| -| `CBNone` | no locally usable credential | -| `CBNeeded` | payment response exposed an available credit | -| `CBRequesting` | service request in flight | -| `CBReceived` | credential response durably cached, not installed | -| `CBInstalling` | core verification/install in progress | -| `CBInstalled` | credential verified and installed | -| `CBRetryableFailure` | transient RPC/sign/install failure; old badge retained | -| `CBFinalFailure` | invalid credential/protocol/key; update or support required | +| `CPNone` | no payment | +| `CPPreparing` | prepare RPC running | +| `CPStoreReady` | Apple/Google binding ready | +| `CPCheckoutReady` | Stripe URL ready | +| `CPProviderPending` | payment/approval pending | +| `CPVerifying` | evidence/status RPC running | +| `CPEntitled` | last bot status is paid | +| `CPCanceling` | management/cancel operation running | +| `CPCancelAtEnd` | renewal off; paid time remains | +| `CPProblem` | typed error + prior snapshot + retry time | +| `CPExpired` | no entitlement remains | -### 3.5 Bot badge state +### Bot payment | State | Meaning | |---|---| -| `BBRequested` | credit/key idempotency record created | -| `BBSigning` | signing work claimed by one worker | -| `BBIssued` | credential cached and credit consumed | -| `BBRetryableFailure` | safe to repeat same logical request | -| `BBFinalFailure` | malformed key/product or permanently unsupported request | +| `BPPrepared` | payment/capability/binding stored | +| `BPCheckoutOpen` | Stripe Session stored | +| `BPPendingProvider` | provider not complete | +| `BPVerifying` | reconciliation lease active | +| `BPPaidOneTime` | verified one-time payment | +| `BPActive` | paid subscription, renewal on | +| `BPGrace` | provider grants grace | +| `BPOnHold` | failed payment; no new credit | +| `BPPaused` | provider paused entitlement | +| `BPCancelAtEnd` | renewal off; paid time remains | +| `BPExpired` | paid time ended | +| `BPRefunded` | verified refund/chargeback | +| `BPRevoked` | provider revoked entitlement | -No `BBInstalled` exists: installation belongs only to the client. +`BPVerifying` stores prior state, lease owner, and lease expiry. -## 4. RPC and internal contracts +### Client badge -### 4.1 Unified service call +| State | Meaning | +|---|---| +| `CBNone` | no usable local badge | +| `CBNeeded` | credit available | +| `CBRequesting` | issue RPC running | +| `CBReceived` | response cached, not installed | +| `CBInstalling` | core verification/install running | +| `CBInstalled` | verified and installed | +| `CBRetryableFailure` | retry while retaining old badge | +| `CBFinalFailure` | update/support required | -The application payload lets payment resolution and a service request share one roundtrip without coupling their implementations. +### Bot badge + +| State | Meaning | +|---|---| +| `BBRequested` | credit/key idempotency row created | +| `BBSigning` | signing lease active | +| `BBIssued` | credential cached; credit consumed | +| `BBRetryableFailure` | same request can retry | +| `BBFinalFailure` | invalid/permanently unsupported request | + +Credit states are `CreditAvailable`, `CreditConsumed`, and `CreditVoided`. There is no bot “installed” state. + +## 3. Contracts + +### RPC payload ```haskell data ServiceCall = ServiceCall @@ -214,11 +177,8 @@ data PaymentInput | CancelSubscription PaymentId Capability | CreatePortal PaymentId Capability -data ServiceRequest - = IssueBadge MasterKey (Maybe CreditId) -``` +data ServiceRequest = IssueBadge MasterKey (Maybe CreditId) -```haskell data ServiceResponse = ServiceResponse { requestId :: RequestId , payment :: PaymentSnapshot @@ -230,434 +190,347 @@ data ServiceResponse = ServiceResponse Rules: -- `Prepare` cannot include `IssueBadge`; no credit exists yet. -- Apple/Google purchased evidence may include `IssueBadge`, so verification and issuance complete in one RPC. -- Stripe prepare returns `CPCheckoutReady`; after webhook/API reconciliation, `ExistingPayment + IssueBadge` completes issuance in one later RPC. -- If payment is pending, the final response contains the canonical snapshot, no service result, and `retryAfter`. -- A `creditId` is a selector only. The bot independently resolves eligibility and rejects a credit belonging to another payment/product. +- `Prepare` cannot issue a badge. +- Apple/Google evidence may include `IssueBadge`. +- Stripe prepare returns Checkout data; a later `ExistingPayment + IssueBadge` issues. +- Pending response has no credit/result and includes `retryAfter`. +- Capability never enters Stripe metadata or a return URL. +- Store capability in the encrypted profile database and include it in supported profile transfer/backup. If it is lost, RPC identity cannot recover it; require explicit provider-bound restore/support and never silently reassign payment. +- `creditId` selects only; bot rechecks ownership/product/eligibility. -### 4.2 Internal program boundary +### Internal interface ```haskell resolvePayment :: PaymentInput -> Transaction PaymentDecision fulfillBadge :: PaymentCredit -> BadgeRequest -> Transaction BadgeResult ``` -The orchestrator performs: +Order: -1. authorize capability and resolve provider-neutral payment decision; -2. commit payment transition and create/find the due credit; -3. if a service request exists, pass only that credit and request to the badge service; -4. persist `BBIssued` and `CreditConsumed` atomically, then return the cached credential. +1. authorize capability; +2. resolve/verify payment; +3. commit payment and create/load due credit; +4. pass only credit + request to badge service; +5. cache issuance and consume credit atomically; +6. return one final response. -Provider calls/signing happen outside long DB transactions. Leases plus compare-and-swap versions make crash recovery explicit. +### Idempotency and audit -### 4.3 Correlation, replay, and audit +- `requestId` binds to canonical request hash. Same body returns stored response; different body returns `idempotency_mismatch`. +- 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 capability, JWS/token, Checkout query/return token, master key, credential, and provider/customer IDs. -- `requestId` is stable for one logical operation and bound to a canonical request hash. Same ID/body returns the stored response; same ID/different body returns `idempotency_mismatch`. -- The RPC transport separately deduplicates exact encrypted request bytes for its bounded 1–24 hour replay window. Application idempotency outlives that window. -- Stripe mutation idempotency keys derive from `requestId` and operation. -- Every attempt and final result is visible in **Developer Tools → Chat Console** with timestamp, request ID, method, payment ID suffix, state before/after, retry class, and duration. -- Console/log redaction removes capability, JWS, purchase token, checkout URL query, return token, master key, credential bytes, and provider/customer IDs. +## 4. Provider flows -## 5. Message-driven lifecycles +Product outcomes are in the Product Plan. These diagrams show implementation boundaries only. -State notes show exactly which party changes state. A dashed response never changes bot state unless its preceding bot note says so. - -### 5.1 Apple purchase — offline initial verification +### Common credit → badge path ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant RPC as Service RPC - participant BP as Bot payment service - participant CR as Payment credit - participant BB as Bot badge service - participant Core as Client core - - Note over CP: CPNone - U->>CP: Buy once / monthly / yearly - Note over CP: CPPreparing - CP->>RPC: ServiceCall(Prepare Apple) - RPC->>BP: prepare - Note over BP: BPPrepared - BP-->>CP: paymentId + capability + appAccountToken - Note over CP: CPStoreReady - CP->>CP: StoreKit purchase sheet with appAccountToken - alt User canceled - Note over CP: CPNone (prepared row expires later) - else StoreKit pending - Note over CP: CPProviderPending - else Verified transaction JWS returned - Note over CP: CPVerifying - Note over CB: CBRequesting - CP->>RPC: ServiceCall(AppleEvidence, IssueBadge) - RPC->>BP: resolve payment - Note over BP: BPVerifying - BP->>BP: verify signed JWS offline
binding, app, product, dates, revocation - Note over BP: BPPaidOneTime or BPActive - BP->>CR: create/find monthly credit - Note over CR: CreditAvailable - RPC->>BB: fulfill credit + badge request - Note over BB: BBRequested -> BBSigning -> BBIssued - BB->>CR: consume atomically with issued credential - Note over CR: CreditConsumed - BB-->>CP: final payment snapshot + credential - Note over CP: CPEntitled - Note over CB: CBReceived - CB->>Core: verify and install - Note over CB: CBInstalling -> CBInstalled - end + participant C as Client + participant RPC as RPC + participant O as Orchestrator + participant P as Payment service + participant X as Credit + participant B as Badge service + participant K as Core + C->>RPC: ServiceCall(payment, IssueBadge) + RPC->>O: Authorized request + O->>P: Resolve payment + Note over P: Paid / active + P->>X: Create or load slot + Note over X: Available + O->>B: Create request + Note over B: Requested + O->>B: Claim and sign + Note over B: Issued + B->>X: Commit result + Note over X: Consumed + O-->>RPC: Final status + credential + RPC-->>C: Final response + C->>K: Verify and install + Note over C: Installed ``` -The initial Apple transaction is verified offline. Later subscription status/recovery uses App Store Server API and signed Notifications V2. - -### 5.2 Google purchase — server API verification +### Apple initial verification ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant RPC as Service RPC - participant BP as Bot payment service - participant G as Google Publisher API - participant CR as Payment credit - participant BB as Bot badge service - participant Core as Client core - - Note over CP: CPNone - U->>CP: Buy once / monthly / yearly - Note over CP: CPPreparing - CP->>RPC: ServiceCall(Prepare Google) - RPC->>BP: prepare - Note over BP: BPPrepared - BP-->>CP: paymentId + capability + obfuscatedAccountId - Note over CP: CPStoreReady - CP->>CP: Play Billing UI with binding - alt User canceled - Note over CP: CPNone (prepared row expires later) - else Purchase pending - Note over CP: CPProviderPending - else PURCHASED + purchaseToken - Note over CP: CPVerifying - Note over CB: CBRequesting - CP->>RPC: ServiceCall(GoogleEvidence, IssueBadge) - RPC->>BP: resolve payment - Note over BP: BPVerifying - BP->>G: productsv2.get or subscriptionsv2.get - G-->>BP: canonical purchase and period - Note over BP: BPPaidOneTime or BPActive - BP->>CR: create/find monthly credit - Note over CR: CreditAvailable - RPC->>BB: fulfill credit + badge request - Note over BB: BBRequested -> BBSigning -> BBIssued - BB->>CR: consume atomically with issued credential - Note over CR: CreditConsumed - BB-->>CP: final payment snapshot + credential - Note over CP: CPEntitled - Note over CB: CBReceived - CB->>Core: verify and install - Note over CB: CBInstalling -> CBInstalled - BP->>G: acknowledge / consume via durable outbox - end + participant C as Client + participant RPC as RPC + participant O as Orchestrator + participant A as Apple adapter + participant P as Payment store + C->>RPC: Apple evidence + RPC->>O: Authorized request + O->>A: Verify signed transaction + A->>A: Verify signature, app, product,
binding, dates, revocation + A-->>O: Normalized result + O->>P: Apply result + Note over P: Paid / active ``` -The bot commits entitlement before acknowledgement/consume and retries the provider action durably. The client does not race it unless the bot explicitly requests a compatibility fallback. +This path is offline. Status/restore uses App Store Server API; Notifications V2 only trigger reconciliation. -### 5.3 Stripe — F-Droid and desktop +### Google verification ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant RPC as Service RPC - participant BP as Bot payment service + participant C as Client + participant RPC as RPC + participant O as Orchestrator + participant G as Google adapter + participant API as Publisher API + participant P as Payment store + C->>RPC: Google evidence + RPC->>O: Authorized request + O->>G: Verify token + G->>API: productsv2.get / subscriptionsv2.get + API-->>G: Canonical purchase + G-->>O: Normalized result + O->>P: Apply result + Note over P: Paid / active +``` + +Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never grant from notification payload. + +### Stripe Checkout and webhook + +```mermaid +sequenceDiagram + participant C as Client + participant RPC as RPC + participant P as Payment service participant S as Stripe - participant W as System browser - participant CR as Payment credit - participant BB as Bot badge service - participant Core as Client core - - Note over CP: CPNone - U->>CP: Buy once / monthly / yearly - Note over CP: CPPreparing - CP->>RPC: ServiceCall(Prepare Stripe) - RPC->>BP: prepare and create Checkout - Note over BP: BPPrepared - BP->>S: Checkout Session.create (idempotency key) - S-->>BP: session ID + hosted URL - Note over BP: BPCheckoutOpen - BP-->>CP: paymentId + capability + checkout ID + URL - Note over CP: CPCheckoutReady - CP->>W: open hosted Checkout - Note over CP: CPProviderPending - W->>S: complete payment - S-->>BP: signed webhook - Note over BP: BPVerifying - BP->>S: retrieve Session/Invoice/Subscription - S-->>BP: canonical object - Note over BP: BPPaidOneTime or BPActive - BP->>CR: create/find due monthly credit - Note over CR: CreditAvailable - Note over CP: still CPProviderPending (no bot push) - S-->>W: hosted HTTPS success page - W-->>CP: app/universal link (routing only) - Note over CP: CPVerifying - Note over CB: CBRequesting - CP->>RPC: ServiceCall(ExistingPayment, IssueBadge) - RPC->>BP: authorize and reconcile if stale - alt Still pending or webhook delayed - Note over BP: BPPendingProvider - BP-->>CP: pending snapshot + retryAfter - Note over CP: CPProviderPending - Note over CB: CBRetryableFailure - else Credit available - RPC->>BB: fulfill credit + badge request - Note over BB: BBRequested -> BBSigning -> BBIssued - BB->>CR: consume atomically with issued credential - Note over CR: CreditConsumed - BB-->>CP: final payment snapshot + credential - Note over CP: CPEntitled - Note over CB: CBReceived - CB->>Core: verify and install - Note over CB: CBInstalling -> CBInstalled - end + participant W as Webhook endpoint + C->>RPC: Prepare Stripe + RPC->>P: Authorized request + Note over P: Prepared + P->>S: Create Checkout Session + S-->>P: Session ID + URL + Note over P: Checkout open + P-->>RPC: payment ID + capability + URL + RPC-->>C: Final response + Note over C: Checkout ready, start polling + S-->>W: Signed webhook + W->>W: Verify and persist event + W-->>S: 2xx + W->>P: Reconcile event + P->>S: Retrieve current objects + S-->>P: Canonical payment + Note over P: Paid / active, credit available ``` -No localhost listener is used. The hosted return page works for F-Droid and desktop. If return routing fails, foreground/status reconciliation finds the payment. Poll new RPCs after 5, 15, 30, 60, and 120 seconds, then stop until a normal trigger. The bot never pushes. +Webhook handler verifies the raw body, persists/deduplicates event ID, returns `2xx`, then workers reconcile. Client remains pending until its next RPC. -Stripe subscription cancellation is only through `CancelSubscription` RPC. Customer Portal may support invoices and payment-method changes, but subscription cancellation is disabled there. - -### 5.4 Cancellation and status refresh +### Stripe status while pending ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant RPC as Service RPC - participant BP as Bot payment service - participant P as Apple / Google / Stripe - - Note over CP: CPEntitled - U->>CP: Cancel and confirm - Note over CP: CPCanceling - alt Apple - CP->>P: showManageSubscriptions - P-->>CP: return / foreground - CP->>RPC: ServiceCall(ExistingPayment) - RPC->>BP: refresh status - BP->>P: App Store Server API status - else Google - CP->>P: open Play subscription management UI - P-->>CP: return / foreground - CP->>RPC: ServiceCall(ExistingPayment) - RPC->>BP: refresh status - BP->>P: subscriptionsv2.get - else Stripe - CP->>RPC: ServiceCall(CancelSubscription) - RPC->>BP: cancel at period end - BP->>P: Subscription.update(cancel_at_period_end=true) - end - P-->>BP: canonical renewal status - Note over BP: BPCancelAtEnd - BP-->>CP: willRenew=false + paidThrough - Note over CP: CPCancelAtEnd + participant C as Client + participant RPC as RPC + participant P as Payment service + participant S as Stripe + C->>RPC: ExistingPayment + RPC->>P: Authorized request + Note over P: Verifying + P->>S: Retrieve Checkout + S-->>P: Pending / unpaid + Note over P: Pending provider + P-->>RPC: Pending + retryAfter + RPC-->>C: Final response + Note over C: Poll later ``` -Timeout/error leaves the last canonical state intact and moves the client to `CPProblem`; the UI must not claim cancellation until the response confirms `willRenew=false`. “Already canceled” is idempotent success. +Poll from Checkout open and on return/foreground at 5, 15, 30, 60, 120 seconds. Then use normal reconciliation. Deep links are optional; no localhost listener. -## 6. Persistence and `CallState` machinery +### Cancellation -### 6.1 Pattern to mirror +| Provider | Client action | Bot action | Confirmed state | +|---|---|---|---| +| Apple | open Apple management UI; status RPC on return | App Store Server API status | `BPCancelAtEnd` | +| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `BPCancelAtEnd` | +| Stripe | `CancelSubscription` RPC | set `cancel_at_period_end=true`, retrieve Subscription | `BPCancelAtEnd` | -The implementation must mirror the existing `data CallState` machinery, not merely copy its naming: +Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. Stripe Portal cancellation is disabled. -- closed sum constructors carry only fields valid in that state; -- a small tag enum/projection supports SQL queries; -- `deriveJSON (singleFieldJSON fstToLower)` provides stable tagged JSON; -- explicit `ToField`/`FromField` encode SQL `TEXT` and reject unknown tags; -- typed store functions reconstruct the full sum and fail on inconsistent columns; -- the controller keeps runtime `TMap`s and per-payment locks, as calls do; -- command/subscriber transitions pattern-match current state and return a typed invalid-state error; -- migrations add every new tag/column before code can emit it. +## 5. Persistence and `CallState` pattern -Reference implementations in this repository: +Mirror existing `data CallState` machinery: -- `src/Simplex/Chat/Call.hs` — `CallState`, state tags, JSON/DB instances; -- `src/Simplex/Chat/Store/Profiles.hs` — typed reads/writes; -- `src/Simplex/Chat/Library/Commands.hs` and `Library/Subscriber.hs` — transition sites; -- `src/Simplex/Chat/Controller.hs` — runtime maps and concurrency ownership. +- closed sums with state-specific fields; +- separate tag projection for queries; +- `deriveJSON (singleFieldJSON fstToLower)`; +- explicit SQL `TEXT` `ToField`/`FromField`; +- typed store reconstruction with inconsistent-row failure; +- controller `TMap` + per-payment locks; +- transition pattern matching + typed invalid-state errors; +- migrations before emitting new tags. -Define separate sums: `ClientPaymentState`, `ClientBadgeState`, `BotPaymentState`, `PaymentCreditState`, and `BotBadgeState`. Do not use one broad record with nullable fields as the state machine. +References: `Simplex.Chat.Call`, `Store.Profiles`, `Library.Commands`, `Library.Subscriber`, and `Controller`. -### 6.2 Client tables +Define five separate sums: client payment, client badge, bot payment, credit, bot badge. Do not encode state as one nullable record. -`badge_payments` owns payment state and scheduling: +### Client tables -- payment ID, provider, product/kind/interval, state tag + state payload; -- encrypted capability and master key, provider binding/proof reference; -- canonical `paidThrough`, `willRenew`, last checked, next retry, version. +`badge_payments`: provider/product/plan, payment state payload, encrypted capability/master key, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version. -`badges` owns credential workflow: +`badges`: payment/credit/slot/key hash, badge state payload, cached credential, expiry, attempt/error, version. -- badge row ID, payment ID, credit ID/slot start, master-key hash; -- state tag + state payload, credential cache, expiry, attempt/error, version. +Join by payment/credit ID only. Update active profile only after core installation. -They live alongside each other and join only by `payment_id`/`credit_id`. Active profile projection is updated only after core installation. +### Bot tables -### 6.3 Bot tables - -| Table | Purpose / important uniqueness | +| Table | Unique key / purpose | |---|---| -| `payments` | canonical bot payment sum; unique provider object ownership | -| `payment_credits` | credit sum; unique `(payment_id, product_id, slot_start)` | -| `badge_issuances` | badge sum and cached credential; unique `(credit_id, master_key_hash)` | -| `rpc_requests` | request hash + stored final response; unique request ID | -| `provider_events` | raw-reference/dedupe/result; unique provider event ID | -| `outbox` | acknowledge, consume, notification reconciliation, cleanup | +| `payments` | provider-object ownership; canonical payment sum | +| `payment_credits` | payment + product + slot | +| `badge_issuances` | credit + master-key hash; cached credential | +| `rpc_requests` | request ID; request hash + final response | +| `provider_events` | provider event ID; dedupe/result | +| `outbox` | acknowledge, consume, reconciliation, cleanup | -All mutations use optimistic version checks or a per-payment row lock. A webhook and RPC call share the same transition functions. +Provider calls/signing run outside long transactions. Leases and compare-and-swap versions recover crashes. -## 7. Reconciliation, errors, and retries +## 6. Reconciliation and errors -### 7.1 Client reconciliation +### Client reconciliation -Triggers: launch, profile switch, foreground, network restored, store purchase update, browser/app-link return, manual retry, six-hour jittered timer, and `paidThrough`/badge-expiry boundaries. +Triggers: launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and date boundaries. ```text reconcile(paymentId): - coalesce: one worker per payment - render cached payment and installed badge independently + coalesce to one worker + render cached payment + installed badge submit unseen Apple/Google evidence - call ExistingPayment for every nonterminal payment - if response exposes CreditAvailable and no installed badge covers that slot: - repeat same logical call with IssueBadge - if credential is returned: - cache -> core verify/install -> persist CBInstalled - schedule next check; never infer payment expiry from the local clock alone + request status for nonterminal payment + if current credit exists and badge is absent: request IssueBadge + if credential returned: cache -> verify -> install + schedule next check ``` -Stripe browser-return polling uses elapsed 5 s, 15 s, 30 s, 60 s, and 120 s attempts. Other transient work uses 5 s, 30 s, 2 min, 15 min, then 6 h with jitter. Respect `retryAfter`. Background work is opportunistic; foreground reconciliation is required. +Never infer provider entitlement from the local clock. Keep an active badge during payment errors. -### 7.2 Total response handling +### Total handling rule -Every input has exactly one of four outcomes: +Every input is one of: -1. **Apply** a legal transition and return the new snapshot/result. -2. **Idempotent success**: return the already-stored snapshot/result. -3. **Retry**: preserve canonical state, store typed error/next retry, return `retryAfter`. -4. **Reject/quarantine**: preserve canonical state, return a safe final error, alert operators where appropriate. +1. apply legal transition; +2. return idempotent success; +3. preserve state and retry; +4. preserve state and reject/quarantine. -| Code / signal | Outcome | Client reaction | Bot reaction | +| Input/result | Class | Client | Bot | |---|---|---|---| -| `payment_pending` | Retry | `CPProviderPending`; poll/schedule | keep `BPPendingProvider` | -| provider timeout/429/5xx | Retry | `CPProblem`; show cached state | backoff; retain snapshot | -| response lost | Retry | repeat identical request ID/body | return cached response or continue lease | -| `idempotency_mismatch` | Reject | new ID only for genuinely new action | preserve state; security telemetry | -| invalid/foreign capability or binding | Reject | restore/support; no details | preserve state; rate-limit | -| invalid proof / unknown product | Reject | no blind retry; update/support | preserve/quarantine; alert config owner | -| provider unknown enum/event | Reject/quarantine | stale state + retry later | re-fetch object; no guessed transition | -| entitled + `CreditAvailable` | Apply | `CBNeeded`/request issue | fulfill if requested | -| credit already consumed | Idempotent success | install returned cached credential | return matching issuance; reject other key | -| signing/provider temporarily unavailable | Retry | retain old badge; backoff | `BBRetryableFailure`/outbox retry | -| invalid master key/credential/protocol | Reject | `CBFinalFailure`; update/support | `BBFinalFailure`; never consume credit | -| install crash after response | Retry locally | resume `CBReceived -> CBInstalling` | no bot change | -| cancel timeout | Retry | keep renewal label; never say canceled | retain state; reconcile provider | -| already canceled | Idempotent success | `CPCancelAtEnd` | return refreshed `BPCancelAtEnd` | -| user dismisses store UI | Idempotent exit | restore prior client state | prepared row expires later | -| Apple/Google pending purchase | Retry | `CPProviderPending`; listen/query | no credit | -| Stripe Session expired/async failed | Reject attempt | offer a new checkout on user action | close attempt; no credit | -| refund/revocation | Apply | show payment ended; installed badge lives to signed expiry | void unused credits; no future credit | -| DB unavailable during webhook | Retry delivery | no immediate client effect | return non-2xx; provider retries | +| payment pending | retry | pending; schedule | keep pending; no credit | +| timeout/429/5xx | retry | prior snapshot; backoff | retain state; `retryAfter` | +| lost response | retry | repeat same ID/body | return cached result | +| duplicate event/request | idempotent | accept same result | dedupe/re-fetch | +| ID reused with new body | reject | new ID only for new action | preserve; telemetry | +| invalid capability/binding | reject | restore/support | preserve; rate-limit | +| invalid proof/product | reject | no blind retry | quarantine/alert | +| unknown provider state | quarantine | stale + retry later | re-fetch; do not guess | +| credit available | apply | request issuance | fulfill if requested | +| credit consumed | idempotent | install cached credential | return same issuance | +| signing unavailable | retry | keep old badge | retryable badge state | +| invalid key/credential/protocol | reject | update/support | final badge failure | +| install crash | local retry | resume cached response | no bot change | +| cancel timeout | retry | still show Renews | preserve canonical state | +| already canceled | idempotent | show end date | return cancel-at-end | +| user cancels store | exit | prior state | expire prepared row later | +| Stripe Checkout expired | final attempt | new checkout on user action | close attempt; no credit | +| refund/revocation | apply | payment ended; signed badge survives to expiry | void unused credits | +| webhook DB failure | retry delivery | no change | non-2xx; provider retries | -Never expose raw provider exceptions. Stable response codes include `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`, and `internal_error`. +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`. -### 7.3 Crash boundaries +### Crash recovery -- Before provider call: repeat the same request. -- Provider succeeds before local commit: retrieve by provider idempotency key/object binding and reconcile. -- Payment committed before service fulfillment: credit remains available. -- Credential signed before response: cached `BBIssued` is returned on repeat. -- Response received before client install: resume from `CBReceived` without a new charge. -- Duplicate/out-of-order event: dedupe, re-fetch current object, apply monotonic transition. +- Before provider call: repeat request. +- Provider succeeds before commit: retrieve by idempotency key/object binding. +- Payment committed before issuance: credit remains available. +- Credential cached before response loss: repeat returns it. +- Response cached before install: resume local installation. +- Duplicate/out-of-order event: dedupe, re-fetch, monotonic transition. -## 8. Provider rules +## 7. Provider rules -| Rail | Initial verification | Ongoing truth | Cancel UX | Required details | +| Provider | Verify | Identity/period | Notifications | Cancel | |---|---|---|---|---| -| Apple | verify StoreKit signed transaction JWS offline | App Store Server API + Notifications V2 | `showManageSubscriptions`; App Store fallback | bind `appAccountToken`; validate chain, bundle, environment, product, transaction, dates, revocation; identity is `originalTransactionId` | -| Google | Android Publisher products v2/subscriptions v2 GET | Publisher API + RTDN | Play subscription-management UI | bind obfuscated account ID; model pending/active/grace/on-hold/paused/canceled/expired; follow linked-token chain; durable acknowledge/consume | -| Stripe | retrieve Checkout Session/PaymentIntent/Invoice | signed webhook + API retrieval | bot `CancelSubscription` RPC only | server-selected allowlisted Price; Checkout idempotency; raw-body signature; paid invoice required for subscription credit | +| Apple | offline signed initial transaction; server API later | subscription: original transaction + renewal transaction | Notifications V2 → re-fetch | store UI | +| Google | products v2 / subscriptions v2 GET | linked token chain + order/period | RTDN → re-fetch | Play UI | +| Stripe | retrieve Session/Intent/Invoice/Subscription | one-time intent/session; subscription paid invoice | signed webhook → re-fetch | bot RPC | -Additional rules: +Required mappings: -- Apple one-time is a consumable/non-renewing product according to App Store configuration; finish only after durable attachment. Initial JWS needs no Apple API roundtrip. -- Google one-time uses `purchases.productsv2.getproductpurchasev2`; subscriptions use `purchases.subscriptionsv2.get`. A token is not a renewal-period ID. -- Stripe uses `mode=payment|subscription`, `client_reference_id=paymentId`, and a hosted HTTPS `success_url`. No amount, Price, Customer, or redirect URL comes from the client. -- Stripe webhook, completion page, and status RPC call the same reconciliation function. `checkout.session.completed` with `payment_status=unpaid` remains pending. Subscription credit requires a paid invoice, not merely `status=active`. -- The Stripe return token routes to a local payment only. It is not authorization and contains no capability. -- Customer Portal cancellation is disabled; portal sessions may be created for invoices/payment methods. -- Normal Google cancellation is UI-driven; the Publisher cancel API is operator/recovery only. Apple cannot be canceled by the bot. +- Apple: active, grace, billing retry, cancel-at-end, expired, refunded/revoked. +- Google: pending, active, grace, on-hold, paused, canceled, expired, linked-token replacement. +- Stripe: Checkout open/expired, async pending/success/failure, invoice paid/failed, subscription active/past-due/unpaid/paused/cancel-at-end/deleted, refund/dispute. -## 9. Security and concurrency +Rules: -- Verify provider signatures/objects server-side; never trust client-decoded fields or redirect parameters. -- Hash capabilities at rest; encrypt retained JWS/tokens/provider identifiers; rotate keys. -- The raw master key is client-encrypted at rest and exists on the bot only during signing. Store only its hash afterward. -- Allowlist product, app/package, environment, currency/price, and provider account binding. -- Rate-limit by payment/operation and cap payload sizes. RPC has no persistent contact identity. -- Serialize payment mutations with a lock/version. Provider events and RPC use the same transition functions. -- Use transactional outboxes for provider actions and event work. Alert on stale verification leases, acknowledgement deadline, webhook lag, and signing failures. -- Trust issuer keys shipped with the client. Unknown issuer/protocol produces `CBFinalFailure`/update UX. +- Google initial subscription acknowledgement and one-time consumption run from durable outbox. +- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, and redirect URLs. +- Stripe subscription credit requires a paid invoice, not merely active Subscription status. +- Webhook/status/completion page use one reconciliation function; redirects never fulfill. +- Portal is for invoices/payment methods only. Apple/Google normal cancellation is store UI. -## 10. Delivery and tests +## 8. Security and concurrency -1. **Schema and protocol:** all five sums/codecs, client and bot migrations, credit boundary, request ledger, redacted Chat Console audit, core install command. -2. **Apple and Google:** prepare binding, evidence verification, complete provider-state mapping, notification/RTDN ingestion, acknowledgement/consume, native purchase and management UI. -3. **Stripe:** Checkout Sessions, hosted completion/deep link, raw-body webhook, API reconciliation, cancel RPC, restricted portal. -4. **UX and hardening:** reconciliation scheduler, every product UX state/error, migrations from existing bot state, telemetry and cleanup. +- Verify provider signatures/objects server-side; never trust decoded client/redirect fields. +- Hash capabilities; encrypt retained proofs/provider IDs; rotate keys. +- Keep raw master key client-encrypted and bot-memory-only during signing; persist its hash. +- 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. +- Use outbox for provider actions/events. Alert on stale leases, acknowledgement deadline, webhook lag, and signing failures. +- Trust client-shipped issuer keys; unknown key/protocol requires update. -Required tests: +## 9. Delivery and tests -- JSON/SQL golden roundtrips for every constructor and rejection of unknown/inconsistent rows; -- property tests for every legal/illegal transition in all five machines; -- sequence tests asserting each message changes only the named owner state; -- Apple offline JWS and later status/notification cases; -- Google pending, linked-token renewal, grace/hold/pause/cancel, acknowledge/consume retry; -- Stripe async payment, paid invoice, monthly/yearly credit schedule, cancellation, closed browser/app, delayed/duplicate/reordered webhooks; -- July 21 → August 31 badge expiry and August 21 billing/next-slot boundary; -- crash/replay at every boundary in section 7.3; -- Chat Console coverage and secret-redaction snapshots for every RPC variant; -- cross-payment capability, credit, and master-key isolation. +1. **Schema/protocol:** five sums/codecs, migrations, credit 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, completion page, webhook, reconciliation, cancel RPC, restricted Portal. +4. **UX/hardening:** scheduler, all Product states, migration, telemetry, cleanup. -Release gates: provider sandbox end-to-end tests, webhook signature/replay tests, schema rollback test, store-policy review, no unresolved state/error handling, and operational dashboards. +Tests: -## 11. Code integration +- JSON/SQL roundtrip and invalid-row tests for every constructor; +- legal/illegal transition properties for all five machines; +- message tests proving only the named owner changes state; +- Apple JWS/status/notification and Google pending/renewal/grace/hold/cancel cases; +- Stripe async payment, invoice renewal, cancellation, closed app/browser, delayed/duplicate/reordered webhook; +- monthly/yearly slots and 21 July → 31 August expiry; +- crash/replay at every side-effect boundary; +- capability/credit/master-key isolation; +- Chat Console coverage and redaction snapshots. -| Location | Work | +Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, store-policy review, complete error handling, operational dashboards. + +### Code locations + +| Location | Change | |---|---| -| new `Simplex.Chat.Badges.Lifecycle` | client state sums, tags/codecs, transitions, reconciliation | -| `Simplex.Chat.Library.Commands.addUserBadge` | safe non-CLI credential install returning updated user | -| RPC client/controller and console | unified calls, response handling, redacted audit records | -| client store/migrations | separate `badge_payments` and `badges` typed stores | -| Kotlin/Swift UI | derive Product Plan state from payment snapshot + installed badge | -| bot payment service/repository | replace `customData`; provider adapters, credits, transitions, outbox | -| bot badge service/repository | consume credit, sign, cache idempotent credential; no provider imports | -| `badge-service/apple.py` | offline proof validation plus server subscription reconciliation | -| `badge-service/google.py` | full Publisher mapping, period identity, ack/consume | -| `badge-service/stripe_api.py` | Checkout, webhook, status, cancel, restricted portal | -| `badge-service/wire.py` | versioned `ServiceCall`/`ServiceResponse`; retain v1 during migration | +| new `Simplex.Chat.Badges.Lifecycle` | client sums/transitions/reconciliation | +| `Library.Commands.addUserBadge` | non-CLI verified install API | +| RPC/controller/console | calls, response handling, redacted audit | +| client store/migrations | separate payment and badge stores | +| Kotlin/Swift | derive Product UX state | +| bot payment repository | replace `customData`; providers, credits, outbox | +| bot badge repository | credit-only signing/cache; no provider imports | +| `badge-service/apple.py` | proof + subscription status | +| `badge-service/google.py` | full mapping + acknowledge/consume | +| `badge-service/stripe_api.py` | Checkout/webhook/status/cancel/Portal | +| `badge-service/wire.py` | versioned call/response; retain v1 migration path | -## 12. API references +## 10. API references -| Provider | Client | Server/events | -|---|---|---| -| Apple | [StoreKit](https://developer.apple.com/storekit/) purchase, transaction updates, `showManageSubscriptions` | [Get All Subscription Statuses](https://developer.apple.com/documentation/appstoreserverapi/get-all-subscription-statuses), transaction history, Notifications V2 | -| Google | [Play Billing](https://developer.android.com/google/play/billing/integrate) purchase/query/manage UI | [`productsv2.getproductpurchasev2`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.productsv2/getproductpurchasev2), [`subscriptionsv2.get`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get), RTDN | -| Stripe | hosted browser and app/universal link | [Checkout Session create](https://docs.stripe.com/api/checkout/sessions/create), [fulfillment](https://docs.stripe.com/checkout/fulfillment), [webhooks](https://docs.stripe.com/webhooks), [subscription events](https://docs.stripe.com/billing/subscriptions/webhooks), [cancel](https://docs.stripe.com/billing/subscriptions/cancel), [Customer Portal](https://docs.stripe.com/customer-management/integrate-customer-portal) | - -SimpleX transport semantics are defined by the [`simplexmq` service RPC RFC](https://github.com/simplex-chat/simplexmq/blob/rpc/rfcs/2026-07-11-service-rpc.md): one short-lived request/reply exchange, no persistent caller identity, and no service-initiated messages. +| Provider | References | +|---|---| +| Apple | [StoreKit](https://developer.apple.com/storekit/), [subscription statuses](https://developer.apple.com/documentation/appstoreserverapi/get-all-subscription-statuses), Notifications V2 | +| Google | [Play Billing](https://developer.android.com/google/play/billing/integrate), [`productsv2.getproductpurchasev2`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.productsv2/getproductpurchasev2), [`subscriptionsv2.get`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get), RTDN | +| Stripe | [Checkout](https://docs.stripe.com/api/checkout/sessions/create), [fulfillment](https://docs.stripe.com/checkout/fulfillment), [webhooks](https://docs.stripe.com/webhooks), [subscription events](https://docs.stripe.com/billing/subscriptions/webhooks), [cancel](https://docs.stripe.com/billing/subscriptions/cancel), [Portal](https://docs.stripe.com/customer-management/integrate-customer-portal) | +| RPC | [`simplexmq` service RPC RFC](https://github.com/simplex-chat/simplexmq/blob/rpc/rfcs/2026-07-11-service-rpc.md) | diff --git a/plans/2026-07-20-supporter-badges-v2-product.md b/plans/2026-07-20-supporter-badges-v2-product.md index 7cd729aea9..dd3f0596ff 100644 --- a/plans/2026-07-20-supporter-badges-v2-product.md +++ b/plans/2026-07-20-supporter-badges-v2-product.md @@ -4,307 +4,334 @@ **Status:** implementation-ready **Companion:** [Implementation plan](2026-07-20-supporter-badges-v2-implementation.md) -A payment grants a provider-neutral monthly service credit. That credit can issue one badge credential. Payment, badge issuance, and local badge installation remain separate states even when one RPC completes several steps. +Payment and badge are separate: payment creates a service credit for an eligible monthly slot; the credit issues one badge; core verifies and installs it. -![End-to-end lifecycle](assets/badge-v2-e2e.svg) +![Lifecycle](assets/badge-v2-e2e.svg) ## Contents -- [1. Product rules](#1-product-rules) +- [1. Rules](#1-rules) - [2. UX states](#2-ux-states) - [3. Badge screen](#3-badge-screen) -- [4. Message-driven flows](#4-message-driven-flows) -- [5. Refresh and notification](#5-refresh-and-notification) -- [6. Error UX](#6-error-ux) -- [7. Acceptance criteria](#7-acceptance-criteria) +- [4. Payment flows](#4-payment-flows) +- [5. Refresh and errors](#5-refresh-and-errors) +- [6. Acceptance criteria](#6-acceptance-criteria) -## 1. Product rules +## 1. Rules -### 1.1 Payment rails +### Plans and providers -| Build | Purchase | Cancel/manage | +| Build | Payment | Cancel/manage | |---|---|---| -| iOS | Apple StoreKit UI | Apple subscription-management UI | -| Android Play | Google Play Billing UI | Google Play subscription-management UI | -| Android non-Play / desktop | Stripe hosted Checkout | cancellation through bot RPC; portal for invoices/payment methods | +| iOS | StoreKit | Apple subscription UI | +| Android Play | Play Billing | Google Play subscription UI | +| F-Droid / desktop | Stripe Checkout | cancel RPC; Customer Portal for invoices/payment methods | -The build selects the rail. There are exactly three choices: **One-time**, **Monthly subscription**, and **Yearly subscription**. There is no Extend action. +Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action. -- One-time buys one non-renewing badge period and does not stack. It becomes purchasable again after expiry. -- Subscribing while a one-time badge is active starts a normal new payment flow; stores do not convert that purchase. -- Monthly/yearly subscriptions renew until canceled and create a new badge credit each eligible month. -- Cancellation stops future renewal but does not shorten an already-issued badge. -- Stripe Checkout, Customer Portal, and app links use the system browser. No localhost HTTP service is used. -- Store-policy approval for Stripe digital purchases is a release gate for every build/region where it is offered. +- One-time does not stack and is available again after badge expiry. +- Subscribing from one-time starts a new payment; there is no conversion API. +- Monthly and yearly plans issue one badge credit per eligible month. +- Cancellation stops renewal. It does not shorten an issued badge. +- Stripe uses the system browser. No localhost service is required. -### 1.2 Dates +### Dates -Billing and badge validity use separate clocks: - -| Event | Billing | Badge | +| Payment event | Billing | Badge | |---|---|---| -| Payment **21 July** | monthly renewal **21 August**; yearly renewal **21 July next year** | valid through **31 August**; expires `1 September 00:00 UTC` | -| Monthly slot **21 August** | monthly renewal **21 September**; yearly billing date unchanged | new badge valid through **30 September** | -| Cancel before the next bill | access remains through provider `paidThrough` | already-issued badge remains valid to its signed expiry | +| Paid 21 July | monthly renews 21 August; yearly renews 21 July next year | valid through 31 August | +| Eligible slot 21 August | monthly renews 21 September; yearly billing unchanged | new badge valid through 30 September | +| Canceled before renewal | subscription remains paid to provider period end | issued badge remains valid to signed expiry | -The UI labels these separately as **Badge valid until** and **Renews on**. After cancellation, use **Subscription ends on**. +Show **Badge valid until** separately from **Renews on** or **Subscription ends on**. -### 1.3 Truth and privacy +### Sources of truth -- Core signature verification + credential expiry decides whether the badge is active. -- Bot/provider verification decides payment status and whether a service credit exists. -- StoreKit/Play local state and Stripe redirects are UI hints, never payment proof. -- The bot returns one final response to each client RPC and cannot initiate a client message. -- Capabilities, receipts, tokens, provider IDs, master keys, and credentials are redacted from logs and Chat Console. +- Core signature and expiry decide badge validity. +- Bot/provider verification decides payment status and credit eligibility. +- Store state and Stripe redirects are hints only. +- Client asks through RPC; bot returns one final response and never pushes. +- Payment capability authorizes bot requests; secrets and provider proofs are redacted. ## 2. UX states -The screen derives its state from two independent sources: the last payment snapshot and the locally installed badge. - -| UX state | Payment / badge condition | Display | Actions | +| State | Condition | Display | Actions | |---|---|---|---| -| **No badge** | no entitlement; no active badge | prices and plan choices | Buy once; Subscribe monthly/yearly | -| **Payment pending** | provider approval/payment pending | old badge if valid; pending message | Continue payment; Check again | -| **Paid, issuing** | credit available; badge request/install in progress | old badge + progress | automatic retry; Retry | -| **Active one-time** | one-time paid; badge active | tier; badge expiry | Subscribe monthly/yearly | -| **Active subscription** | subscription paid and renewing; badge active | interval; badge expiry; renewal date | Cancel subscription; Manage payment | -| **Canceled, active** | renewal off; paid period or badge still active | badge expiry; subscription end | Resubscribe | -| **Payment issue** | grace/on-hold/paused/provider failure | active badge until its own expiry | Fix payment; Check again | -| **Badge missing** | payment credit exists; no usable badge | issuance unavailable/retrying | Retry | -| **Expired** | no entitlement; no active badge | prior badge per retention rules | Buy once; Subscribe monthly/yearly | -| **Needs update** | unknown issuer/protocol | badge unavailable | Update app | -| **Offline/stale** | refresh failed; cache exists | last known state + check time | Retry | +| No badge | no entitlement or active badge | plans and prices | Buy once; Monthly; Yearly | +| Payment pending | provider not complete | old badge if valid | Continue; Check again | +| Issuing | paid; badge request/install running | old badge + progress | automatic retry; Retry | +| Active one-time | one-time badge active | tier; badge expiry | Monthly; Yearly | +| Active subscription | paid, renewing, badge active | interval; badge expiry; renewal | Cancel; Manage | +| Canceled, active | renewal off; paid/badge time remains | badge expiry; subscription end | Resubscribe | +| Payment issue | grace/on-hold/provider error | active badge until expiry | Fix payment; Check again | +| Badge missing | credit exists; no usable badge | issuance error/progress | Retry | +| Expired | no entitlement or active badge | expired state | Buy once; Monthly; Yearly | +| Needs update | unknown issuer/protocol | unavailable | Update app | +| Offline/stale | refresh failed | cached state + check time | Retry | -An active installed badge remains visible during payment refresh, cancellation, or network failures. Payment state alone never activates perks. +An active installed badge remains visible during payment and network errors. ![State ownership](assets/badge-v2-states.svg) ## 3. Badge screen -Use one stable layout: +Display, in order: -1. badge artwork, tier, and proof status; +1. badge, tier, proof status; 2. **Badge valid until**; -3. One-time/Monthly/Yearly and **Renews on** or **Subscription ends on**; -4. one primary action and one secondary manage/recovery action; -5. compact error banner and **Last checked …** only when relevant. +3. payment type and **Renews on** / **Subscription ends on**; +4. primary action, then manage/recovery action; +5. error and **Last checked** only when needed. ![No badge](assets/badge-v2-screen-s0.svg) ![One-time badge](assets/badge-v2-screen-s1.svg) ![Subscription badge](assets/badge-v2-screen-s2.svg) -| Action | Behavior | -|---|---| -| Buy once | start the build’s one-time payment UI; disabled while one-time entitlement is active | -| Subscribe / Resubscribe | choose Monthly or Yearly, then start a new subscription payment | -| Cancel subscription | confirm → Apple/Google management UI or Stripe cancel RPC → refresh | -| Manage / Fix payment | Apple/Google management UI or Stripe Customer Portal | -| Check again | immediate coalesced status RPC; rate-limit repeated taps | - Cancellation copy: **“Cancel renewal? Your subscription stays active until {date}. You won’t be charged again.”** -## 4. Message-driven flows +## 4. Payment flows -Each state note names its owner. Detailed persisted state definitions are in the implementation plan. +Every diagram is one outcome. Client and bot state are labeled separately. -### 4.1 Apple purchase +### Apple + +#### Success ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant B as Bot payment / badge services - participant Core as Client core - - Note over CP: No payment - U->>CP: Buy / Subscribe - Note over CP: Preparing - CP->>B: Prepare Apple payment - Note over B: Payment prepared - B-->>CP: capability + appAccountToken - Note over CP: Store ready - CP->>CP: Open StoreKit purchase UI - alt Canceled or pending - Note over CP: Prior state or Provider pending - else Signed transaction returned - Note over CP: Verifying - Note over CB: Requesting - CP->>B: Apple proof + Issue badge request - Note over B: Verify JWS offline -> Payment entitled
Create credit -> Sign badge -> Consume credit - B-->>CP: payment snapshot + credential - Note over CP: Entitled - Note over CB: Received - CB->>Core: Verify and install - Note over CB: Installed - end + participant C as Client + participant A as StoreKit + participant B as Bot + C->>B: Prepare Apple payment + B-->>C: Account binding + Note over C: Store ready + C->>A: Purchase + A-->>C: Signed transaction + Note over C: Verifying + C->>B: Transaction + badge request + Note over B: Payment entitled, badge issued + B-->>C: Status + badge + Note over C: Entitled, badge installed ``` -### 4.2 Google purchase +#### Pending ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant B as Bot payment / badge services - participant G as Google Publisher API - participant Core as Client core - - Note over CP: No payment - U->>CP: Buy / Subscribe - Note over CP: Preparing - CP->>B: Prepare Google payment - Note over B: Payment prepared - B-->>CP: capability + obfuscated account binding - Note over CP: Store ready - CP->>CP: Open Google Play Billing UI - alt Canceled or pending - Note over CP: Prior state or Provider pending - else purchaseToken returned - Note over CP: Verifying - Note over CB: Requesting - CP->>B: Google proof + Issue badge request - Note over B: Payment verifying - B->>G: Verify product/subscription - G-->>B: canonical purchase period - Note over B: Payment entitled
Create credit -> Sign badge -> Consume credit - B-->>CP: payment snapshot + credential - Note over CP: Entitled - Note over CB: Received - CB->>Core: Verify and install - Note over CB: Installed - end + participant C as Client + participant A as StoreKit + participant B as Bot + C->>B: Prepare Apple payment + B-->>C: Account binding + C->>A: Purchase + A-->>C: Pending + Note over C: Payment pending, badge unchanged + Note over B: Prepared, no credit ``` -Apple and Google intentionally use separate flows: Apple verifies the initial signed transaction offline; Google asks the Publisher API. - -### 4.3 Stripe — F-Droid and desktop +#### Canceled ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant CB as Client badge - participant B as Bot payment / badge services + participant C as Client + participant A as StoreKit + participant B as Bot + C->>B: Prepare Apple payment + B-->>C: Account binding + C->>A: Purchase + A-->>C: User canceled + Note over C: Previous state + Note over B: Prepared row expires later +``` + +Apple initial proof is verified offline. Later status uses App Store Server API. + +### Google + +#### Success + +```mermaid +sequenceDiagram + participant C as Client + participant G as Google Play + participant B as Bot + C->>B: Prepare Google payment + B-->>C: Account binding + C->>G: Purchase + G-->>C: Purchase token + Note over C: Verifying + C->>B: Token + badge request + B->>G: Verify with Publisher API + G-->>B: Paid period + Note over B: Payment entitled, badge issued + B-->>C: Status + badge + Note over C: Entitled, badge installed +``` + +#### Pending + +```mermaid +sequenceDiagram + participant C as Client + participant G as Google Play + participant B as Bot + C->>B: Prepare Google payment + B-->>C: Account binding + C->>G: Purchase + G-->>C: Pending + Note over C: Payment pending, badge unchanged + Note over B: Prepared, no credit +``` + +#### Canceled + +```mermaid +sequenceDiagram + participant C as Client + participant G as Google Play + participant B as Bot + C->>B: Prepare Google payment + B-->>C: Account binding + C->>G: Purchase + G-->>C: User canceled + Note over C: Previous state + Note over B: Prepared row expires later +``` + +### Stripe + +#### Success + +```mermaid +sequenceDiagram + participant C as Client + participant B as Bot participant S as Stripe - participant W as System browser - participant Core as Client core - - Note over CP: No payment - U->>CP: Buy / Subscribe - Note over CP: Preparing - CP->>B: Prepare Stripe payment + C->>B: Prepare Stripe payment B->>S: Create Checkout Session - Note over B: Checkout open - B-->>CP: capability + checkout ID + URL - Note over CP: Checkout ready - CP->>W: Open hosted Checkout - Note over CP: Provider pending - W->>S: Pay + B-->>C: Checkout URL + capability + C->>S: Open Checkout + Note over C: Payment pending, start polling S-->>B: Signed webhook - Note over B: Verify with Stripe API
Payment entitled + credit available - Note over CP: Still pending, bot cannot push - S-->>W: Hosted success page - W-->>CP: Return link (routing only) - Note over CP: Verifying - Note over CB: Requesting - CP->>B: Status + Issue badge request - alt Payment still pending - B-->>CP: pending + retry time - Note over CP: Provider pending - else Credit available - Note over B: Sign badge -> Consume credit - B-->>CP: payment snapshot + credential - Note over CP: Entitled - Note over CB: Received - CB->>Core: Verify and install - Note over CB: Installed - end + Note over B: Payment entitled, credit available + C->>B: Status + badge request + B-->>C: Status + badge + Note over C: Entitled, badge installed ``` -The return link is not proof. On return/foreground the app asks the bot; if still pending it polls at 5, 15, 30, 60, and 120 seconds, then waits for normal reconciliation. If the link fails, foreground refresh still recovers the purchase. - -### 4.4 Cancellation +#### Still pending ```mermaid sequenceDiagram - actor U as User - participant CP as Client payment - participant B as Bot payment service - participant P as Apple / Google / Stripe - - Note over CP: Active subscription - U->>CP: Cancel and confirm - Note over CP: Canceling - alt Apple / Google - CP->>P: Open store subscription management - P-->>CP: Return / foreground - CP->>B: Status request - B->>P: Read canonical subscription status - else Stripe - CP->>B: CancelSubscription RPC - B->>P: Set cancel at period end - end - P-->>B: renewal off + paid-through date - Note over B: Cancel at end - B-->>CP: canonical snapshot - Note over CP: Canceled, active until date + participant C as Client + participant B as Bot + participant S as Stripe + C->>B: Status request + B->>S: Retrieve Checkout + S-->>B: Pending + Note over B: Pending, no credit + B-->>C: Pending + retry time + Note over C: Poll later, badge unchanged ``` -A timeout keeps the previous state and shows Retry. The app never says canceled until the bot confirms renewal is off. Stripe cancellation is through bot RPC only. +#### Expired -## 5. Refresh and notification +```mermaid +sequenceDiagram + participant C as Client + participant B as Bot + participant S as Stripe + S-->>B: Checkout expired + Note over B: Expired, no credit + C->>B: Status request + B-->>C: Checkout expired + Note over C: New checkout requires user action +``` -The client asks; the bot only responds. There are no bot events to the client. +Start polling when Checkout opens and on return/foreground: 5, 15, 30, 60, 120 seconds, then normal refresh. The return link is optional routing, never proof. -Refresh on: +### Cancel subscription -- launch, foreground, profile switch, network restored; -- StoreKit/Play purchase update; -- Stripe return link or browser return; -- manual Check again; -- six-hour jittered timer; -- 24 hours before payment end or badge expiry. +#### Apple -After refresh: +```mermaid +sequenceDiagram + participant C as Client + participant UI as Apple UI + participant B as Bot + participant API as Apple API + C->>UI: Manage subscription + UI-->>C: Return + C->>B: Status request + B->>API: Read status + API-->>B: Renewal off + end date + B-->>C: Updated status + Note over C: Canceled, active until end date +``` -- payment pending → keep pending and schedule retry; -- credit available + badge absent for that slot → request issuance; -- credential returned → cache, verify, install, then update UI; -- no subscription and badge expired → show available purchase choices; -- cancellation/refund → stop future issuance; keep a cryptographically active installed badge until its expiry. +#### Google -Notify once per payment/slot for: payment action required, badge expiring soon without renewal, subscription ending, and badge issuance repeatedly failing. Do not notify merely because the app was offline. +```mermaid +sequenceDiagram + participant C as Client + participant UI as Google UI + participant B as Bot + participant API as Google API + C->>UI: Manage subscription + UI-->>C: Return + C->>B: Status request + B->>API: Read status + API-->>B: Renewal off + end date + B-->>C: Updated status + Note over C: Canceled, active until end date +``` -## 6. Error UX +#### Stripe -| Condition | User message/action | +```mermaid +sequenceDiagram + participant C as Client + participant B as Bot + participant S as Stripe API + C->>B: Cancel RPC + B->>S: Cancel at period end + S-->>B: Renewal off + end date + B-->>C: Updated status + Note over C: Canceled, active until end date +``` + +Never show canceled until the bot confirms renewal is off. + +## 5. Refresh and errors + +Refresh on launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and payment/badge date boundaries. + +If paid credit exists without the current badge, request issuance. Cache the response before core verification/install. There are no bot-initiated client events. + +| Condition | Client action | |---|---| -| Store UI dismissed | return silently to prior screen | -| Payment pending | “Payment pending”; Continue/Check again | -| Network/provider unavailable | keep cached badge; “Couldn’t refresh”; Retry | -| Stripe return link fails | no special failure; foreground polling/status recovers | -| Payment confirmed, badge issue failed | “Payment confirmed. Badge is being prepared”; automatic retry | -| Cancellation request failed | keep **Renews on**; “Couldn’t cancel”; Retry | -| Payment method/grace/on-hold | keep badge while valid; Fix payment/Manage | -| Invalid proof or ownership conflict | generic restore/support message; no sensitive details | -| Unknown issuer/protocol | “Update SimpleX to use this badge” | -| Invalid returned credential | do not install; retain old badge; retry/support | -| Duplicate request/response loss | no duplicate charge/badge; repeat returns same result | +| Store canceled | restore previous screen | +| Payment pending | show pending; poll/schedule | +| 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 | +| Unknown issuer/protocol | require update | +| Invalid credential | reject; retain old badge; retry/support | +| Duplicate/lost response | repeat same request; no duplicate charge/badge | -Every error preserves the last known payment snapshot and installed badge. Errors are classified as retryable, final user/configuration, or operator/security; raw provider messages are never shown. +Errors preserve the last payment snapshot and installed badge. The implementation plan defines retry/final handling. -## 7. Acceptance criteria +## 6. Acceptance criteria -- The three top-level badge states are clear: no badge, active one-time, active subscription. -- Monthly and yearly subscription choices are explicit; no Extend subscription action exists. -- A 21 July payment displays badge validity through 31 August while billing remains 21 August/monthly or 21 July next year/yearly. -- Apple, Google, and Stripe flows show separate client and bot state markers and the message causing each transition. -- Apple initial proof is offline; Google initial proof uses its server API; Stripe uses Checkout + webhook/API reconciliation. -- Payment verification yields a provider-neutral credit; badge issuance does not import provider logic. -- Payment and badge tables are separate state machines on both client and bot. -- RPC is client-request/bot-response only; response loss is recovered idempotently. -- Stripe works without localhost and cancellation is bot RPC only. -- Every response/error has a client reaction, bot reaction, and retry/final classification in the implementation plan. -- Every badge RPC attempt/result is auditable in Developer Tools → Chat Console with secrets redacted. +- No badge, one-time badge, and subscription badge UX is complete. +- Choices are One-time, Monthly, Yearly; no Extend. +- Payment on 21 July shows badge through 31 August while billing keeps its provider date. +- Apple, Google, and Stripe have separate linear outcomes. +- Payment verification creates provider-neutral credit; badge service has no provider logic. +- Client and bot payment/badge states are separate. +- RPC is client-request/bot-response only and idempotent. +- Stripe needs no localhost/deep-link success and cancels through bot RPC. +- Every error category has an owner, state-preserving action, and retry/final result. +- RPC attempts/results appear redacted in Developer Tools → Chat Console.