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

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



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