diff --git a/plans/2026-07-20-supporter-badges-v2-implementation.md b/plans/2026-07-20-supporter-badges-v2-implementation.md
index 59667176ac..0acf1fa015 100644
--- a/plans/2026-07-20-supporter-badges-v2-implementation.md
+++ b/plans/2026-07-20-supporter-badges-v2-implementation.md
@@ -17,10 +17,11 @@ Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance f
- [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)
-- [11. Open question](#11-open-question)
+- [8. Recovery](#8-recovery)
+- [9. Security and concurrency](#9-security-and-concurrency)
+- [10. Delivery and tests](#10-delivery-and-tests)
+- [11. API references](#11-api-references)
+- [12. Open questions](#12-open-questions)
## 1. Architecture
@@ -34,7 +35,7 @@ flowchart LR
| Component | Owns | Must not own |
|---|---|---|
-| Client payment | BBS master key, capability, purchase UI, cached status, retry schedule | bot/provider truth |
+| Client payment | BBS master key, purchase UI, cached status, retry schedule | bot/provider truth |
| Client badge | credential receipt and installation | billing state |
| Payment service | BBS owner commitment, proof verification, billing state, grant schedule | raw master key, credential |
| Service grant | product and eligible monthly grant period | provider proof, credential |
@@ -51,7 +52,7 @@ Treat these as separate programs with typed interfaces.
4. Only `GrantReady` plus the matching raw master key enters badge signing.
5. Grant fulfillment and cached issuance result are atomic/idempotent.
6. Payment never activates perks; verified credential does.
-7. RPC has no caller identity or bot push. Capability authorizes each payment request.
+7. RPC has no caller identity, no bot-issued token, and no bot push. Each request carries its own credential — a fresh provider proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe) — verified against the payment binding.
8. Duplicate RPCs/events return the same result. Unknown states preserve prior state.
9. Provider dates create eligibility; retry/request time never changes badge expiry.
@@ -112,7 +113,7 @@ These names are canonical. Every transition is validated against the current con
| State | Meaning |
|---|---|
-| `BPPrepared` | payment/capability/binding stored |
+| `BPPrepared` | payment/commitment/binding stored |
| `BPCheckoutOpen` | Stripe Session stored |
| `BPAwaitingPayment` | provider not complete |
| `BPVerifying` | reconciliation lease active |
@@ -166,12 +167,22 @@ data ServiceCall = ServiceCall
data PaymentInput
= Prepare Provider ServiceProductId PurchaseKind BadgeKeyCommitment
- | AppleEvidence PaymentId Capability SignedTransactionJWS
- | GoogleEvidence PaymentId Capability PurchaseToken
- | ExistingPayment PaymentId Capability
- | CancelSubscription PaymentId Capability
- | CreatePortal PaymentId Capability
+ | AppleEvidence BadgeKeyCommitment SignedTransactionJWS
+ | GoogleEvidence BadgeKeyCommitment PurchaseToken
+ | StripeStatus BadgeKeyProof
+ | StripeCancel BadgeKeyProof
+ | StripePortal BadgeKeyProof
+-- BadgeKeyProof is the raw BadgeMasterKey, revealed over the E2E channel;
+-- the bot re-derives BadgeKeyCommitment = SHA-256(domain || key) and matches.
+-- This reuses issuance, which already sends the key, so it exposes nothing new.
+--
+-- No PaymentId on the wire: each Prepare uses a fresh BadgeMasterKey, so
+-- BadgeKeyCommitment is 1:1 with the payment and selects it. Apple/Google
+-- carry the commitment and authenticate by fresh store proof; Stripe calls
+-- carry a BadgeKeyProof, which both selects (via commitment) and authenticates.
+-- PaymentId (below) is an internal primary/foreign key only.
+type BadgeKeyProof = BadgeMasterKey
data ServiceRequest = IssueBadge BadgeMasterKey (Maybe GrantId)
data ServiceResponse = ServiceResponse
@@ -185,13 +196,16 @@ data ServiceResponse = ServiceResponse
Rules:
+- Each `Prepare` uses a fresh `BadgeMasterKey`; renewals of the same subscription reuse it without a new `Prepare`. So `BadgeKeyCommitment` is unique per payment and is the wire selector; `PaymentId` never appears on the wire.
- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeKeyCommitment`, and reuses the key for all renewal badges.
- `Prepare` stores `BadgeKeyCommitment` but cannot issue a badge.
- Apple/Google evidence may include `IssueBadge`.
- Stripe prepare returns Checkout data. The client then sends `IssueBadge`; while payment is pending the bot holds that call and sends no response.
- The waiting call responds once after verified payment and issuance, or with a terminal payment error. Other retryable operations may still return `retryAfter`.
-- Capability and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
+- The `BadgeMasterKey`, its `BadgeKeyProof`, and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
- `grantId` selects only; bot rechecks payment, owner, product, and eligibility.
+- `StripeCancel`/`StripePortal` return a portal URL in the payment snapshot (like the Checkout URL); the bot never cancels silently. `StripeCancel` deep-links to the cancel flow; `StripePortal` opens general management. When the request carries no valid `BadgeKeyProof` (customer cannot be identified), the bot returns the account-wide login page instead of a session.
+- There is no bot-issued authorization token and no `PaymentId` on the wire. The payment is selected by `BadgeKeyCommitment` (Apple/Google carry it explicitly; a Stripe `BadgeKeyProof` references it). Each RPC also authenticates: Apple/Google by fresh store proof, Stripe by the `BadgeKeyProof`. The bot resolves and verifies before acting.
- Before signing, orchestrator recomputes `BadgeKeyCommitment` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without fulfilling grant.
### Internal interface
@@ -203,7 +217,7 @@ fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult
Order:
-1. authorize capability;
+1. select the payment by `BadgeKeyCommitment` and authenticate the request credential (store proof, or `BadgeKeyProof` against that commitment);
2. resolve/verify payment;
3. commit payment and create/load due grant;
4. verify the request key matches the payment `BadgeKeyCommitment`;
@@ -217,7 +231,7 @@ Order:
- 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.
+- Redact JWS/token, `BadgeKeyProof`, Checkout query/return token, master key, credential, and provider/customer IDs.
## 4. Provider flows
@@ -302,11 +316,11 @@ sequenceDiagram
B->>S: Create Checkout Session
S-->>B: Session ID + URL
Note over B: BPCheckoutOpen
- B-->>C: RPC payment ID + capability + URL
+ B-->>C: RPC Checkout URL
Note over C: CPCheckoutReady
C->>S: Open Checkout
Note over C: CPAwaitingPayment
- C->>B: RPC IssueBadge(requestId, paymentId, key)
+ C->>B: RPC IssueBadge(requestId, key)
Note over C: CPAwaitingPayment + CBRequesting
B->>B: Register waiter and recheck payment under lock
Note over B: BPAwaitingPayment No response yet
@@ -351,7 +365,7 @@ sequenceDiagram
B-->>C: Credential immediately if ready otherwise hold this call
```
-The client persists `requestId`, payment capability, and `BadgeMasterKey` before opening Checkout. It retries only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
+The client persists `requestId` and `BadgeMasterKey` before opening Checkout. It retries only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
### Cancellation
@@ -359,9 +373,18 @@ The client persists `requestId`, payment capability, and `BadgeMasterKey` before
|---|---|---|---|
| Apple | open Apple management UI; status RPC on return | App Store Server API status | `BPEndsAtPeriodEnd` |
| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `BPEndsAtPeriodEnd` |
-| Stripe | `CancelSubscription` RPC | set `cancel_at_period_end=true`, retrieve Subscription | `BPEndsAtPeriodEnd` |
+| Stripe | open a browser Customer Portal from a bot-provided link | return a portal link (session or login page); the portal performs the cancel, reconciled via `customer.subscription.updated` webhook | `BPEndsAtPeriodEnd` |
-Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. Stripe Portal cancellation is disabled.
+Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. The bot never cancels a Stripe subscription itself: the hosted Customer Portal calls `cancel_at_period_end`, and the bot reconciles it from the webhook.
+
+**Stripe cancel-link selection.** Cancellation is always in the browser portal; the bot chooses which link it returns based on whether the request identifies the customer:
+
+| Client presents | Portal link the bot returns |
+|---|---|
+| valid `BadgeKeyProof` (matches stored `BadgeKeyCommitment`) | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code |
+| no valid proof (master key lost with the app) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP |
+
+The authenticated session link is short-lived and per-customer; the login page is the operator-config account-wide URL and returns no per-customer secret. The bot carries whichever link applies in Stripe status responses so a cancel path is always reachable. Because possession of the `BadgeMasterKey` is the sole client credential, there is no intermediate "capability lost" state: the client either can prove key possession (session) or cannot (login page).
## 5. Persistence and `CallState` pattern
@@ -382,7 +405,7 @@ Define five separate sums: client payment, client badge, bot payment, grant, bot
### Client tables
-`badge_payments`: provider/product/plan, payment state payload, encrypted capability and `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
+`badge_payments`: provider/product/plan, payment state payload, encrypted `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
`badges`: payment/grant/grant-period/key hash, badge state payload, cached credential, expiry, attempt/error, version.
@@ -408,7 +431,7 @@ Provider calls/signing run outside long transactions. Leases and compare-and-swa
Triggers: launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and date boundaries.
```text
-reconcile(paymentId):
+reconcile(payment):
coalesce to one worker
render cached payment + installed badge
submit unseen Apple/Google evidence
@@ -438,7 +461,7 @@ Every input is one of:
| deadline/restart/lost response | retry on foreground | preserve state; repeat same ID/body | remove waiter; return cached result or wait again |
| duplicate event/request | idempotent | accept same state/result | preserve state; dedupe/re-fetch |
| ID reused with new body | reject | preserve state; new ID only for new action | preserve state; telemetry |
-| invalid capability/binding | reject | preserve state; restore/support | preserve state; rate-limit |
+| invalid proof/binding | reject | preserve state; restore/support | preserve state; rate-limit |
| invalid proof/product | reject | `CPPaymentProblem`; no blind retry | preserve `BP…`; quarantine/alert |
| unknown provider state | quarantine | `CPPaymentProblem`; retry later | preserve `BP…`; re-fetch, never guess |
| `GrantReady` | apply | `CBNeeded` → `CBRequesting` | `BBRequested` when requested |
@@ -471,7 +494,7 @@ Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_
|---|---|---|---|---|
| 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 |
+| Stripe | retrieve Session/Intent/Invoice/Subscription | one-time intent/session; subscription paid invoice | signed webhook → re-fetch | browser portal (bot-provided link) |
Provider-state mapping:
@@ -495,15 +518,60 @@ Google linked-token replacement changes subscription identity/period data, then
Rules:
- Google initial subscription acknowledgement and one-time consumption run from durable outbox.
-- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, and redirect URLs.
+- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, redirect URLs, and collects customer email so the hosted portal login works.
- Stripe subscription grant 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.
+- All Stripe cancellation, invoices, and payment methods go through the browser Customer Portal — an authenticated `billing_portal.Session` when the customer is identifiable, else the account-wide login page (email OTP) which is also the app-removed path; the bot reconciles portal cancellation from the webhook. Apple/Google normal cancellation is store UI.
-## 8. Security and concurrency
+## 8. Recovery
+
+Recovery re-establishes payment control and the badge after reinstall, device transfer, or local data loss. There is no bot-issued token and no caller identity, so a reinstalled client is a new contact; it re-attaches by presenting a credential the bot matches to a stored payment.
+
+### State ownership
+
+| Side | Durable | Lost on client wipe without backup |
+|---|---|---|
+| Bot | payment (keyed by `BadgeKeyCommitment`), provider bindings, `sub_`/`cus_`, grants, cached credential | — |
+| Client | — | `BadgeMasterKey` (commitment re-derives from it), cached credential, installed badge |
+
+The bot never loses the payment. `BadgeMasterKey` is the only client secret to protect; all other client state is re-derivable.
+
+### Restore from backup
+
+SimpleX encrypted-profile backup or migration restores `BadgeMasterKey`, `BadgeKeyCommitment`, and the cached badge. No recovery RPC runs. This is the primary path.
+
+### Re-attach when only the master key survives
+
+With `BadgeMasterKey` retained, the client re-derives `BadgeKeyCommitment` and re-attaches by presenting a provider credential (the store proof for Apple/Google, a key-possession proof for Stripe):
+
+| Provider | Credential | Bot match | Result |
+|---|---|---|---|
+| Apple | signed transaction (`Transaction.currentEntitlements`) | original-transaction binding | re-attached; status/grant refreshed |
+| Google | purchase token (`queryPurchases`) | linked-token/order binding | re-attached; status/grant refreshed |
+| Stripe | `BadgeKeyProof` | `BadgeKeyCommitment` → `payments` row | re-attached; status/grant refreshed (portal session if requested) |
+
+### Badge re-issuance
+
+Re-attaching does not install a badge. Reconciliation then issues from the eligible or fulfilled grant. Issuance is idempotent on `(grant, master-key hash)`: the same `BadgeMasterKey` returns the same cached credential — no new charge, no duplicate. A one-time grant already `GrantFulfilled` returns the cached credential.
+
+### Lost master key
+
+If `BadgeMasterKey` is lost with no backup, the payment cannot be re-attached:
+
+- Apple/Google: unaffected; the client re-presents store proof and cancellation is store-side.
+- Stripe: cancellation falls back to the hosted portal login page (email OTP); billing continues until canceled there or the card lapses.
+- Installed badges remain valid to signed expiry; no new issuance against the lost key.
+
+### Abuse controls
+
+- Verify the credential before re-attaching; never re-attach on an unauthenticated selector such as a bare `BadgeKeyCommitment`.
+- Rate-limit attempts per `BadgeKeyCommitment` and per provider binding; the `BadgeKeyProof` nonce prevents replay.
+- Re-attaching changes only the client association; it never mutates provider or billing state.
+
+## 9. Security and concurrency
- Verify provider signatures/objects server-side; never trust decoded client/redirect fields.
-- Hash capabilities; encrypt retained proofs/provider IDs; rotate keys.
+- Encrypt retained proofs/provider IDs; rotate keys. Select the payment by `BadgeKeyCommitment` and authorize only on a verified proof (store proof or `BadgeKeyProof`); never act on an unauthenticated selector.
- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during ownership verification/signing; persist only domain-separated `BadgeKeyCommitment`.
- Allowlist product, app/package, environment, currency/price, and account binding.
- Rate-limit operation/payment and cap payload sizes.
@@ -511,11 +579,11 @@ Rules:
- 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.
-## 9. Delivery and tests
+## 10. Delivery and tests
1. **Schema/protocol:** five sums/codecs, migrations, grant boundary, request ledger, Chat Console audit, core install API.
2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI.
-3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, cancel RPC, restricted Portal.
+3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation.
4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup.
Tests:
@@ -527,7 +595,7 @@ Tests:
- Stripe async payment, invoice renewal, cancellation, closed app/browser, delayed/duplicate/reordered webhook;
- monthly/yearly grant periods and 21 July → 31 August expiry;
- crash/replay at every side-effect boundary;
-- capability/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
+- `BadgeKeyProof`/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
- Chat Console coverage and redaction snapshots.
Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, store-policy review, complete error handling, operational dashboards.
@@ -548,15 +616,15 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
| `badge-service/stripe_api.py` | Checkout/webhook/status/cancel/Portal |
| `badge-service/wire.py` | versioned call/response; keep existing badge request compatibility during rollout |
-## 10. API references
+## 11. API references
| 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) |
+| 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), [hosted portal login](https://docs.stripe.com/customer-management/activate-no-code-customer-portal) |
| RPC | [`simplexmq` service RPC RFC](https://github.com/simplex-chat/simplexmq/blob/rpc/rfcs/2026-07-11-service-rpc.md) |
-## 11. Open question
+## 12. Open questions
-**Capability recovery:** if the client still has its BBS `BadgeMasterKey` but loses the payment capability after reinstall or device transfer, should key possession recover/rotate that capability? Define the recovery RPC, provider re-verification, rate limits, and user confirmation before implementation. Until then, do not automatically reassign a payment.
+**Lost key.** Recovery §8 defines the behavior when `BadgeMasterKey` is lost with no backup (Stripe cancellation falls back to the portal login page; badge expires normally; Apple/Google unaffected). Decision: accept this, or add an optional user-held recovery code as a second Stripe recovery credential.
diff --git a/plans/2026-07-20-supporter-badges-v2-product.md b/plans/2026-07-20-supporter-badges-v2-product.md
index 9335640894..6c28ea1b91 100644
--- a/plans/2026-07-20-supporter-badges-v2-product.md
+++ b/plans/2026-07-20-supporter-badges-v2-product.md
@@ -25,7 +25,7 @@ Payment and badge are separate: payment creates a service grant for an eligible
|---|---|---|
| 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 |
+| F-Droid / desktop | Stripe Checkout | browser Customer Portal — authenticated session, or login page (email code) after app removal |
Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
@@ -52,7 +52,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
- Store state and Stripe redirects are hints only.
- Each client RPC call receives exactly one bot response; the bot never initiates a call.
- Before payment, the client generates one 32-byte BBS `BadgeMasterKey`. The payment and every badge issued from it are bound to that key; renewals reuse it.
-- Payment capability authorizes bot requests. The raw BBS key, capability, and provider proofs are redacted.
+- There is no bot-issued token. Each request carries its own credential — a fresh store proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe). The raw BBS key and provider proofs are redacted.
## 2. UX states
@@ -210,7 +210,7 @@ sequenceDiagram
participant S as Stripe
C->>B: RPC Prepare Stripe
B->>S: Create Checkout Session
- B-->>C: RPC Checkout URL + capability
+ B-->>C: RPC Checkout URL
C->>S: Open Checkout
Note over C: CPAwaitingPayment
C->>B: RPC IssueBadge
@@ -301,16 +301,28 @@ sequenceDiagram
sequenceDiagram
participant C as Client
participant B as Bot
+ participant P as Stripe Portal
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
+ C->>B: Request cancel link
+ B-->>C: Portal URL (session or login page)
+ C->>P: Open portal, confirm cancel
+ P->>S: Cancel at period end
+ S-->>B: Signed webhook (renewal off + end date)
+ B-->>C: Updated status on next check
Note over C: Canceled, active until end date
```
Never show canceled until the bot confirms renewal is off.
+Cancellation also works after the app is removed: Apple/Google via the store subscription UI; Stripe via the hosted Customer Portal login page (`billing.stripe.com/p/login/…`), where the user signs in with the email they paid with. The bot reconciles the resulting cancellation from the provider webhook/status.
+
+Stripe cancellation always happens in the browser Customer Portal (the portal cancels; the bot reconciles from the webhook). The bot only chooses which link it sends, based on what the client can still prove:
+
+| Client still holds | Cancel link the bot sends |
+|---|---|
+| the master key (proves ownership) | authenticated portal session — opens straight to the cancel flow, no email code |
+| nothing (key lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) |
+
## 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.
@@ -341,6 +353,6 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
- Payment verification creates a provider-neutral service grant; badge service has no provider logic.
- Client and bot payment/badge states are separate.
- RPC is client-request/bot-response only and idempotent.
-- Stripe needs no localhost/deep-link success and cancels through bot RPC.
+- Stripe needs no localhost/deep-link success; cancellation is always via the browser Customer Portal — an authenticated session, or the login page when the client cannot identify the payment.
- Every error category has an owner, state-preserving action, and retry/final result.
- RPC attempts/results appear redacted in Developer Tools → Chat Console.
diff --git a/plans/assets/badge-v2-providers.svg b/plans/assets/badge-v2-providers.svg
index d842d2ef4d..a04d492359 100644
--- a/plans/assets/badge-v2-providers.svg
+++ b/plans/assets/badge-v2-providers.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/plans/assets/badge-v2-roles.svg b/plans/assets/badge-v2-roles.svg
index 4b05e4ec98..050beb2d64 100644
--- a/plans/assets/badge-v2-roles.svg
+++ b/plans/assets/badge-v2-roles.svg
@@ -1,6 +1,6 @@