mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-19 08:30:21 +00:00
docs: rework badge auth, cancel, and recovery model
This commit is contained in:
@@ -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<br/>No response yet
|
||||
@@ -351,7 +365,7 @@ sequenceDiagram
|
||||
B-->>C: Credential immediately if ready<br/>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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="430" viewBox="0 0 1000 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:14px;font-weight:700}.b{font-size:12px}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:9}.yes{fill:#ecfdf5;stroke:#10b981}.ui{fill:#fffbeb;stroke:#f59e0b}</style><rect width="1000" height="430" fill="#f8fafc"/><text x="500" y="38" text-anchor="middle" class="title">Provider capability map</text><text x="135" y="82" text-anchor="middle" class="h">Provider</text><text x="350" y="82" text-anchor="middle" class="h">Purchase</text><text x="570" y="82" text-anchor="middle" class="h">Canonical status</text><text x="790" y="82" text-anchor="middle" class="h">Cancel renewal</text><g><rect class="box" x="35" y="100" width="200" height="80"/><text x="135" y="145" text-anchor="middle" class="h">Apple</text><rect class="box yes" x="250" y="100" width="200" height="80"/><text x="350" y="138" text-anchor="middle" class="b">StoreKit 2</text><text x="350" y="158" text-anchor="middle" class="b">signed JWS</text><rect class="box yes" x="470" y="100" width="200" height="80"/><text x="570" y="138" text-anchor="middle" class="b">StoreKit hint +</text><text x="570" y="158" text-anchor="middle" class="b">App Store Server API</text><rect class="box ui" x="690" y="100" width="270" height="80"/><text x="825" y="138" text-anchor="middle" class="b">Apple management UI</text><text x="825" y="158" text-anchor="middle" class="b">(no app cancel API)</text><rect class="box" x="35" y="200" width="200" height="80"/><text x="135" y="245" text-anchor="middle" class="h">Google</text><rect class="box yes" x="250" y="200" width="200" height="80"/><text x="350" y="238" text-anchor="middle" class="b">Play Billing</text><text x="350" y="258" text-anchor="middle" class="b">purchase token</text><rect class="box yes" x="470" y="200" width="200" height="80"/><text x="570" y="238" text-anchor="middle" class="b">subscriptionsv2.get</text><text x="570" y="258" text-anchor="middle" class="b">RTDN triggers re-fetch</text><rect class="box yes" x="690" y="200" width="270" height="80"/><text x="825" y="238" text-anchor="middle" class="b">Play subscriptions UI</text><text x="825" y="258" text-anchor="middle" class="b">API for operator recovery</text><rect class="box" x="35" y="300" width="200" height="80"/><text x="135" y="345" text-anchor="middle" class="h">Stripe</text><rect class="box yes" x="250" y="300" width="200" height="80"/><text x="350" y="338" text-anchor="middle" class="b">Checkout Session</text><text x="350" y="358" text-anchor="middle" class="b">hosted browser</text><rect class="box yes" x="470" y="300" width="200" height="80"/><text x="570" y="338" text-anchor="middle" class="b">signed webhooks +</text><text x="570" y="358" text-anchor="middle" class="b">server API refresh</text><rect class="box yes" x="690" y="300" width="270" height="80"/><text x="825" y="338" text-anchor="middle" class="b">cancel_at_period_end</text><text x="825" y="358" text-anchor="middle" class="b">bot RPC only</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="430" viewBox="0 0 1000 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:14px;font-weight:700}.b{font-size:12px}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:9}.yes{fill:#ecfdf5;stroke:#10b981}.ui{fill:#fffbeb;stroke:#f59e0b}</style><rect width="1000" height="430" fill="#f8fafc"/><text x="500" y="38" text-anchor="middle" class="title">Provider capability map</text><text x="135" y="82" text-anchor="middle" class="h">Provider</text><text x="350" y="82" text-anchor="middle" class="h">Purchase</text><text x="570" y="82" text-anchor="middle" class="h">Canonical status</text><text x="790" y="82" text-anchor="middle" class="h">Cancel renewal</text><g><rect class="box" x="35" y="100" width="200" height="80"/><text x="135" y="145" text-anchor="middle" class="h">Apple</text><rect class="box yes" x="250" y="100" width="200" height="80"/><text x="350" y="138" text-anchor="middle" class="b">StoreKit 2</text><text x="350" y="158" text-anchor="middle" class="b">signed JWS</text><rect class="box yes" x="470" y="100" width="200" height="80"/><text x="570" y="138" text-anchor="middle" class="b">StoreKit hint +</text><text x="570" y="158" text-anchor="middle" class="b">App Store Server API</text><rect class="box ui" x="690" y="100" width="270" height="80"/><text x="825" y="138" text-anchor="middle" class="b">Apple management UI</text><text x="825" y="158" text-anchor="middle" class="b">(no app cancel API)</text><rect class="box" x="35" y="200" width="200" height="80"/><text x="135" y="245" text-anchor="middle" class="h">Google</text><rect class="box yes" x="250" y="200" width="200" height="80"/><text x="350" y="238" text-anchor="middle" class="b">Play Billing</text><text x="350" y="258" text-anchor="middle" class="b">purchase token</text><rect class="box yes" x="470" y="200" width="200" height="80"/><text x="570" y="238" text-anchor="middle" class="b">subscriptionsv2.get</text><text x="570" y="258" text-anchor="middle" class="b">RTDN triggers re-fetch</text><rect class="box yes" x="690" y="200" width="270" height="80"/><text x="825" y="238" text-anchor="middle" class="b">Play subscriptions UI</text><text x="825" y="258" text-anchor="middle" class="b">API for operator recovery</text><rect class="box" x="35" y="300" width="200" height="80"/><text x="135" y="345" text-anchor="middle" class="h">Stripe</text><rect class="box yes" x="250" y="300" width="200" height="80"/><text x="350" y="338" text-anchor="middle" class="b">Checkout Session</text><text x="350" y="358" text-anchor="middle" class="b">hosted browser</text><rect class="box yes" x="470" y="300" width="200" height="80"/><text x="570" y="338" text-anchor="middle" class="b">signed webhooks +</text><text x="570" y="358" text-anchor="middle" class="b">server API refresh</text><rect class="box yes" x="690" y="300" width="270" height="80"/><text x="825" y="338" text-anchor="middle" class="b">hosted Customer Portal</text><text x="825" y="358" text-anchor="middle" class="b">browser, bot link</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="500" viewBox="0 0 1100 500"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="500" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">Responsibility and trust boundaries</text>
|
||||
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns capability + master key</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
|
||||
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">• capability authorization</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and grant schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
|
||||
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns master key (sole credential)</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
|
||||
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">• store proof / key-possession auth</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and grant schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
|
||||
<rect class="box grant" rx="10" x="595" y="120" width="180" height="130"/><text x="685" y="151" text-anchor="middle" class="h">SERVICE GRANT</text><text x="615" y="188" class="b">• provider-neutral authorization</text><text x="615" y="217" class="b">• one monthly grant period</text>
|
||||
<rect class="box badge" rx="10" x="830" y="75" width="235" height="220"/><text x="947" y="106" text-anchor="middle" class="h">BADGE SERVICE</text><text x="850" y="143" class="b">• accepts grant + badge request</text><text x="850" y="172" class="b">• signs and caches credential</text><text x="850" y="201" class="b">• fulfills grant atomically</text><text x="850" y="230" class="b">• never imports provider logic</text><text x="850" y="259" class="b">• never changes payment state</text>
|
||||
<rect class="box provider" rx="10" x="330" y="350" width="210" height="90"/><text x="435" y="381" text-anchor="middle" class="h">PROVIDER ADAPTERS</text><text x="435" y="407" text-anchor="middle" class="b">Apple · Google · Stripe</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -25,5 +25,5 @@
|
||||
<!-- button -->
|
||||
<rect x="36" y="372" width="248" height="44" rx="10" fill="#ffffff" stroke="#dc2626" stroke-width="2"/>
|
||||
<text x="160" y="399" text-anchor="middle" font-size="14" font-weight="700" fill="#dc2626">Cancel subscription</text>
|
||||
<text x="160" y="436" text-anchor="middle" font-size="10" fill="#94a3b8">Apple/Google: store · Stripe: bot RPC</text>
|
||||
<text x="160" y="436" text-anchor="middle" font-size="10" fill="#94a3b8">Apple/Google: store · Stripe: browser portal</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user