mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-21 21:10:15 +00:00
docs: self-host Stripe Payment Element to avoid mandatory email
Stripe hosted Checkout forces an email field; the Payment Element on our own page supports email='never'. Bot creates a Customer with no email (metadata[order_key]) via a self-hosted pay page: Subscription (default_incomplete) / PaymentIntent, client_secret served from a backend endpoint (never in a URL per Stripe), Link disabled. The cus_ handle is the durable Stripe id; the bot returns a signed recovery handle over it in OrderStatus.providerRef for the client to back up. Cancellation is always an authenticated per-cus_ portal session reached by order key or saved handle — the email-OTP login page is gone. Update provider rules, recovery, tests, code locations, refs; new web tier.
This commit is contained in:
@@ -205,7 +205,7 @@ data StripeOp = StripeInvoice | StripePaid InvoiceId | StripeManage -- Mana
|
||||
newtype InvoiceId = InvoiceId Text
|
||||
|
||||
data ServiceResponse
|
||||
= RspInvoice InvoiceRef -- Stripe Checkout URL, or Apple/Google store binding
|
||||
= RspInvoice InvoiceRef -- self-hosted Stripe pay-page URL, or Apple/Google store binding
|
||||
| RspCredential BadgeCredential -- reuse Simplex.Chat.Badges.BadgeCredential
|
||||
| RspStatus OrderStatus
|
||||
| RspPortal Url -- Stripe cancel-flow / management portal link
|
||||
@@ -214,8 +214,9 @@ newtype InvoiceRef = InvoiceRef Text
|
||||
newtype Url = Url Text
|
||||
|
||||
data OrderStatus = OrderStatus
|
||||
{ orderState :: BotOrderState, badgeIssued :: Bool -- badge for the current period issued?
|
||||
, paidThrough :: Maybe UTCTime, willRenew :: Bool } -- subscription status, re-derived per request
|
||||
{ orderState :: BotOrderState, badgeIssued :: Bool -- badge for the current period issued?
|
||||
, paidThrough :: Maybe UTCTime, willRenew :: Bool -- subscription status, re-derived per request
|
||||
, providerRef :: Maybe Text } -- Stripe: bot-signed recovery handle over cus_; else Nothing
|
||||
|
||||
data ServiceError = ServiceError { code :: ErrorCode, message :: Text, retryAfter :: Maybe NominalDiffTime }
|
||||
data ErrorCode
|
||||
@@ -236,7 +237,7 @@ Rules:
|
||||
- **Tier/period/expiry are server-derived; only `masterKey` is client-authoritative.** The bot resolves the tier from `badge_types[plan]`, sets `badgeExpiry = end_of_next_month`, `badgeExtra = ""`, and assembles the internal `BadgeRequest`. `badgeType` is never on the wire.
|
||||
- **Declared SKU must equal the verified SKU** proven by `Payment` (Apple/Google receipt `productId`, Stripe `plan`); divergence ⇒ `product_mismatch`.
|
||||
- Stripe stays event-driven: a `Purchase` with `StripePaid` holds the call until the webhook confirms, but only within `serviceResponseTimeout` (≤180 s); it responds once after verified payment + issuance, a terminal payment error, or the deadline (after which the client re-requests, §4).
|
||||
- Stripe `Cancel`/`Status` (`StripeManage`) return a portal URL in `RspPortal`; the bot never cancels silently. When the order can't be identified, it returns the account-wide portal login page.
|
||||
- Stripe `Cancel`/`Status` (`StripeManage`) return a portal URL in `RspPortal`; the bot never cancels silently. It maps the order (or the client's saved recovery handle) to `cus_` and returns an authenticated portal session — there is no email-OTP login page (no email is collected).
|
||||
- The `BadgeMasterKey` never enters Stripe metadata or a return URL.
|
||||
|
||||
### Internal interface
|
||||
@@ -261,7 +262,7 @@ Order:
|
||||
- Transport replay dedupe is separate and shorter-lived.
|
||||
- Stripe mutation idempotency key derives from the order key + operation.
|
||||
- Developer Tools → Chat Console records start/result, order-key suffix, operation, before/after states, retry class, and duration.
|
||||
- Redact JWS/token, Checkout query/return token, `BadgeMasterKey`, credential, and provider/customer IDs. (The order signer key is public; the signature is handled by the transport, not logged here.)
|
||||
- Redact JWS/token, `client_secret`, pay-page/return token, `BadgeMasterKey`, credential, recovery handle, and provider/customer IDs. (The order signer key is public; the signature is handled by the transport, not logged here.)
|
||||
|
||||
## 4. Provider flows
|
||||
|
||||
@@ -320,27 +321,42 @@ sequenceDiagram
|
||||
|
||||
Commit the order before acknowledgement/consume. RTDN triggers a provider GET; never issue from the notification payload.
|
||||
|
||||
### Stripe Checkout and waiting `Purchase`
|
||||
### Stripe self-hosted payment page (no email)
|
||||
|
||||
Stripe's **hosted Checkout mandates an email field** — it can be prefilled but not removed ([Checkout Session](https://docs.stripe.com/api/checkout/sessions/create)). Email is friction and PII we don't want, and it is not a usable recovery handle. We therefore **self-host the payment page** with the Stripe **Payment Element**, whose email field can be set to `never` ([control billing details](https://docs.stripe.com/payments/payment-element/control-billing-details-collection)), and the bot creates a Stripe **Customer with no email** ([email is optional](https://docs.stripe.com/api/customers/create)) tagged `metadata[order_key]`. The Customer id (`cus_…`) is the durable Stripe handle for the order — what the bot uses for the management portal and what the client backs up for Stripe recovery (§8). No email is ever collected.
|
||||
|
||||
Mechanics:
|
||||
|
||||
- On `Purchase(StripeInvoice)` the bot creates a **Customer** (`email` omitted, `metadata[order_key]=<order_key>`) and the payment object — a **Subscription** with `payment_behavior=default_incomplete`, expanding `latest_invoice.confirmation_secret` (and `pending_setup_intent`) for recurring, or a **PaymentIntent** for one-time ([build subscriptions with Elements](https://docs.stripe.com/payments/advanced/build-subscriptions)). It stores `cus_`(+`sub_`) as the order's `provider_ref` and returns `RspInvoice` = a URL to **our own pay page** (e.g. `https://pay.simplex.chat/o/<opaque session>`) — **never** the `client_secret`.
|
||||
- The pay page (served by the bot's web tier) loads Stripe.js + the **Payment Element** with `fields.billingDetails.email='never'` and **Link disabled** (Link would otherwise re-introduce an email field), and **fetches the `client_secret` from a backend endpoint** keyed by the opaque session — Stripe forbids putting a `client_secret` in a URL or logs. The payer enters card details only; `stripe.confirmPayment`/`confirmSetup` completes it. `return_url` shows status and an optional deep link back to the app.
|
||||
- Payment completes → **signed webhook** (`invoice.paid` / `payment_intent.succeeded` / `customer.subscription.updated`) → the bot re-fetches via API, matches by `metadata[order_key]`, and issues into the ledger. The held `Purchase(StripePaid)` wakes exactly as before.
|
||||
|
||||
Only the **publishable key** and the payer-scoped `client_secret` reach the browser; the **secret key** stays server-side (config.yaml). The opaque pay-session id is unguessable and single-order-scoped. The pay page and its `client_secret` endpoint are new attack surface: HTTPS-only, rate-limited, no secrets in the page, no `client_secret` in URLs/logs.
|
||||
|
||||
**Customer handle to the client.** The bot returns a **recovery handle** — a bot-signed opaque token over `cus_` (not the raw id) — in `OrderStatus.providerRef`; the client stores it in `provider_ref` and backs it up. It is a cancel/manage credential (§8), so the client keeps it encrypted at rest.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
participant W as Pay page
|
||||
participant S as Stripe
|
||||
Note over C: CORequesting
|
||||
C->>B: Purchase (StripeInvoice), create order
|
||||
B->>S: Create Checkout Session
|
||||
S-->>B: Session ID + URL
|
||||
Note over B: BORequesting (checkout open)
|
||||
B-->>C: RspInvoice (Checkout URL)
|
||||
Note over C: CORequesting (checkout ready)
|
||||
C->>S: Open Checkout
|
||||
B->>S: Create Customer (no email) + Subscription/PaymentIntent
|
||||
S-->>B: cus_ / sub_ + client_secret
|
||||
Note over B: BORequesting — store cus_ as provider_ref
|
||||
B-->>C: RspInvoice (self-hosted pay-page URL)
|
||||
Note over C: CORequesting (pay page ready)
|
||||
C->>W: Open pay page (Payment Element, no email)
|
||||
W->>B: Fetch client_secret (backend, not via URL)
|
||||
W->>S: confirmPayment / confirmSetup
|
||||
Note over C: COPaid
|
||||
C->>B: Purchase (StripePaid), same key
|
||||
B->>B: Register waiter and recheck payment under lock
|
||||
Note over B: BORequesting<br/>No response yet
|
||||
S-->>B: Signed webhook
|
||||
B->>S: Retrieve current payment
|
||||
S-->>B: Signed webhook (invoice.paid / intent succeeded)
|
||||
B->>S: Retrieve current payment (match metadata[order_key])
|
||||
S-->>B: Canonical paid payment
|
||||
B->>B: Verify key, sign badge into ledger, wake waiter
|
||||
Note over B: BOCompleted
|
||||
@@ -349,11 +365,11 @@ sequenceDiagram
|
||||
Note over C: COCompleted
|
||||
```
|
||||
|
||||
The second `Purchase` (`StripePaid`) has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the order lock prevents a webhook/request race. If the webhook completed first, the `Purchase` responds immediately.
|
||||
The second `Purchase` (`StripePaid`) has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as intent/session expiry. Register-and-recheck under the order lock prevents a webhook/request race. If the webhook completed first, the `Purchase` responds immediately.
|
||||
|
||||
The hold is bounded by `serviceResponseTimeout` (≤180 s). A `checkout.session.completed` webhook normally lands within seconds, so the common case answers on the held call. If Stripe retrieval fails transiently after the webhook, the bot retries internally within that window and sends no intermediate response. If the window elapses first (or payment is still pending), the call ends without a badge and the client re-requests the same signed `Purchase` on foreground (see wait interruption below) — genuinely async/long-settlement payments always take this path.
|
||||
The hold is bounded by `serviceResponseTimeout` (≤180 s). The `invoice.paid`/`payment_intent.succeeded` webhook normally lands within seconds, so the common case answers on the held call. If Stripe retrieval fails transiently after the webhook, the bot retries internally within that window and sends no intermediate response. If the window elapses first (or payment is still pending), the call ends without a badge and the client re-requests the same signed `Purchase` on foreground (see wait interruption below) — genuinely async/long-settlement payments always take this path.
|
||||
|
||||
Persist the order (`BORequesting`) and any issued `badges` row; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after the badge row is durable. After bot restart, the repeated `Purchase` rechecks persisted order state (and re-derives payment from the provider) and either returns the cached credential immediately or installs a new waiter.
|
||||
Persist the order (`BORequesting`), `cus_`/`sub_`, and any issued `badges` row; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after the badge row is durable. After bot restart, the repeated `Purchase` rechecks persisted order state (and re-derives payment from the provider) and either returns the cached credential immediately or installs a new waiter.
|
||||
|
||||
### Stripe wait interruption
|
||||
|
||||
@@ -376,7 +392,7 @@ sequenceDiagram
|
||||
B-->>C: Credential immediately if ready<br/>otherwise hold this call
|
||||
```
|
||||
|
||||
The client persists `orderSk` and `BadgeMasterKey` before opening Checkout. It retries the same signed `Purchase` only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
|
||||
The client persists `orderSk` and `BadgeMasterKey` before opening the pay page. It retries the same signed `Purchase` only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
|
||||
|
||||
### Cancellation
|
||||
|
||||
@@ -384,18 +400,19 @@ The client persists `orderSk` and `BadgeMasterKey` before opening Checkout. It r
|
||||
|---|---|---|---|
|
||||
| Apple | open Apple management UI; status RPC on return | App Store Server API status | `willRenew=false` |
|
||||
| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `willRenew=false` |
|
||||
| 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 | `willRenew=false` |
|
||||
| Stripe | open a browser Customer Portal from a bot-provided link | return an authenticated per-`cus_` portal session; the portal performs the cancel, reconciled via `customer.subscription.updated` webhook | `willRenew=false` |
|
||||
|
||||
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:
|
||||
**Stripe cancel-link selection.** Cancellation is always in the browser portal; the bot returns an authenticated per-`cus_` session, sourced from whichever credential the client still holds:
|
||||
|
||||
| Client presents | Portal link the bot returns |
|
||||
|---|---|
|
||||
| a `Cancel` (`StripeManage`) whose signer key matches the order's `orderKey` | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code |
|
||||
| no identifiable order (total loss — `orderSk` gone) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP |
|
||||
| a `Cancel` (`StripeManage`) whose signer key matches the order's `orderKey` | authenticated `billing_portal.Session` for the order's `cus_` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no login |
|
||||
| total loss (`orderSk` gone) but the client saved its recovery handle | the bot verifies the handle, maps it to `cus_`, and returns the same authenticated session |
|
||||
| neither the key nor the handle survives | no cancel path — nothing binds the client to `cus_` (no email was collected) |
|
||||
|
||||
The authenticated session link is short-lived and per-customer; the login page is the operator-config account-wide URL and returns no per-customer secret. The bot carries whichever link applies in `RspStatus` so a cancel path is always reachable. Signing for the order (holding `orderSk`, verified by the transport) is the sole client credential: the client either can sign as the order's `orderKey` (session) or cannot (login page).
|
||||
Because we collected no email, **there is no email-OTP login page**; every reachable cancel path is an authenticated per-`cus_` `billing_portal.Session` (short-lived, opens straight to cancel with no login — [portal session](https://docs.stripe.com/api/customer_portal/sessions/create)). The bot creates it on demand and returns the URL in `RspPortal`. The client's credential is either the order key (signed request) or the saved recovery handle; without one of them, `cus_` is unreachable.
|
||||
|
||||
## 5. Persistence
|
||||
|
||||
@@ -449,7 +466,7 @@ reconcile(order):
|
||||
coalesce to one worker
|
||||
render cached order state + installed badge
|
||||
submit a signed Purchase for unseen Apple/Google receipts
|
||||
for a pending Stripe checkout: ensure one Purchase(StripePaid) is waiting
|
||||
for a pending Stripe payment: ensure one Purchase(StripePaid) is waiting
|
||||
otherwise send Status for a non-completed or renewing order
|
||||
if a credential returned but its badges row is not installed: verify + install (set installed)
|
||||
schedule next check
|
||||
@@ -485,7 +502,7 @@ Every input is one of:
|
||||
| cancel timeout | retry | keep state; still show Renews | preserve order; renewal unchanged |
|
||||
| already canceled | idempotent | show renewal off | report `willRenew=false` |
|
||||
| user cancels store | exit | restore prior state | `BORequesting` expires later |
|
||||
| Stripe Checkout expired | final attempt | new checkout on user action | no badge; reissue invoice on next request |
|
||||
| Stripe pay page / intent expired | final attempt | new pay page on user action | no badge; new intent on next request |
|
||||
| refund/revocation | apply | signed badge survives to expiry | stop future issuance; report not entitled |
|
||||
| webhook DB failure | retry delivery | no transition | no transition; non-2xx |
|
||||
|
||||
@@ -507,7 +524,7 @@ Stable codes: `bad_request`, `unsupported_version`, `order_auth_invalid`, `produ
|
||||
|---|---|---|---|---|
|
||||
| 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 | browser portal (bot-provided link) |
|
||||
| Stripe | retrieve PaymentIntent/Invoice/Subscription | one-time PaymentIntent; subscription paid invoice | signed webhook → re-fetch | browser portal (per-`cus_` session) |
|
||||
|
||||
Provider status is **derived on each request** and reported via `OrderStatus`, never stored as an order state:
|
||||
|
||||
@@ -519,7 +536,7 @@ Provider status is **derived on each request** and reported via `OrderStatus`, n
|
||||
| renewal off, time remaining | entitled to `paid_through`; `willRenew=false` |
|
||||
| expired | not entitled; no new issuance |
|
||||
| refund / chargeback / revoke | not entitled; stop future issuance (an issued badge stands to expiry) |
|
||||
| Stripe checkout open / async pending | order `BORequesting`; awaiting webhook |
|
||||
| Stripe pay page open / async pending | order `BORequesting`; awaiting webhook |
|
||||
| paid one-time / paid subscription invoice | issue the eligible period → `BOCompleted` |
|
||||
|
||||
Google linked-token replacement changes subscription identity/period data, then reports the retrieved state per this table.
|
||||
@@ -527,10 +544,10 @@ Google linked-token replacement changes subscription identity/period data, then
|
||||
Rules:
|
||||
|
||||
- Google initial subscription acknowledgement and one-time consumption run from durable retry (the auxiliary `provider_events` bookkeeping), not from order state.
|
||||
- Stripe uses server-selected Price (from `badge_types`/`stripe.plans[plan]`), mode, Customer, `client_reference_id=order_key` (so the webhook maps back to the order), metadata, redirect URLs, and collects customer email so the hosted portal login works.
|
||||
- Stripe payment is on a **self-hosted Payment Element page** (§4), not hosted Checkout: the bot creates a **Customer with no email** tagged `metadata[order_key]`, a server-selected Price (from `badge_types`/`stripe.plans[plan]`), and a Subscription (`default_incomplete`) or PaymentIntent; webhooks map back by `metadata[order_key]`. Only the publishable key + payer-scoped `client_secret` reach the browser.
|
||||
- Stripe issuance requires a paid invoice, not merely active Subscription status.
|
||||
- Webhook/status/completion page use one reconciliation function; redirects never fulfill.
|
||||
- All Stripe cancellation, invoices, and payment methods go through the browser Customer Portal — an authenticated `billing_portal.Session` when the customer is identifiable, else the account-wide login page (email OTP) which is also the app-removed path; the bot reconciles portal cancellation from the webhook. Apple/Google normal cancellation is store UI.
|
||||
- All Stripe cancellation, invoices, and payment methods go through the browser Customer Portal — always an authenticated per-`cus_` `billing_portal.Session` (the order key or the client's saved recovery handle maps to `cus_`); there is no email-OTP page, since no email is collected. The bot reconciles portal cancellation from the webhook. Apple/Google normal cancellation is store UI.
|
||||
|
||||
## 8. Recovery
|
||||
|
||||
@@ -559,7 +576,7 @@ SimpleX encrypted-profile backup or migration restores `order_sk`, `BadgeMasterK
|
||||
|
||||
This requires the bot to let a *new* order claim an entitlement a prior order already bound. The `orders.provider_ref` binding (looked up by `(provider, provider_ref)`) permits re-bind to a new order on a fresh verified receipt, **capped per (provider object, period) and rate-limited** (BBS badges can't be revoked, so bound the over-issue).
|
||||
|
||||
**Stripe has no tier-2 badge recovery** — there is no client-side re-presentable receipt. The hosted portal login (email OTP) can only cancel; a new badge requires a new order/purchase.
|
||||
**Stripe has no tier-2 badge recovery** — there is no client-side re-presentable receipt. If the client saved its **recovery handle** (§4), it can still cancel/manage the subscription (handle → `cus_` → authenticated portal session, no email); but a new badge still requires a new order/purchase. Without the handle, `cus_` is unreachable.
|
||||
|
||||
### Badge re-issuance
|
||||
|
||||
@@ -586,7 +603,7 @@ Issuance is idempotent on `(order, period, master-key hash)`: within one order t
|
||||
|
||||
1. **Schema/protocol:** two order-state sums/codecs, migrations, issuance ledger, Chat Console audit, core install API.
|
||||
2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI.
|
||||
3. **Stripe:** Checkout, waiting `Purchase(StripePaid)`, webhook wake-up, reconciliation, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation.
|
||||
3. **Stripe:** self-hosted Payment Element pay page + `client_secret` endpoint, Customer (no email)/Subscription/PaymentIntent, waiting `Purchase(StripePaid)`, webhook wake-up + `metadata[order_key]` match, per-`cus_` portal session (cancel), recovery handle.
|
||||
4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup.
|
||||
|
||||
Tests:
|
||||
@@ -596,6 +613,7 @@ Tests:
|
||||
- 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;
|
||||
- Stripe self-hosted flow: Payment Element confirms with no email (Link disabled); `client_secret` served only from the backend endpoint (never in the URL); webhook matched by `metadata[order_key]`; recovery handle → `cus_` → portal session; secret key never reaches the page;
|
||||
- response-deadline (`serviceResponseTimeout` ≤180 s) elapsing mid-wait → client re-requests the same signed `Purchase`; non-DR address (`ASENotDRAddress`) and processing-off (silent drop → `ASETimeout`) prerequisites;
|
||||
- monthly/yearly issuance periods and 21 July → 31 August expiry;
|
||||
- crash/replay at every side-effect boundary;
|
||||
@@ -617,7 +635,8 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
|
||||
| bot badge signing | ledger-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/stripe_api.py` | Customer (no email)/Subscription/PaymentIntent, webhook match by `metadata[order_key]`, cancel via per-`cus_` portal session, recovery-handle sign/verify |
|
||||
| `badge-service` web tier (new) | self-hosted pay page (Payment Element, email off, Link off) + `client_secret` endpoint keyed by the opaque pay-session; publishable key only |
|
||||
| `badge-service/wire.py` | versioned call/response; keep existing badge request compatibility during rollout |
|
||||
|
||||
## 11. API references
|
||||
@@ -626,11 +645,11 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
|
||||
|---|---|
|
||||
| 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), [hosted portal login](https://docs.stripe.com/customer-management/activate-no-code-customer-portal) |
|
||||
| Stripe | [Payment Element](https://docs.stripe.com/payments/payment-element), [subscriptions with Elements](https://docs.stripe.com/payments/advanced/build-subscriptions), [suppress email (`never`)](https://docs.stripe.com/payments/payment-element/control-billing-details-collection), [create Customer (email optional)](https://docs.stripe.com/api/customers/create), [webhooks](https://docs.stripe.com/webhooks), [subscription events](https://docs.stripe.com/billing/subscriptions/webhooks), [cancel](https://docs.stripe.com/billing/subscriptions/cancel), [portal session](https://docs.stripe.com/api/customer_portal/sessions/create) |
|
||||
| RPC | service RPC in `simplex-chat` branch `rpc` (merged `d2b63cd46`; `plans/2026-07-22-service-rpc-chat.md`). Bot side: `CEvtServiceRequest {signerKey :: Maybe C.PublicKeyEd25519, requestData}` → `APISendServiceResponse` → `CRServiceReplyAccepted {connectionId}` → `CEvtServiceReplySent {connectionId}`. Requester: `APISendServiceRequest {signKey :: Maybe (C.StoredPrivateKey 'Ed25519), requestTimeout}`. Agent-side Ed25519 sign+verify (binding `sha3_256("SimpleXService" <> rcAD)`), errors `ASETimeout`/`ASENotDRAddress`/`ASEBadSignature`; simplexmq pin `a82b487a` |
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
**Stripe total loss.** Recovery §8 tier 2 shows Apple/Google recover after a full wipe (store re-presents the entitlement) but Stripe cannot — the hosted portal login only cancels, and a new badge needs a new order. Decision: accept this, or add an optional user-held recovery code as a second Stripe order-recovery credential.
|
||||
**Stripe total loss.** Management/cancel after a full wipe is now handled by the client-held **recovery handle** (§4) → `cus_` → authenticated portal session (no email). Stripe still has **no badge re-issue** after total loss (no re-presentable receipt), unlike Apple/Google — a new badge needs a new order. Open: the handle format (bot-signed token vs raw `cus_`) and its rotation/revocation; whether to offer a bot-side account-recovery flow for the badge itself.
|
||||
|
||||
**Re-issue cap value.** A per-(provider object, period) re-issue counter bounds tier-2 over-issue. Decision: pick the cap (e.g. 2–3 per period) and the rate-limit window.
|
||||
|
||||
@@ -25,7 +25,7 @@ An **order** carries a plan (the product) and a payment. A verified payment issu
|
||||
|---|---|---|
|
||||
| iOS | StoreKit | Apple subscription UI |
|
||||
| Android Play | Play Billing | Google Play subscription UI |
|
||||
| F-Droid / desktop | Stripe Checkout | browser Customer Portal — authenticated session, or login page (email code) after app removal |
|
||||
| F-Droid / desktop | our own Stripe payment page (Payment Element, no email) | browser Customer Portal — authenticated session via order key or saved recovery handle |
|
||||
|
||||
Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
|
||||
|
||||
@@ -211,9 +211,9 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
C->>B: Purchase (StripeInvoice), create order
|
||||
B->>S: Create Checkout Session
|
||||
B-->>C: RspInvoice (Checkout URL)
|
||||
C->>S: Open Checkout
|
||||
B->>S: Create Customer (no email) + payment
|
||||
B-->>C: RspInvoice (our pay-page URL)
|
||||
C->>S: Open our pay page (Payment Element, no email)
|
||||
Note over C: Paid, awaiting confirmation
|
||||
C->>B: Purchase (StripePaid), same order
|
||||
Note over B: Hold call, no response yet
|
||||
@@ -244,7 +244,7 @@ sequenceDiagram
|
||||
Note over B: Respond immediately if webhook already completed<br/>otherwise wait again
|
||||
```
|
||||
|
||||
#### Checkout expired
|
||||
#### Payment page expired
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -253,13 +253,13 @@ sequenceDiagram
|
||||
participant S as Stripe
|
||||
C->>B: Purchase (StripePaid)
|
||||
Note over B: Hold call
|
||||
S-->>B: Signed Checkout expired event
|
||||
Note over B: Checkout expired, no badge
|
||||
B-->>C: RspError checkout expired
|
||||
Note over C: Expired — new Checkout requires user action
|
||||
S-->>B: Signed intent/session expired event
|
||||
Note over B: Payment page expired, no badge
|
||||
B-->>C: RspError payment expired
|
||||
Note over C: Expired — new payment page requires user action
|
||||
```
|
||||
|
||||
There is no payment polling. The pending `Purchase` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof.
|
||||
There is no payment polling. The pending `Purchase` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof. Stripe payment is on our own page (Stripe Payment Element) so no email is collected — the hosted Stripe Checkout, which forces an email field, is not used.
|
||||
|
||||
### Cancel subscription
|
||||
|
||||
@@ -305,8 +305,8 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant P as Stripe Portal
|
||||
participant S as Stripe API
|
||||
C->>B: Request cancel link
|
||||
B-->>C: Portal URL (session or login page)
|
||||
C->>B: Request cancel link (signed, or with saved recovery handle)
|
||||
B-->>C: Authenticated portal session URL
|
||||
C->>P: Open portal, confirm cancel
|
||||
P->>S: Cancel at period end
|
||||
S-->>B: Signed webhook (renewal off + end date)
|
||||
@@ -316,14 +316,15 @@ sequenceDiagram
|
||||
|
||||
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.
|
||||
Cancellation also works after the app is removed: Apple/Google via the store subscription UI; Stripe **only if the user saved their recovery handle** (we collect no email, so there is no email login). 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:
|
||||
Stripe cancellation always happens in the browser Customer Portal (the portal cancels; the bot reconciles from the webhook). The bot always returns an authenticated per-customer session, sourced from whatever the client can still prove:
|
||||
|
||||
| Client still holds | Cancel link the bot sends |
|
||||
|---|---|
|
||||
| the order (can sign with its order key) | authenticated portal session — opens straight to the cancel flow, no email code |
|
||||
| nothing (order lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) |
|
||||
| the order (can sign with its order key) | authenticated portal session — opens straight to the cancel flow, no login |
|
||||
| only the saved recovery handle (order lost with the app) | same authenticated session, via handle → customer |
|
||||
| neither | no cancel path — nothing binds them to the customer (no email was collected) |
|
||||
|
||||
## 5. Refresh and errors
|
||||
|
||||
@@ -356,6 +357,6 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
|
||||
- Client and bot order/badge state are separate.
|
||||
- Requests are client-signed with the order key (which also identifies the order), one bot response each; the per-period badge ledger dedups repeats.
|
||||
- Tier and billing period are fixed by the plan (SKU); the client cannot request a higher tier or longer life than it paid for.
|
||||
- Stripe needs no localhost/deep-link success; cancellation is always via the browser Customer Portal — an authenticated session, or the login page when the client cannot identify the payment.
|
||||
- Stripe payment is on our own page (Payment Element, no email), not hosted Checkout; needs no localhost/deep-link success. Cancellation is always an authenticated per-customer Customer Portal session, reached via the order key or a saved recovery handle (no email login).
|
||||
- Every error category has an owner, state-preserving action, and retry/final result.
|
||||
- RPC attempts/results appear redacted in Developer Tools → Chat Console.
|
||||
|
||||
Reference in New Issue
Block a user