docs: finalize supporter badge v2 lifecycle plans
@@ -1,291 +1,263 @@
|
||||
# Supporter Badges v2 — Product Plan
|
||||
# Supporter Badges v2 — Product and UX Plan
|
||||
|
||||
> What a paying supporter sees and does: buy a badge on Apple / Google / Stripe, receive it over chat, and keep it renewed — with status and cancellation handled entirely on-device.
|
||||
**Date:** 2026-07-21
|
||||
**Status:** implementation-ready
|
||||
**Companion:** [Implementation plan](2026-07-20-supporter-badges-v2-implementation.md)
|
||||
|
||||
> **Companion:** [Implementation Plan](2026-07-20-supporter-badges-v2-implementation.md) — wire protocol, provider APIs, client state model, error catalog, and roadmap. This doc owns UX and product rules; it cross-refs the Implementation Plan for anything technical.
|
||||
A badge is a short-lived signed credential. A payment is a separate provider entitlement. The UI combines both, but never treats payment status as proof that a badge is valid.
|
||||
|
||||
> **v2 of** [`2026-06-01-supporter-badges-v1.md`](2026-06-01-supporter-badges-v1.md) (which shipped badge *verification* only). This plan covers the commercial lifecycle: purchase, issuance, renewal, cancellation.
|
||||
|
||||
**Date:** 2026-07-20 · **Status:** draft for review
|
||||
|
||||
> Diagrams use Mermaid + SVG (a deliberate deviation from the ASCII-only house style, per request). SVG sources live in [`assets/`](assets/).
|
||||
|
||||
---
|
||||

|
||||
|
||||
## Contents
|
||||
|
||||
- [1. Overview & Scope](#1-overview--scope)
|
||||
- [2. How Badges Work](#2-how-badges-work)
|
||||
- [3. Screens](#3-screens)
|
||||
- [4. End-to-End Journey](#4-end-to-end-journey)
|
||||
- [5. Product Rules](#5-product-rules)
|
||||
- [6. Provider Capability Summary](#6-provider-capability-summary)
|
||||
- [7. Open Product Decisions](#7-open-product-decisions)
|
||||
- [1. Rules](#1-rules)
|
||||
- [2. UX states](#2-ux-states)
|
||||
- [3. Badge screen](#3-badge-screen)
|
||||
- [4. Purchase and cancellation flows](#4-purchase-and-cancellation-flows)
|
||||
- [5. Refresh and notification](#5-refresh-and-notification)
|
||||
- [6. Error UX](#6-error-ux)
|
||||
- [7. Acceptance criteria](#7-acceptance-criteria)
|
||||
|
||||
---
|
||||
## 1. Rules
|
||||
|
||||
## 1. Overview & Scope
|
||||
### 1.1 Payment rail
|
||||
|
||||
### 1.1 What
|
||||
| Build | Rail | Purchase | Cancel/manage |
|
||||
|---|---|---|---|
|
||||
| iOS | Apple | StoreKit 2 sheet | `showManageSubscriptions`; App Store fallback |
|
||||
| Android Play | Google | Play Billing sheet | Play subscription-management UI |
|
||||
| Android non-Play / desktop | Stripe | hosted Checkout | bot cancellation RPC only |
|
||||
|
||||
Supporter Badges **v1** shipped verification only: clients can display and cryptographically verify badges, but there is no way to *buy* one. **v2** productizes the full commercial lifecycle:
|
||||
The build chooses the rail. Apple/Google purchases are prepared with an opaque provider account binding before the native sheet opens; this prevents a forwarded receipt/token from being attached to another prepared payment. Store-policy approval for Stripe digital purchases is a release gate for every distributed build and region.
|
||||
|
||||
> **purchase → issue → present → renew → cancel / lapse**
|
||||
### 1.2 Dates
|
||||
|
||||
across three payment rails. **One rail per platform — never a user choice of provider:**
|
||||
Badge and billing use different clocks:
|
||||
|
||||
| Platform | Rail | Purchase surface |
|
||||
| Event | Billing date | Badge validity |
|
||||
|---|---|---|
|
||||
| iOS | Apple (StoreKit 2) | in-app |
|
||||
| Android — Play build | Google (Play Billing) | in-app |
|
||||
| Android — F-Droid / no-GMS | **Stripe** | browser (Checkout + portal) |
|
||||
| Desktop | **Stripe** | browser (Checkout + portal) |
|
||||
| Monthly subscription starts **21 July** | next payment **21 August** | valid through **31 August**; expires `1 September 00:00 UTC` |
|
||||
| Monthly renewal / yearly monthly slot **21 August** | monthly bills **21 September**; yearly still bills **21 July next year** | new badge valid through **30 September**; expires `1 October 00:00 UTC` |
|
||||
| Cancel monthly before 21 August | subscription ends **21 August** | already-issued badge remains valid through **31 August** |
|
||||
| Cancel yearly after 21 July payment | subscription ends **21 July next year** | monthly badge slots continue through the paid annual period |
|
||||
|
||||
Desktop has no Apple/Google in-app purchase. Android ships in **two build flavors with different rails**: the **Play build** uses Play Billing; the **F-Droid / no-GMS build** — which can't bundle proprietary Play Billing — uses **Stripe** browser checkout, the same rail as desktop. So Stripe serves desktop *and* the F-Droid Android build.
|
||||
Rules:
|
||||
|
||||
### 1.2 Why
|
||||
- Subscription billing is monthly or yearly and follows the provider’s actual `currentPeriodEnd`.
|
||||
- Badge issuance remains monthly for both subscription intervals. Each monthly slot uses `endOfNextMonth(slotStart)`; billing and badge clocks remain separate.
|
||||
- After cancellation, label the billing date **Subscription ends on**, not **Renews on**.
|
||||
- Perks require a cryptographically active badge. Payment status alone never unlocks them.
|
||||
|
||||
The crypto, credential format, trust anchors, and proof presentation are done and stable. What is missing is everything a paying user touches: a purchase flow, a subscription that auto-renews, a way to see and cancel it, and automatic re-issuance when a period rolls over. Without this, badges cannot ship as a revenue feature.
|
||||
### 1.3 Purchase type
|
||||
|
||||
### 1.3 For Whom
|
||||
- The purchase choices are exactly: **One-time**, **Monthly subscription**, and **Yearly subscription**.
|
||||
- One-time buys one non-renewing badge period and does not stack. Buy once becomes available again after that badge expires.
|
||||
- Monthly and yearly subscriptions renew until canceled. Both create monthly badge issuance slots while the provider reports paid entitlement.
|
||||
- Subscribing from an active one-time badge starts a normal monthly/yearly purchase flow. There is no conversion API; the existing badge remains until the subscription badge installs.
|
||||
- Cancellation stops future renewal; already-paid access remains until the provider period end. v2 does not revoke an already-issued badge after refund/revocation; short badge expiry bounds the exposure.
|
||||
|
||||
Paying supporters on **iOS, Android, and desktop**. Every UI surface and the reconciliation loop are client-side — **the client drives everything**. The bot is anonymous and **reactive**: it verifies payment and issues a badge for the current period only when the client asks (on startup and each billing period). It never pushes, background-polls, or uses webhooks; for Stripe it keeps the subscription id per contact so it can re-check on request.
|
||||
### 1.4 Truth and privacy
|
||||
|
||||
### 1.4 Scope
|
||||
- Credential signature + expiry are authoritative for badge validity.
|
||||
- Bot/provider verification is authoritative for payment and issuance eligibility.
|
||||
- StoreKit/Play local reads are fast UI hints only.
|
||||
- The bot binds provider purchases to a prepared payment capability. RPC supplies no persistent caller identity. The bot stores opaque provider IDs/tokens, never payment-card details.
|
||||
- Receipts, tokens, provider IDs, and master secrets never appear in logs or user errors.
|
||||
|
||||
| In scope | Non-goals |
|
||||
|---|---|
|
||||
| Purchase flows (Apple / Google / Stripe) | Bot-side status/cancel commands — client-local instead |
|
||||
| Client-local subscription **status** (read from provider) | Badge **revocation** / revocation lists (badges expire, never revoked) |
|
||||
| Client-local **cancel** (App Store / Play UI, Stripe portal) | Apple/Google server push as *primary* renewal signal — optional only |
|
||||
| Client-driven **renewal** (re-check on startup + each billing period) | New badge **types** or tier logic (issuer-fixed) |
|
||||
| Client-local lifecycle **state** + reconciliation loop | Multi-device credential sync (each device its own secret, unlinkable by design) |
|
||||
| Minimal bot protocol additions (see Implementation Plan) | Changing the core `BadgeStatus` enum |
|
||||
## 2. UX states
|
||||
|
||||
**Renewal is client-driven on every rail** (see §5.4): on app startup and each billing period the app asks the bot to check the current period and re-issue. Apple/Google re-present their on-device receipt; Stripe asks the bot to re-check its stored subscription. The bot never reaches out on its own.
|
||||
The view state is derived from the persisted payment machine, badge machine, and current operation.
|
||||
|
||||
### 1.5 Relationship to v1
|
||||
|
||||
This is **v2 of** [`2026-06-01-supporter-badges-v1.md`](2026-06-01-supporter-badges-v1.md) (verification only). That v1 plan is **stale vs shipped code** on several load-bearing points — trust the code, not the v1 doc:
|
||||
|
||||
| v1 plan says | Shipped code |
|
||||
|---|---|
|
||||
| badge types `supporter` / `business` / `legend` / `cf_investor` | `supporter` / `legend` / `investor` (+ `unknown` tag) |
|
||||
| 3 signed messages `[ms, expiry, level]` | **4** messages `[masterKey, expiry, type, extra]`, `extra` reserved `""` |
|
||||
| single hardcoded server pubkey | **8** issuer pubkeys (idx 1–8); credential carries `badgeKeyIdx` |
|
||||
| badge stored on contact **and** group-member profiles | badge columns on `contact_profiles` **only**; one badge per profile |
|
||||
|
||||
Subscription lifecycle (renewing, grace, canceled-but-active, pending) has **no representation in the core** — the core `BadgeStatus` is a fixed fieldless enum. All of v2's lifecycle therefore lives client-local, layered over the unchanged core badge (see the Implementation Plan, §Client State Model).
|
||||
|
||||
---
|
||||
|
||||
## 2. How Badges Work
|
||||
|
||||
A badge answers one question — *"do I have a valid proof to show peers?"* — while a separate **entitlement** (read locally from the store) answers *"am I still paying / covered?"*. They can disagree (e.g. paid but the credential hasn't been delivered yet), so the app tracks both and shows one of nine states, S0–S8.
|
||||
|
||||
### 2.1 Three user variants → nine states
|
||||
|
||||
The three headline user cases map onto the state set as:
|
||||
|
||||
- **Variant 1 — no badge yet:** `S0 NoBadge` (buy), plus check-even-without-badge handled by `S8 RecoverNeeded`.
|
||||
- **Variant 2 — one-time purchase:** `S1 ActiveOneTime` (extend / upgrade-to-subscription).
|
||||
- **Variant 3 — subscription:** `S2 ActiveSubscription`, `S3 SubCanceledActive`, `S4 GraceOrBillingRetry`.
|
||||
|
||||
Terminal/transitional states shared by all variants: `S5 Expired`, `S6 PendingPayment`, `S7 PendingIssuance`, `S8 RecoverNeeded`.
|
||||
|
||||
### 2.2 States
|
||||
|
||||
| State | Badge shown to peers | Primary actions |
|
||||
|---|---|---|
|
||||
| **S0** NoBadge | hidden | Choose plan → buy |
|
||||
| **S1** ActiveOneTime | full (tier + expiry) | Extend (buy period); Subscribe (new sub purchase) |
|
||||
| **S2** ActiveSubscription | full (tier, expiry, next renewal) | Cancel (store UI / Stripe portal) |
|
||||
| **S3** SubCanceledActive | full + "active until <periodEnd>" | Resubscribe |
|
||||
| **S4** GraceOrBillingRetry | dim or full | Fix payment method → deep-link store/portal |
|
||||
| **S5** Expired | dim ≤31d, then hidden | Renew; Subscribe |
|
||||
| **S6** PendingPayment (Stripe) | prior | Open checkout link; "waiting" (timeout → prior) |
|
||||
| **S7** PendingIssuance | prior | Spinner "finalizing"; resolves on credential/error |
|
||||
| **S8** RecoverNeeded | none | auto re-request → S7; if app too old → "Update app" |
|
||||
|
||||
Notes:
|
||||
- **S4** is an **overlay** on S2/S3 (a payment problem while nominally subscribed). Badge stays valid during grace only; if the payment problem outlasts the badge's coarse expiry, the badge lapses and its perk is lost — even while the store still reports grace/retry (see §5.4).
|
||||
- **S8** covers "check even when no badge is present yet": entitlement is active but there is no valid local proof, so the app silently re-requests issuance. If the credential was signed by an issuer key this app version doesn't know, it's unrecoverable in-app → prompt update.
|
||||
- **One-time (S1) never auto-renews:** at expiry it goes to S5; **Extend** is a new purchase (see §5.3).
|
||||
|
||||
### 2.3 Lifecycle at a glance
|
||||
|
||||
Simplified lifecycle (S6 PendingPayment + S7 PendingIssuance shown as one **Pending**; the SVG below has the full detail including the Stripe two-step and S8 auto-recovery).
|
||||
| State | Condition | Display | Actions |
|
||||
|---|---|---|---|
|
||||
| **No badge** | no entitlement; no active badge | one-time, monthly, yearly prices | Buy once, Subscribe monthly/yearly |
|
||||
| **Payment pending** | provider not yet paid/approved | old badge if still valid | Continue payment, Check again |
|
||||
| **Paid, issuing** | entitled; issue in progress | old badge + progress | automatic retry; Retry after delay |
|
||||
| **Active one-time** | active badge + one-time payment | tier; badge expiry | Subscribe monthly/yearly |
|
||||
| **Active subscription** | active badge + `willRenew` | Monthly/Yearly; badge expiry; renewal date | Cancel subscription |
|
||||
| **Canceled, active** | active badge; renewal off; future period end | badge expiry; ends date | Resume/Resubscribe |
|
||||
| **Payment issue** | grace/retry/on-hold/past-due | valid badge until its own expiry | Fix payment, Check again |
|
||||
| **Entitled, badge missing** | paid period; no usable badge | temporarily unavailable | automatic issue, Retry |
|
||||
| **Expired** | no entitlement; no active badge | expired badge per core retention rule | Buy once, Subscribe monthly/yearly |
|
||||
| **Needs update** | unknown issuer key/protocol | badge unavailable | Update app |
|
||||
| **Offline/stale** | refresh failed; cache available | last known state + check time | Retry |
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
direction LR
|
||||
state "No badge (S0)" as No
|
||||
state "Pending (S6/S7)" as Pend
|
||||
state "Active: one-time (S1)" as One
|
||||
state "Active: subscription (S2)" as Sub
|
||||
state "Canceled, still active (S3)" as Cxl
|
||||
state "Payment issue (S4)" as Grace
|
||||
state "Expired (S5)" as Exp
|
||||
|
||||
[*] --> No
|
||||
No --> Pend: buy
|
||||
Pend --> One: issued, one-time
|
||||
Pend --> Sub: issued, subscription
|
||||
|
||||
One --> Pend: Extend / Subscribe
|
||||
One --> Exp: expires
|
||||
|
||||
Sub --> Pend: monthly renewal
|
||||
Sub --> Cxl: user cancels
|
||||
Sub --> Grace: payment fails
|
||||
Grace --> Sub: payment fixed
|
||||
Grace --> Exp: unrecovered
|
||||
Cxl --> Sub: resubscribe
|
||||
Cxl --> Exp: period ends
|
||||
|
||||
Exp --> Pend: Renew / Subscribe
|
||||
[*] --> NoBadge
|
||||
NoBadge --> Pending: buy / subscribe
|
||||
Pending --> Issuing: provider confirms paid
|
||||
Pending --> NoBadge: canceled / abandoned
|
||||
Issuing --> OneTime: one-time credential installed
|
||||
Issuing --> Subscription: subscription credential installed
|
||||
Issuing --> Missing: delivery/signing fails
|
||||
OneTime --> Pending: subscribe monthly / yearly
|
||||
OneTime --> Expired: credential expires
|
||||
Subscription --> CanceledActive: cancel confirmed
|
||||
Subscription --> PaymentIssue: renewal fails
|
||||
Subscription --> Issuing: next period paid
|
||||
CanceledActive --> Subscription: resume / resubscribe
|
||||
CanceledActive --> Expired: coverage ends
|
||||
PaymentIssue --> Subscription: payment recovered
|
||||
PaymentIssue --> Expired: coverage and badge end
|
||||
Missing --> Issuing: retry
|
||||
Expired --> Pending: renew / subscribe
|
||||
```
|
||||
|
||||
The full nine-state machine (S0–S8), colour-coded, with every transition:
|
||||

|
||||
|
||||

|
||||
*Green = valid proof shown, amber = attention needed, grey = no badge.*
|
||||
## 3. Badge screen
|
||||
|
||||
---
|
||||
Use one stable layout:
|
||||
|
||||
## 3. Screens
|
||||
1. badge artwork, tier, and proof status;
|
||||
2. **Badge valid until**;
|
||||
3. payment type and **Renews on** or **Subscription ends on**;
|
||||
4. one primary action and one secondary manage/recovery action;
|
||||
5. compact error banner and **Last checked …** only when relevant.
|
||||
|
||||
A single badge-management screen renders one of S0–S8 (§2) from the badge, the local entitlement, and any in-flight request. Buttons name the transition they trigger. The payment rail is fixed by platform/flavor (**iOS → Apple; Android Play → Google; Android F-Droid + desktop → Stripe**); the user chooses a plan/tier, never a provider. The Stripe-specific mocks (S6 PendingPayment, portal cancel) appear on **desktop and the F-Droid Android build**.
|
||||

|
||||

|
||||

|
||||
|
||||
### 3.1 No badge (S0)
|
||||
|
||||

|
||||
|
||||
| Button | Trigger |
|
||||
| Action | Behavior |
|
||||
|---|---|
|
||||
| Subscribe | subscription purchase (Apple/Google) or Stripe Payment Link → S6/S7 |
|
||||
| Buy once | same, one-time SKU → S6/S7 |
|
||||
| Buy once | start one-time payment flow; available only without an active one-time entitlement |
|
||||
| Subscribe / Resubscribe | choose Monthly or Yearly, then start that subscription flow |
|
||||
| Cancel subscription | confirm → Apple/Google store management or Stripe RPC → refresh |
|
||||
| Manage / Fix payment | open native management or Stripe Customer Portal |
|
||||
| Check again | immediate coalesced status sync; rate-limit repeated taps |
|
||||
|
||||
### 3.2 Active one-time (S1)
|
||||
Cancellation copy: **“Cancel renewal? Your subscription stays active until {date}. You won’t be charged again.”**
|
||||
|
||||

|
||||
## 4. Purchase and cancellation flows
|
||||
|
||||
| Button | Trigger |
|
||||
|---|---|
|
||||
| Extend | buy another one-time period (full payment flow) → S7 |
|
||||
| Subscribe | NEW subscription purchase (auto-renew) → S7; not a convert (see §5.3) |
|
||||
### 4.1 Apple / Google purchase
|
||||
|
||||
### 3.3 Active subscription (S2)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant C as Client
|
||||
participant P as Apple / Google
|
||||
participant B as Badge RPC service
|
||||
U->>C: Buy / Subscribe
|
||||
C->>B: RPC payment.prepare(plan, kind)
|
||||
B-->>C: paymentId + capability + account binding
|
||||
C->>P: native purchase with Apple/Google binding
|
||||
alt dismissed
|
||||
P-->>C: user canceled
|
||||
C-->>U: unchanged screen
|
||||
else pending
|
||||
P-->>C: pending approval/payment
|
||||
C-->>U: Payment pending
|
||||
else purchased
|
||||
P-->>C: JWS / purchaseToken
|
||||
C->>B: RPC payment.attach(paymentId, capability, proof)
|
||||
B->>P: server verification/status
|
||||
P-->>B: canonical purchase period
|
||||
B-->>C: final payment snapshot response
|
||||
C->>B: RPC badge.issue(paymentId, capability, masterKey)
|
||||
B-->>C: final credential response
|
||||
C->>C: verify and persist
|
||||
C->>P: Apple finish / Google handled server-side
|
||||
C-->>U: Active badge
|
||||
end
|
||||
```
|
||||
|
||||

|
||||
Every client/bot exchange is a one-off SimpleX service RPC: the client asks, the bot returns one final response, and the reply queue is removed. There is no persistent bot connection or bot-initiated event. If a response is lost, the app repeats the identical logical request and receives the same result.
|
||||
|
||||
This screen shows **two different dates** — badge expiry vs next renewal. They are deliberately distinct; see §5.1 for the rule and worked example.
|
||||
### 4.2 Stripe checkout
|
||||
|
||||
| Button | Trigger |
|
||||
|---|---|
|
||||
| Cancel subscription | client-side only: Apple manage-subscriptions sheet / Play deep-link / Stripe portal — user cancels there → S3 (see §5.2) |
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant C as Client
|
||||
participant B as Badge RPC service
|
||||
participant W as Browser
|
||||
participant S as Stripe
|
||||
C->>B: RPC checkout.create(requestId, plan, returnToken)
|
||||
B->>S: Checkout Session.create
|
||||
B-->>C: paymentId + capability + checkoutId + URL
|
||||
C->>W: open hosted Checkout
|
||||
W->>S: pay
|
||||
S-->>B: signed webhook
|
||||
B->>B: persist paid provider period
|
||||
S-->>W: hosted HTTPS success page
|
||||
W->>C: universal/app link (UX hint)
|
||||
C->>B: RPC payment.status(paymentId, capability)
|
||||
B-->>C: paid snapshot
|
||||
C->>B: RPC badge.issue(paymentId, capability, masterKey)
|
||||
B-->>C: credential
|
||||
```
|
||||
|
||||
### 3.4 Compact mocks
|
||||
Do not spawn a localhost server. A hosted HTTPS success page works across desktop/mobile and offers **Return to SimpleX** using a universal/app link with custom-scheme fallback. Redirects are not payment proof. On return/foreground the client calls `payment.status`; while Checkout remains pending it polls by making new status RPCs at 5, 15, 30, 60, and 120 seconds, then stops. Later foreground/timer reconciliation recovers closed apps and delayed webhooks. The bot never pushes state. The client supplies its master key only in the subsequent `badge.issue` RPC.
|
||||
|
||||

|
||||
### 4.3 Cancel
|
||||
|
||||
- **Subscription canceled, still active (S3)** — Resubscribe → new subscription purchase → S2.
|
||||
- **Expired (S5)** — badge dim ≤31d, hidden after. Renew → re-buy one-time; Subscribe → new sub. Both → S7.
|
||||
- **Pending payment — Stripe (S6)** — Open page → Stripe Payment Link URL. The app polls the bot until it's paid → S7/S2; timeout (~15 min) → prior state (the pending link persists for re-open).
|
||||
- **Pending issuance (S7)** — no buttons. Awaiting the bot's credential (seconds; if delayed it stays pending — delivery is trusted). On install → S1/S2; on error → prior state + toast.
|
||||
- **Payment-issue banner (S4, overlays S2/S3)** — shown for store grace/billing-retry/past_due. Fix → store/Play/Stripe portal deep-link. Badge stays valid during grace only; on-hold/retry-failed → S5.
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor U as User
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
participant P as Provider
|
||||
U->>C: Cancel subscription
|
||||
C-->>U: confirm paid-through date
|
||||
alt Stripe
|
||||
C->>B: RPC subscription.cancel(requestId, paymentId, capability)
|
||||
B->>P: stop renewal at period end
|
||||
P-->>B: success / already canceled / error
|
||||
B-->>C: canonical payment snapshot
|
||||
else Apple / Google
|
||||
C->>P: open store subscription management
|
||||
C->>B: RPC payment.status after return
|
||||
B->>P: verify subscription status
|
||||
B-->>C: canonical payment snapshot
|
||||
end
|
||||
C-->>U: Subscription ends on {date}
|
||||
```
|
||||
|
||||
> S8 RecoverNeeded (entitlement active, badge missing/invalid) is not a screen: reconciliation auto re-requests issuance (→ S7). If the app is too old to verify the issuer key, show inline "Update app to display your badge."
|
||||
Apple uses `showManageSubscriptions`; Google opens the specific Play subscription-management URL. Stripe sets `cancel_at_period_end=true` through RPC. After any store/portal return, the client refreshes by RPC. “Already canceled” is success.
|
||||
|
||||
---
|
||||
Stripe cancellation has no browser fallback. If `subscription.cancel` RPC or Stripe is unavailable, keep the subscription state unchanged, show the retryable error, and retry later. Customer Portal may be used for payment methods and invoices, but its cancellation feature must be disabled.
|
||||
|
||||
## 4. End-to-End Journey
|
||||
## 5. Refresh and notification
|
||||
|
||||
The complete lifecycle in one view: connect to the bot → buy on Apple/Google/Stripe → the bot verifies and issues → the app installs and shows the badge → on every launch/timer the app checks its own expiry and subscription and renews or notifies → cancel is client-side.
|
||||
Refresh on app start, profile switch, foreground, connectivity restoration, provider purchase updates, return from management, a foreground timer every 6 hours (±15% jitter), 24 hours before the nearer of payment/badge expiry, and at that boundary.
|
||||
|
||||

|
||||
Rules:
|
||||
|
||||
**Narrative.**
|
||||
1. Check payment even when no badge exists.
|
||||
2. If entitled and the current monthly issuance slot has no valid badge, request issuance automatically.
|
||||
3. Coalesce triggers: one sync per payment at a time.
|
||||
4. Retry transient failures after 5 s, 30 s, 2 min, 15 min, then 6 h, with jitter. Reset after success, foreground, or manual retry.
|
||||
5. Never calculate credential validity locally. Offline keeps a still-valid badge and cached status.
|
||||
6. Each RPC has one final response. Repeating an identical issue request returns the cached credential and installs idempotently using payment/period/badge IDs. A provider event alone cannot mint a badge because the bot does not retain the master key.
|
||||
7. Notify once per event: 7 days before one-time expiry, first payment problem, expiration without entitlement, and confirmed cancellation. Do not notify every poll.
|
||||
|
||||
1. **Connect (once).** The app connects to the hardcoded badge bot address; the bot auto-accepts.
|
||||
2. **Buy.** The user taps a plan. On iOS/Android the purchase happens in the native store sheet; on desktop the app opens a Stripe Checkout page in the browser.
|
||||
3. **Get badge.** The app sends its purchase receipt to the bot; the bot verifies it with the provider, derives the tier and expiry, signs the credential, and returns it. The app verifies and installs the badge, which then unlocks perks and can be shown to peers.
|
||||
4. **Renew.** On startup, foreground, coarse timer, and each billing period, the app re-checks. Apple/Google re-present the current receipt; for Stripe the app asks the bot to re-check its stored subscription. Either way the bot returns the next period's badge, so an active subscriber's badge never visibly lapses mid-cycle (see §5.4).
|
||||
5. **Cancel.** The user cancels in the native store UI or the Stripe portal — never through the bot. The badge stays active until its coarse expiry, then lapses (see §5.2).
|
||||
## 6. Error UX
|
||||
|
||||
> The message-level sequence diagrams (request → response between app, bot, and provider) live in the [Implementation Plan](2026-07-20-supporter-badges-v2-implementation.md), §Client ↔ Bot Protocol.
|
||||
|
||||
---
|
||||
|
||||
## 5. Product Rules
|
||||
|
||||
The cross-cutting rules, each stated once. Screens and provider sections reference these.
|
||||
|
||||
### 5.1 Two dates: badge expiry vs renewal
|
||||
|
||||
A subscriber's screen shows two dates that are set independently:
|
||||
|
||||
| | Alignment | How it's set | Example: bought **July 20** |
|
||||
|---|---|---|---|
|
||||
| **Badge expiry** | **month-aligned, coarse** | issuer sets it to the **end of the month after purchase** (`end_of_next_month`), shared by everyone who buys that month so the *disclosed* value can't fingerprint the holder | valid through **end of August** (expires `2026-09-01T00:00:00Z`) |
|
||||
| **Renewal / next billing** | **subscription-aligned** | the store's real period end, read locally (Apple `expirationDate` / Play `expiryTime` / Stripe item period end) | **renews August 20** |
|
||||
|
||||
The renewal (Aug 20) deliberately falls **before** the badge expiry (end of Aug): at renewal the app re-issues and gets the *next* month's cohort expiry, so an active subscriber's badge never visibly lapses mid-cycle. Badge expiry comes from the signed credential; the renewal date from the store.
|
||||
|
||||
### 5.2 Cancellation is user-driven and store-side
|
||||
|
||||
There is **no bot or backend cancel**. The app only opens the store's own UI, and the user cancels there:
|
||||
|
||||
- **Apple:** the native manage-subscriptions sheet (`showManageSubscriptions`), fallback deep-link to the account subscriptions page.
|
||||
- **Google:** deep-link to the Play subscription center.
|
||||
- **Stripe:** the bot mints a hosted Customer Portal URL; the user self-cancels there (`cancel_at_period_end`, reversible until period end).
|
||||
|
||||
After cancelling, the badge **stays active until its coarse expiry, then lapses** (S2 → S3 → S5). The app detects the cancel on its next status read (auto-renew flag flips off).
|
||||
|
||||
### 5.3 One-time vs subscription
|
||||
|
||||
- **One-time** never auto-renews. At expiry it goes to S5; **Extend** is a fresh purchase of a new one-time period. A one-time credential lost locally (reinstall) is not restorable — the user re-buys.
|
||||
- **Subscription** auto-renews until cancelled; the primary action is **Cancel** (§5.2).
|
||||
- **"Convert" is not a special API.** Upgrading one-time → subscription (S1 "Subscribe") is simply a **new subscription purchase**. Overlapping coverage is allowed; the coarse end-of-next-month expiry dedupes it, and the badge just gains the later expiry.
|
||||
|
||||
### 5.4 Renewal is automatic and invisible
|
||||
|
||||
The app re-issues the badge on renewal with no user action:
|
||||
|
||||
- **On startup and each billing period,** the app asks the bot to check the current period and re-issue.
|
||||
- **Apple / Google:** the app re-presents its on-device receipt/token.
|
||||
- **Stripe:** the app has no local subscription state, so it asks the bot to re-check the stored subscription (no new checkout).
|
||||
- The bot verifies payment for the period and returns the badge — the same one if the period hasn't advanced, a fresh one (next cohort expiry) if it has.
|
||||
|
||||
**Perks require an active badge.** The 2 GB / 5 GB file-size cap unlocks only while the badge is active and drops the instant the badge leaves that state. If a payment problem (S4) outlasts the badge's coarse expiry, the badge lapses and the perk is lost even while the store still reports grace/retry.
|
||||
|
||||
### 5.5 Availability by platform
|
||||
|
||||
- **iOS → Apple; Android Play build → Google; Android F-Droid/no-GMS build → Stripe; desktop → Stripe.** Stripe serves desktop **and** the F-Droid Android build; there is no Apple/Google purchase on desktop.
|
||||
- **Android F-Droid / no-GMS build has no Play Billing** — the FOSS APK must stay free of proprietary billing code, so it uses **Stripe** (browser checkout + portal) instead, the same rail as desktop. Badge display/verification (v1) works on every flavor; only the *purchase rail* differs (Play → Play Billing, F-Droid → Stripe).
|
||||
|
||||
---
|
||||
|
||||
## 6. Provider Capability Summary
|
||||
|
||||
What the app can do locally on each rail vs. what needs the bot:
|
||||
|
||||

|
||||
|
||||
| Provider | Platform | Cancel mechanism | Renewal model |
|
||||
|---|---|---|---|
|
||||
| Apple (StoreKit 2) | iOS | native manage-subscriptions sheet | app re-presents receipt each period (client-driven) |
|
||||
| Google (Play Billing) | Android (Play flavor) | Play subscription-center deep-link | app re-presents token each period (client-driven) |
|
||||
| Stripe | Desktop + Android (F-Droid) | hosted Customer Portal URL | app asks the bot to re-check the subscription each period (client-driven) |
|
||||
|
||||
Status is read locally on Apple/Google and via the hosted portal on Stripe; **cancel is user-driven store-side** on all three (§5.2). Provider API detail (StoreKit 2, Play Developer API, Stripe Checkout/portal, error handling) is in the [Implementation Plan](2026-07-20-supporter-badges-v2-implementation.md), §Provider Integration.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Product Decisions
|
||||
|
||||
Product-level decisions still open; technical decisions are in the [Implementation Plan](2026-07-20-supporter-badges-v2-implementation.md), §Open Technical Decisions.
|
||||
|
||||
| # | Question | Recommended default |
|
||||
| Class | Examples | Handling |
|
||||
|---|---|---|
|
||||
| 1 | Product catalog: offer a one-time non-subscription badge, or subscription-only, on Apple/Google? | Offer **both** (one-time "Extend" + auto-renew "Subscribe"); coarse end-of-next-month expiry dedupes overlap. Revisit if catalog upkeep is too costly. |
|
||||
| User action | sheet/browser dismissed | return silently to prior state |
|
||||
| Pending | Ask to Buy, Play pending, open Checkout | persistent pending state; no new badge |
|
||||
| Network | offline, timeout, 429, provider 5xx | retain valid badge/cache; retry with backoff |
|
||||
| Payment | declined, grace, on hold, past due | show Fix payment and exact dates |
|
||||
| Proof | malformed, wrong app/package, unknown SKU, ownership conflict | restore/refresh once; then support code |
|
||||
| Credential | bad signature/master key | reject and retry once; unknown key → Update app |
|
||||
| Cancel conflict | already canceled | treat as success; refresh |
|
||||
| Stripe cancel failed | bot RPC or Stripe unavailable/rejected | keep current state; retry later; never claim cancellation |
|
||||
| Service/config | provider credentials or issuer key unavailable | preserve paid state; retry issuance |
|
||||
| Local storage | full/corrupt/write failure | do not finish/acknowledge until processing is durable; rebuild cache |
|
||||
|
||||
A user message may include only the stable error code and request ID, never raw upstream details.
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
- Every UX state has a tested entry, exit, action, and recovery path.
|
||||
- Restarting at any step does not duplicate charge or issuance.
|
||||
- A paid user with no badge is found and re-issued automatically.
|
||||
- Cancel is idempotent, shows the paid-through date, and does not remove a valid badge.
|
||||
- Stripe works if the redirect never occurs, the app is closed, or webhooks are duplicated, delayed, or out of order.
|
||||
- Payment and badge expiry may disagree without corrupting either machine.
|
||||
- Monthly and yearly subscriptions both issue one badge per eligible monthly slot; yearly cancellation continues slots only through the paid annual end.
|
||||
- All retryable failures preserve last-known UI and converge after recovery.
|
||||
|
||||
@@ -1,88 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="470" viewBox="0 0 1020 470" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<defs>
|
||||
<marker id="e" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="ep" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#9333ea"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="1020" height="470" fill="#ffffff"/>
|
||||
<text x="510" y="32" text-anchor="middle" font-size="20" font-weight="700" fill="#0f172a">End-to-End Badge Lifecycle</text>
|
||||
<text x="510" y="53" text-anchor="middle" font-size="12.5" fill="#64748b">connect → buy → verify → issue → show → (renew / expiry check) → cancel · request↔response "ping-pong" between client, bot & provider</text>
|
||||
|
||||
<!-- phase cards -->
|
||||
<!-- helper geometry: 7 cards, x0=18, w=124, gap=16 => pitch 140 -->
|
||||
<g font-size="13" font-weight="700" fill="#0f172a" text-anchor="middle">
|
||||
<!-- 0 Connect (client-bot) -->
|
||||
<rect x="18" y="120" width="124" height="92" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="80" y="150">0 · Connect</text>
|
||||
<text x="80" y="172" font-size="10.5" font-weight="400" fill="#475569">add bot address</text>
|
||||
<text x="80" y="188" font-size="10.5" font-weight="400" fill="#475569">→ welcome</text>
|
||||
<text x="80" y="205" font-size="10" font-weight="700" fill="#1d4ed8">client ↔ bot</text>
|
||||
<!-- 1 Buy (client-store) -->
|
||||
<rect x="158" y="120" width="124" height="92" rx="10" fill="#faf5ff" stroke="#9333ea" stroke-width="2"/>
|
||||
<text x="220" y="150">1 · Buy</text>
|
||||
<text x="220" y="172" font-size="10.5" font-weight="400" fill="#475569">StoreKit / Play /</text>
|
||||
<text x="220" y="188" font-size="10.5" font-weight="400" fill="#475569">Stripe checkout</text>
|
||||
<text x="220" y="205" font-size="10" font-weight="700" fill="#7e22ce">client ↔ store</text>
|
||||
<!-- 2 Verify (bot-provider) -->
|
||||
<rect x="298" y="120" width="124" height="92" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2"/>
|
||||
<text x="360" y="150">2 · Verify</text>
|
||||
<text x="360" y="172" font-size="10.5" font-weight="400" fill="#475569">receipt / token</text>
|
||||
<text x="360" y="188" font-size="10.5" font-weight="400" fill="#475569">server-side</text>
|
||||
<text x="360" y="205" font-size="10" font-weight="700" fill="#15803d">bot ↔ provider</text>
|
||||
<!-- 3 Issue (bot-client) -->
|
||||
<rect x="438" y="120" width="124" height="92" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2"/>
|
||||
<text x="500" y="150">3 · Issue</text>
|
||||
<text x="500" y="172" font-size="10.5" font-weight="400" fill="#475569">sign credential</text>
|
||||
<text x="500" y="188" font-size="10.5" font-weight="400" fill="#475569">(issuer key)</text>
|
||||
<text x="500" y="205" font-size="10" font-weight="700" fill="#15803d">bot → client</text>
|
||||
<!-- 4 Show (client) -->
|
||||
<rect x="578" y="120" width="124" height="92" rx="10" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="640" y="150">4 · Show</text>
|
||||
<text x="640" y="172" font-size="10.5" font-weight="400" fill="#475569">verify + install;</text>
|
||||
<text x="640" y="188" font-size="10.5" font-weight="400" fill="#475569">present proofs</text>
|
||||
<text x="640" y="205" font-size="10" font-weight="700" fill="#1d4ed8">client (BSActive)</text>
|
||||
<!-- 5 Renew/Check (loop) -->
|
||||
<rect x="718" y="120" width="124" height="92" rx="10" fill="#fef3c7" stroke="#f59e0b" stroke-width="2.5"/>
|
||||
<text x="780" y="148">5 · Renew /</text>
|
||||
<text x="780" y="164">expiry check</text>
|
||||
<text x="780" y="184" font-size="10.5" font-weight="400" fill="#475569">launch / ~6h timer</text>
|
||||
<text x="780" y="205" font-size="10" font-weight="700" fill="#854d0e">client-driven (all rails)</text>
|
||||
<!-- 6 Cancel (client-store) -->
|
||||
<rect x="858" y="120" width="144" height="92" rx="10" fill="#faf5ff" stroke="#9333ea" stroke-width="2"/>
|
||||
<text x="930" y="150">6 · Cancel</text>
|
||||
<text x="930" y="172" font-size="10.5" font-weight="400" fill="#475569">store UI / portal</text>
|
||||
<text x="930" y="188" font-size="10.5" font-weight="400" fill="#475569">(no bot)</text>
|
||||
<text x="930" y="205" font-size="10" font-weight="700" fill="#7e22ce">client ↔ store</text>
|
||||
</g>
|
||||
|
||||
<!-- forward arrows between cards -->
|
||||
<g stroke="#475569" stroke-width="2">
|
||||
<line x1="142" y1="166" x2="158" y2="166" marker-end="url(#e)"/>
|
||||
<line x1="282" y1="166" x2="298" y2="166" marker-end="url(#e)"/>
|
||||
<line x1="422" y1="166" x2="438" y2="166" marker-end="url(#e)"/>
|
||||
<line x1="562" y1="166" x2="578" y2="166" marker-end="url(#e)"/>
|
||||
<line x1="702" y1="166" x2="718" y2="166" marker-end="url(#e)"/>
|
||||
<line x1="842" y1="166" x2="858" y2="166" marker-end="url(#e)"/>
|
||||
</g>
|
||||
|
||||
<!-- renewal loop: from phase 5 back to phase 2 (re-verify → re-issue) -->
|
||||
<path d="M 780 212 C 780 300, 360 300, 360 212" fill="none" stroke="#9333ea" stroke-width="2.2" marker-end="url(#ep)"/>
|
||||
<rect x="470" y="286" width="200" height="20" fill="#ffffff" opacity="0.9"/>
|
||||
<text x="570" y="301" text-anchor="middle" font-size="11.5" fill="#7e22ce">renewal: re-verify → re-issue fresh badge</text>
|
||||
|
||||
<!-- lapse / expire path from phase 5 down -->
|
||||
<path d="M 810 212 C 900 250, 930 250, 930 214" fill="none" stroke="#94a3b8" stroke-width="1.8" marker-end="url(#e)"/>
|
||||
|
||||
<!-- notes row -->
|
||||
<g font-size="12" fill="#334155">
|
||||
<rect x="18" y="330" width="984" height="118" rx="10" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||
<text x="34" y="354" font-weight="700" fill="#0f172a">Key facts</text>
|
||||
<text x="34" y="378">• Client holds the master secret <tspan font-weight="700">ms</tspan> and a per-request nonce <tspan font-weight="700">req</tspan> (echoed in every reply — transport has no correlation id).</text>
|
||||
<text x="34" y="398">• Bot verifies server-side, signs, and is the only issuer. Delivery is store-and-forward (21-day retention) → client trusts delivery + dedupes.</text>
|
||||
<text x="34" y="418">• Renewal is client-driven on every rail: the app re-checks on startup + each billing period; the bot verifies payment and issues. One-time badges never auto-renew.</text>
|
||||
<text x="34" y="438">• <tspan font-weight="700">Status & Cancel are client-side</tspan> (native store UI / Stripe portal). The bot never reports status and never executes a cancel.</text>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="390" viewBox="0 0 1100 390"><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}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:10}.good{fill:#ecfdf5;stroke:#10b981}.warn{fill:#fffbeb;stroke:#f59e0b}.bad{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;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="390" fill="#f8fafc"/><text x="550" y="38" text-anchor="middle" class="title">End-to-end badge lifecycle</text><g><rect class="box" x="25" y="105" width="150" height="90"/><text x="100" y="135" text-anchor="middle" class="h">1 · Purchase</text><text x="100" y="160" text-anchor="middle" class="b">Store sheet or</text><text x="100" y="178" text-anchor="middle" class="b">Stripe Checkout</text><rect class="box warn" x="210" y="105" width="150" height="90"/><text x="285" y="135" text-anchor="middle" class="h">2 · Verify</text><text x="285" y="160" text-anchor="middle" class="b">proof / server API</text><text x="285" y="178" text-anchor="middle" class="b">signed webhook</text><rect class="box good" x="395" y="105" width="150" height="90"/><text x="470" y="135" text-anchor="middle" class="h">3 · Paid</text><text x="470" y="160" text-anchor="middle" class="b">persist payment</text><text x="470" y="178" text-anchor="middle" class="b">period atomically</text><rect class="box good" x="580" y="105" width="150" height="90"/><text x="655" y="135" text-anchor="middle" class="h">4 · Issue</text><text x="655" y="160" text-anchor="middle" class="b">sign + cache</text><text x="655" y="178" text-anchor="middle" class="b">credential</text><rect class="box good" x="765" y="105" width="150" height="90"/><text x="840" y="135" text-anchor="middle" class="h">5 · Install</text><text x="840" y="160" text-anchor="middle" class="b">core verifies</text><text x="840" y="178" text-anchor="middle" class="b">and enables perk</text><rect class="box" x="950" y="105" width="125" height="90"/><text x="1012" y="135" text-anchor="middle" class="h">6 · Refresh</text><text x="1012" y="160" text-anchor="middle" class="b">renew, cancel,</text><text x="1012" y="178" text-anchor="middle" class="b">expire, recover</text></g><path class="line" d="M175 150 H210"/><path class="line" d="M360 150 H395"/><path class="line" d="M545 150 H580"/><path class="line" d="M730 150 H765"/><path class="line" d="M915 150 H950"/><path class="line" d="M1012 195 V275 H470 V198"/><text x="750" y="297" text-anchor="middle" class="b">next eligible monthly slot → idempotent new issuance</text><text x="550" y="345" text-anchor="middle" class="muted b">Stripe webhooks update server state; clients discover it only through one-off status RPCs.</text></svg>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,67 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="560" viewBox="0 0 1000 560" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<rect x="0" y="0" width="1000" height="560" fill="#ffffff"/>
|
||||
<text x="500" y="32" text-anchor="middle" font-size="20" font-weight="700" fill="#0f172a">Provider Capability Matrix</text>
|
||||
<text x="500" y="53" text-anchor="middle" font-size="12.5" fill="#64748b">What the client can do locally vs. what needs the bot — the asymmetry that shapes the design</text>
|
||||
|
||||
<!-- column headers -->
|
||||
<g font-size="14" font-weight="700" fill="#0f172a" text-anchor="middle">
|
||||
<rect x="40" y="72" width="300" height="44" fill="#f1f5f9" stroke="#e2e8f0"/>
|
||||
<rect x="340" y="72" width="220" height="44" fill="#eff6ff" stroke="#dbeafe"/>
|
||||
<text x="450" y="99" fill="#1d4ed8">Apple</text>
|
||||
<rect x="560" y="72" width="220" height="44" fill="#f0fdf4" stroke="#dcfce7"/>
|
||||
<text x="670" y="99" fill="#15803d">Google</text>
|
||||
<rect x="780" y="72" width="180" height="44" fill="#faf5ff" stroke="#f3e8ff"/>
|
||||
<text x="870" y="99" fill="#7e22ce">Stripe</text>
|
||||
</g>
|
||||
|
||||
<!-- rows -->
|
||||
<!-- row template: label cell + 3 provider cells -->
|
||||
<g font-size="12" text-anchor="middle">
|
||||
<!-- Row 1: Cancel by client app -->
|
||||
<rect x="40" y="116" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="150" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">Cancel — by client app?</text>
|
||||
<rect x="340" y="116" width="220" height="60" fill="#fee2e2" stroke="#fecaca"/><text x="450" y="142" font-weight="700" fill="#b91c1c">✗ No API</text><text x="450" y="162" fill="#475569">opens Apple UI only</text>
|
||||
<rect x="560" y="116" width="220" height="60" fill="#fee2e2" stroke="#fecaca"/><text x="670" y="142" font-weight="700" fill="#b91c1c">✗ No API</text><text x="670" y="162" fill="#475569">deep-link only</text>
|
||||
<rect x="780" y="116" width="180" height="60" fill="#fee2e2" stroke="#fecaca"/><text x="870" y="142" font-weight="700" fill="#b91c1c">✗ (no key)</text><text x="870" y="162" fill="#475569">via hosted portal</text>
|
||||
|
||||
<!-- Row 2: Cancel by bot/backend -->
|
||||
<rect x="40" y="176" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="210" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">Cancel — by bot/backend?</text>
|
||||
<rect x="340" y="176" width="220" height="60" fill="#fee2e2" stroke="#fecaca"/><text x="450" y="202" font-weight="700" fill="#b91c1c">✗ Not possible</text><text x="450" y="222" fill="#475569">no API exists</text>
|
||||
<rect x="560" y="176" width="220" height="60" fill="#fef9c3" stroke="#fde047"/><text x="670" y="202" font-weight="700" fill="#854d0e">△ Server-only</text><text x="670" y="222" fill="#475569">subscriptionsv2.cancel</text>
|
||||
<rect x="780" y="176" width="180" height="60" fill="#fef9c3" stroke="#fde047"/><text x="870" y="200" font-weight="700" fill="#854d0e">△ Not used</text><text x="870" y="218" fill="#475569">user cancels in the</text><text x="870" y="231" fill="#475569">portal (browser)</text>
|
||||
|
||||
<!-- Row 3: User-cancel mechanism -->
|
||||
<rect x="40" y="236" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="262" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">User-cancel mechanism</text>
|
||||
<text x="54" y="281" text-anchor="start" font-size="11" fill="#64748b">(what the "Cancel" button opens)</text>
|
||||
<rect x="340" y="236" width="220" height="60" fill="#eff6ff" stroke="#dbeafe"/><text x="450" y="260" fill="#334155">showManage-</text><text x="450" y="276" fill="#334155">Subscriptions(in:)</text><text x="450" y="290" fill="#64748b" font-size="10.5">manageSubscriptionsSheet</text>
|
||||
<rect x="560" y="236" width="220" height="60" fill="#f0fdf4" stroke="#dcfce7"/><text x="670" y="262" fill="#334155">Play subscriptions</text><text x="670" y="278" fill="#334155">deep link (?sku=&package=)</text>
|
||||
<rect x="780" y="236" width="180" height="60" fill="#faf5ff" stroke="#f3e8ff"/><text x="870" y="262" fill="#334155">Customer Portal</text><text x="870" y="278" fill="#334155">URL (bot mints)</text>
|
||||
|
||||
<!-- Row 4: Status query location -->
|
||||
<rect x="40" y="296" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="330" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">Status read — where?</text>
|
||||
<rect x="340" y="296" width="220" height="60" fill="#dcfce7" stroke="#86efac"/><text x="450" y="322" font-weight="700" fill="#15803d">✓ On-device</text><text x="450" y="342" fill="#475569">SubscriptionInfo.Status</text>
|
||||
<rect x="560" y="296" width="220" height="60" fill="#fef9c3" stroke="#fde047"/><text x="670" y="320" font-weight="700" fill="#854d0e">△ Partial on-device</text><text x="670" y="338" fill="#475569">renewal date → Play Dev API</text>
|
||||
<rect x="780" y="296" width="180" height="60" fill="#faf5ff" stroke="#f3e8ff"/><text x="870" y="322" fill="#334155">hosted portal</text><text x="870" y="340" fill="#475569">(no local state)</text>
|
||||
|
||||
<!-- Row 5: Renewal signal to bot -->
|
||||
<rect x="40" y="356" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="382" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">Renewal → bot re-issues via</text>
|
||||
<text x="54" y="401" text-anchor="start" font-size="11" fill="#64748b">(client-driven, all rails)</text>
|
||||
<rect x="340" y="356" width="220" height="60" fill="#eff6ff" stroke="#dbeafe"/><text x="450" y="380" fill="#334155">re-send new JWS</text><text x="450" y="396" fill="#475569">(currentEntitlements)</text><text x="450" y="410" fill="#64748b" font-size="10.5">push: DID_RENEW (optional)</text>
|
||||
<rect x="560" y="356" width="220" height="60" fill="#f0fdf4" stroke="#dcfce7"/><text x="670" y="380" fill="#334155">re-send SAME token</text><text x="670" y="396" fill="#475569">bot sees new expiryTime</text><text x="670" y="410" fill="#64748b" font-size="10.5">push: RTDN RENEWED (optional)</text>
|
||||
<rect x="780" y="356" width="180" height="60" fill="#faf5ff" stroke="#f3e8ff"/><text x="870" y="382" fill="#334155">client re-checks;</text><text x="870" y="400" fill="#475569">bot checks subscription</text>
|
||||
|
||||
<!-- Row 6: One-time -> subscription -->
|
||||
<rect x="40" y="416" width="300" height="60" fill="#ffffff" stroke="#e2e8f0"/>
|
||||
<text x="54" y="450" text-anchor="start" font-size="13" font-weight="700" fill="#0f172a">One-time → subscription</text>
|
||||
<rect x="340" y="416" width="220" height="60" fill="#fef9c3" stroke="#fde047"/><text x="450" y="450" fill="#334155">new product · new purchase</text>
|
||||
<rect x="560" y="416" width="220" height="60" fill="#fef9c3" stroke="#fde047"/><text x="670" y="450" fill="#334155">new product · new purchase</text>
|
||||
<rect x="780" y="416" width="180" height="60" fill="#fef9c3" stroke="#fde047"/><text x="870" y="450" fill="#334155">new subscription</text>
|
||||
</g>
|
||||
|
||||
<text x="40" y="502" font-size="11.5" fill="#64748b">✓ supported · △ limited/server-only · ✗ not available. Cancellation is a full payment flow to re-subscribe on all three; a "convert" is always a new purchase.</text>
|
||||
<text x="40" y="524" font-size="11.5" fill="#64748b">Design consequence: status & cancel are client-side everywhere (native UI / hosted portal — never a backend cancel). Renewal: client-driven on every rail (app re-checks; bot verifies + issues).</text>
|
||||
</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">cancel_at_period_end</text><text x="825" y="358" text-anchor="middle" class="b">bot RPC only</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,90 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<defs>
|
||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#475569"/>
|
||||
</marker>
|
||||
<marker id="arrB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#2563eb"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="960" height="560" fill="#ffffff"/>
|
||||
<text x="480" y="34" text-anchor="middle" font-size="20" font-weight="700" fill="#0f172a">Supporter Badges v2 — Roles & Trust Boundary</text>
|
||||
<text x="480" y="56" text-anchor="middle" font-size="13" fill="#64748b">Status & cancel are handled client-side · the bot only verifies payment and issues/renews badges</text>
|
||||
|
||||
<!-- CLIENT -->
|
||||
<rect x="30" y="90" width="270" height="410" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="165" y="120" text-anchor="middle" font-size="16" font-weight="700" fill="#1d4ed8">CLIENT (app)</text>
|
||||
<text x="165" y="140" text-anchor="middle" font-size="11.5" fill="#475569">iOS · Android · Desktop</text>
|
||||
<g font-size="12.5" fill="#0f172a">
|
||||
<text x="48" y="172">• owns BBS master secret (ms)</text>
|
||||
<text x="48" y="196">• reads subscription STATUS locally</text>
|
||||
<text x="48" y="220">• CANCEL via store UI / Stripe portal</text>
|
||||
<text x="48" y="244">• drives renewal (re-checks each period)</text>
|
||||
<text x="48" y="268">• stores OwnBadge credential</text>
|
||||
<text x="48" y="292">• verifies credential (issuer pubkeys)</text>
|
||||
<text x="48" y="316">• presents unlinkable proofs to peers</text>
|
||||
<text x="48" y="340">• local lifecycle state store</text>
|
||||
</g>
|
||||
<rect x="48" y="368" width="234" height="112" rx="8" fill="#dbeafe" stroke="#93c5fd"/>
|
||||
<text x="60" y="390" font-size="12" font-weight="700" fill="#1e3a8a">Trust anchor (in app)</text>
|
||||
<text x="60" y="412" font-size="11.5" fill="#334155">8 issuer public keys hardcoded</text>
|
||||
<text x="60" y="430" font-size="11.5" fill="#334155">in Chat.hs (idx 1–8).</text>
|
||||
<text x="60" y="452" font-size="11.5" fill="#334155">verifyCredential(pk[badgeKeyIdx])</text>
|
||||
<text x="60" y="470" font-size="11.5" fill="#334155">→ BSActive / BSUnknownKey.</text>
|
||||
|
||||
<!-- BOT -->
|
||||
<rect x="345" y="90" width="270" height="410" rx="12" fill="#f0fdf4" stroke="#16a34a" stroke-width="2"/>
|
||||
<text x="480" y="120" text-anchor="middle" font-size="16" font-weight="700" fill="#15803d">BOT (badge-service)</text>
|
||||
<text x="480" y="140" text-anchor="middle" font-size="11.5" fill="#475569">SimpleX bot · stateless per request</text>
|
||||
<g font-size="12.5" fill="#0f172a">
|
||||
<text x="363" y="172">• verifies receipts server-side</text>
|
||||
<text x="363" y="196">• holds BBS issuer SECRET keys</text>
|
||||
<text x="363" y="220">• signs credential (chat_badge_issue)</text>
|
||||
<text x="363" y="244">• Stripe: mints checkout + portal URLs</text>
|
||||
<text x="363" y="268">• per-contact customData (bot-local)</text>
|
||||
</g>
|
||||
<rect x="363" y="300" width="234" height="88" rx="8" fill="#fef9c3" stroke="#fde047"/>
|
||||
<text x="375" y="322" font-size="12" font-weight="700" fill="#854d0e">Does NOT</text>
|
||||
<text x="375" y="344" font-size="11.5" fill="#334155">• report subscription status</text>
|
||||
<text x="375" y="362" font-size="11.5" fill="#334155">• execute cancellation</text>
|
||||
<text x="375" y="380" font-size="11.5" fill="#334155">• see the client's local state</text>
|
||||
<text x="363" y="418" font-size="11.5" fill="#475569">customData is local to each side —</text>
|
||||
<text x="363" y="436" font-size="11.5" fill="#475569">the bot's per-user state is invisible</text>
|
||||
<text x="363" y="454" font-size="11.5" fill="#475569">to the client (and vice-versa).</text>
|
||||
|
||||
<!-- PROVIDERS -->
|
||||
<rect x="660" y="90" width="270" height="410" rx="12" fill="#faf5ff" stroke="#9333ea" stroke-width="2"/>
|
||||
<text x="795" y="120" text-anchor="middle" font-size="16" font-weight="700" fill="#7e22ce">PAYMENT PROVIDERS</text>
|
||||
<g font-size="12.5" fill="#0f172a">
|
||||
<text x="678" y="160" font-weight="700">Apple — StoreKit 2</text>
|
||||
<text x="690" y="182">status/cancel on-device</text>
|
||||
<text x="690" y="200">server verify: App Store API</text>
|
||||
<text x="678" y="238" font-weight="700">Google — Play Billing</text>
|
||||
<text x="690" y="260">status on-device (partial)</text>
|
||||
<text x="690" y="278">server verify: Play Dev API</text>
|
||||
<text x="678" y="316" font-weight="700">Stripe — Billing</text>
|
||||
<text x="690" y="338">status/cancel: hosted portal</text>
|
||||
<text x="690" y="356">server verify: Stripe API</text>
|
||||
</g>
|
||||
<rect x="678" y="392" width="234" height="88" rx="8" fill="#f3e8ff" stroke="#d8b4fe"/>
|
||||
<text x="690" y="414" font-size="12" font-weight="700" fill="#6b21a8">Cancellation</text>
|
||||
<text x="690" y="436" font-size="11.5" fill="#334155">Apple: no programmatic API</text>
|
||||
<text x="690" y="454" font-size="11.5" fill="#334155">Google: server-only (discouraged)</text>
|
||||
<text x="690" y="472" font-size="11.5" fill="#334155">Stripe: API / user via portal</text>
|
||||
|
||||
<!-- arrows client<->bot -->
|
||||
<line x1="300" y1="210" x2="345" y2="210" stroke="#2563eb" stroke-width="2" marker-end="url(#arrB)"/>
|
||||
<text x="322" y="200" text-anchor="middle" font-size="10.5" fill="#1d4ed8">/badge</text>
|
||||
<line x1="345" y1="250" x2="300" y2="250" stroke="#16a34a" stroke-width="2" marker-end="url(#arr)"/>
|
||||
<text x="322" y="266" text-anchor="middle" font-size="10.5" fill="#15803d">credential</text>
|
||||
|
||||
<!-- arrows bot<->providers -->
|
||||
<line x1="615" y1="210" x2="660" y2="210" stroke="#475569" stroke-width="2" marker-end="url(#arr)"/>
|
||||
<text x="637" y="200" text-anchor="middle" font-size="10.5" fill="#475569">verify</text>
|
||||
<line x1="660" y1="250" x2="615" y2="250" stroke="#475569" stroke-width="2" marker-end="url(#arr)"/>
|
||||
<text x="637" y="266" text-anchor="middle" font-size="10.5" fill="#475569">receipt ok</text>
|
||||
|
||||
<!-- arrow client<->providers (purchase/status/cancel), curved under -->
|
||||
<path d="M 165 500 C 165 535, 795 535, 795 500" fill="none" stroke="#9333ea" stroke-width="2" marker-end="url(#arr)" marker-start="url(#arr)"/>
|
||||
<text x="480" y="552" text-anchor="middle" font-size="11.5" fill="#7e22ce">purchase · read status · cancel (native store UI / Stripe portal)</text>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="470" viewBox="0 0 1000 470"><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}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:10}.good{fill:#ecfdf5;stroke:#10b981}.warn{fill:#fffbeb;stroke:#f59e0b}.bad{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;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="1000" height="470" fill="#f8fafc"/><text x="500" y="38" text-anchor="middle" class="title">Badge v2 — responsibility and trust</text><rect class="box" x="40" y="80" width="250" height="250"/><text x="165" y="110" text-anchor="middle" class="h">CLIENT</text><text x="60" y="145" class="b">• owns master secret + installed badge</text><text x="60" y="172" class="b">• native Apple / Google purchase UI</text><text x="60" y="199" class="b">• cached snapshot + reconciliation</text><text x="60" y="226" class="b">• verifies every credential in core</text><text x="60" y="253" class="b">• opens manage / portal UI</text><rect class="box good" x="375" y="80" width="250" height="250"/><text x="500" y="110" text-anchor="middle" class="h">BOT / API</text><text x="395" y="145" class="b">• canonical payment state</text><text x="395" y="172" class="b">• provider verification + cancellation</text><text x="395" y="199" class="b">• idempotency + capabilities</text><text x="395" y="226" class="b">• synchronous badge signing + cache</text><text x="395" y="253" class="b">• Stripe Checkout + webhook endpoint</text><rect class="box warn" x="710" y="80" width="250" height="250"/><text x="835" y="110" text-anchor="middle" class="h">PROVIDERS</text><text x="730" y="145" class="b">• charge / renew / refund</text><text x="730" y="172" class="b">• canonical subscription status</text><text x="730" y="199" class="b">• Apple status + management UI</text><text x="730" y="226" class="b">• Google status + management UI</text><text x="730" y="253" class="b">• Stripe APIs + signed webhooks</text><path class="line" d="M290 175 H375"/><path class="line" d="M625 175 H710"/><path class="line" d="M710 270 H625"/><text x="332" y="162" text-anchor="middle" class="muted b">one-off RPC</text><text x="668" y="162" text-anchor="middle" class="muted b">API</text><text x="668" y="292" text-anchor="middle" class="muted b">events/status</text><text x="500" y="385" text-anchor="middle" class="h">Trust rule</text><text x="500" y="412" text-anchor="middle" class="b">Only provider-verified paid periods permit signing; only core verification permits badge use.</text></svg>
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -1,31 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="470" viewBox="0 0 320 470" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<rect x="0" y="0" width="320" height="470" fill="#ffffff"/>
|
||||
<rect width="320" height="470" fill="#ffffff"/>
|
||||
<rect x="12" y="12" width="296" height="446" rx="26" fill="#f8fafc" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<rect x="120" y="24" width="80" height="8" rx="4" fill="#e2e8f0"/>
|
||||
<text x="160" y="72" text-anchor="middle" font-size="18" font-weight="700" fill="#0f172a">Supporter badges</text>
|
||||
<text x="160" y="100" text-anchor="middle" font-size="12.5" fill="#64748b">Support SimpleX and get a badge</text>
|
||||
<text x="160" y="118" text-anchor="middle" font-size="12.5" fill="#64748b">shown to your contacts.</text>
|
||||
<text x="160" y="68" text-anchor="middle" font-size="18" font-weight="700" fill="#0f172a">Supporter badges</text>
|
||||
<text x="160" y="92" text-anchor="middle" font-size="12.5" fill="#64748b">Choose one-time or subscription</text>
|
||||
|
||||
<!-- state pill -->
|
||||
<rect x="120" y="140" width="80" height="24" rx="12" fill="#f1f5f9" stroke="#cbd5e1"/>
|
||||
<text x="160" y="156" text-anchor="middle" font-size="11" fill="#475569">No badge · S0</text>
|
||||
<rect x="120" y="112" width="80" height="24" rx="12" fill="#f1f5f9" stroke="#cbd5e1"/>
|
||||
<text x="160" y="128" text-anchor="middle" font-size="11" fill="#475569">No badge · S0</text>
|
||||
|
||||
<!-- plan options -->
|
||||
<rect x="36" y="190" width="248" height="52" rx="10" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
|
||||
<circle cx="60" cy="216" r="8" fill="#2563eb"/><circle cx="60" cy="216" r="3" fill="#ffffff"/>
|
||||
<text x="80" y="212" font-size="13.5" font-weight="700" fill="#0f172a">Supporter</text>
|
||||
<text x="80" y="230" font-size="12" fill="#64748b">$X / month · auto-renews</text>
|
||||
<rect x="36" y="158" width="248" height="58" rx="10" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<circle cx="58" cy="187" r="8" fill="#ffffff" stroke="#94a3b8" stroke-width="2"/>
|
||||
<text x="78" y="181" font-size="13.5" font-weight="700" fill="#0f172a">One-time</text>
|
||||
<text x="78" y="200" font-size="12" fill="#64748b">$Y · fixed expiry · no renewal</text>
|
||||
|
||||
<rect x="36" y="252" width="248" height="52" rx="10" fill="#ffffff" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<circle cx="60" cy="278" r="8" fill="#ffffff" stroke="#94a3b8" stroke-width="2"/>
|
||||
<text x="80" y="274" font-size="13.5" font-weight="700" fill="#0f172a">Supporter</text>
|
||||
<text x="80" y="292" font-size="12" fill="#64748b">$Y one-time · fixed expiry</text>
|
||||
<rect x="36" y="228" width="248" height="112" rx="10" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
|
||||
<circle cx="58" cy="253" r="8" fill="#2563eb"/><circle cx="58" cy="253" r="3" fill="#ffffff"/>
|
||||
<text x="78" y="258" font-size="13.5" font-weight="700" fill="#0f172a">Subscription</text>
|
||||
<line x1="52" y1="273" x2="268" y2="273" stroke="#e2e8f0"/>
|
||||
<circle cx="66" cy="294" r="7" fill="#2563eb"/><circle cx="66" cy="294" r="2.5" fill="#ffffff"/>
|
||||
<text x="82" y="298" font-size="12.5" font-weight="700" fill="#0f172a">Monthly</text>
|
||||
<text x="264" y="298" text-anchor="end" font-size="12" fill="#64748b">$X / month</text>
|
||||
<circle cx="66" cy="321" r="7" fill="#ffffff" stroke="#94a3b8" stroke-width="1.5"/>
|
||||
<text x="82" y="325" font-size="12.5" font-weight="700" fill="#0f172a">Yearly</text>
|
||||
<text x="264" y="325" text-anchor="end" font-size="12" fill="#64748b">$Z / year</text>
|
||||
|
||||
<!-- buttons -->
|
||||
<rect x="36" y="370" width="118" height="44" rx="10" fill="#2563eb"/>
|
||||
<text x="95" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#ffffff">Subscribe</text>
|
||||
<rect x="166" y="370" width="118" height="44" rx="10" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="225" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#2563eb">Buy once</text>
|
||||
|
||||
<text x="160" y="440" text-anchor="middle" font-size="10.5" fill="#94a3b8">rail = platform (iOS Apple · Android Google · desktop Stripe)</text>
|
||||
</svg>
|
||||
<rect x="36" y="370" width="248" height="44" rx="10" fill="#2563eb"/>
|
||||
<text x="160" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#ffffff">Continue</text>
|
||||
<text x="160" y="438" text-anchor="middle" font-size="10.5" fill="#94a3b8">Apple · Google · Stripe, selected by build</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -19,14 +19,10 @@
|
||||
<text x="36" y="282" font-size="13" fill="#64748b">Type</text>
|
||||
<text x="284" y="282" text-anchor="end" font-size="13" font-weight="700" fill="#0f172a">One-time purchase</text>
|
||||
<line x1="36" y1="304" x2="284" y2="304" stroke="#e2e8f0"/>
|
||||
<text x="160" y="330" text-anchor="middle" font-size="11.5" fill="#94a3b8">no auto-renew · does not lapse-recover</text>
|
||||
<text x="160" y="330" text-anchor="middle" font-size="11.5" fill="#94a3b8">no auto-renew · Buy once returns after expiry</text>
|
||||
|
||||
<!-- buttons -->
|
||||
<rect x="36" y="370" width="118" height="44" rx="10" fill="#2563eb"/>
|
||||
<text x="95" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#ffffff">Extend</text>
|
||||
<rect x="166" y="370" width="118" height="44" rx="10" fill="#ffffff" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="225" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#2563eb">Subscribe</text>
|
||||
|
||||
<text x="95" y="432" text-anchor="middle" font-size="10" fill="#94a3b8">buy another period</text>
|
||||
<text x="225" y="432" text-anchor="middle" font-size="10" fill="#94a3b8">start auto-renew (new purchase)</text>
|
||||
<!-- subscription action -->
|
||||
<rect x="36" y="370" width="248" height="44" rx="10" fill="#2563eb"/>
|
||||
<text x="160" y="397" text-anchor="middle" font-size="14" font-weight="700" fill="#ffffff">Subscribe</text>
|
||||
<text x="160" y="432" text-anchor="middle" font-size="10" fill="#94a3b8">choose monthly or yearly</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -12,19 +12,18 @@
|
||||
<line x1="36" y1="204" x2="284" y2="204" stroke="#e2e8f0"/>
|
||||
<text x="36" y="232" font-size="13" fill="#64748b">Badge valid to</text>
|
||||
<text x="284" y="232" text-anchor="end" font-size="13" font-weight="700" fill="#0f172a">31 Aug 2026</text>
|
||||
<text x="36" y="240" font-size="10" fill="#94a3b8">month-aligned cohort</text>
|
||||
<text x="36" y="240" font-size="10" fill="#94a3b8">monthly badge slot</text>
|
||||
|
||||
<text x="36" y="272" font-size="13" fill="#64748b">Renews</text>
|
||||
<text x="284" y="272" text-anchor="end" font-size="13" font-weight="700" fill="#0f172a">20 Aug 2026</text>
|
||||
<text x="36" y="280" font-size="10" fill="#94a3b8">provider billing date</text>
|
||||
|
||||
<text x="36" y="312" font-size="13" fill="#64748b">Auto-renew</text>
|
||||
<circle cx="256" cy="308" r="6" fill="#16a34a"/>
|
||||
<text x="284" y="312" text-anchor="end" font-size="13" font-weight="700" fill="#15803d">ON</text>
|
||||
<text x="36" y="312" font-size="13" fill="#64748b">Plan</text>
|
||||
<text x="284" y="312" text-anchor="end" font-size="13" font-weight="700" fill="#0f172a">Monthly</text>
|
||||
<line x1="36" y1="332" x2="284" y2="332" stroke="#e2e8f0"/>
|
||||
|
||||
<!-- 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">opens App Store / Play / Stripe portal (user cancels there)</text>
|
||||
<text x="160" y="436" text-anchor="middle" font-size="10" fill="#94a3b8">Apple/Google: store · Stripe: bot RPC</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,121 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="760" viewBox="0 0 1000 760" font-family="-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">
|
||||
<defs>
|
||||
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#475569"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<style>
|
||||
.box{rx:10;stroke-width:2}
|
||||
.t{font-size:13.5px;font-weight:700;fill:#0f172a}
|
||||
.s{font-size:11px;fill:#475569}
|
||||
.lab{font-size:10.5px;fill:#334155}
|
||||
.lb{fill:#ffffff;opacity:0.85}
|
||||
</style>
|
||||
<rect x="0" y="0" width="1000" height="760" fill="#ffffff"/>
|
||||
<text x="500" y="32" text-anchor="middle" font-size="20" font-weight="700" fill="#0f172a">Client-Local Badge & Subscription State Machine</text>
|
||||
<text x="500" y="53" text-anchor="middle" font-size="12.5" fill="#64748b">UI states layered on the fixed core BadgeStatus — the lifecycle lives in client-local state, not in BadgeStatus</text>
|
||||
|
||||
<!-- boxes -->
|
||||
<!-- S0 -->
|
||||
<rect x="60" y="90" width="210" height="60" rx="10" fill="#f1f5f9" stroke="#94a3b8" stroke-width="2"/>
|
||||
<text x="72" y="118" class="t">S0 · No badge</text>
|
||||
<text x="72" y="138" class="s">no credential, no entitlement</text>
|
||||
<!-- S6 -->
|
||||
<rect x="60" y="230" width="210" height="60" rx="10" fill="#fef3c7" stroke="#f59e0b" stroke-width="2"/>
|
||||
<text x="72" y="258" class="t">S6 · Pending payment</text>
|
||||
<text x="72" y="278" class="s">Stripe checkout open (browser)</text>
|
||||
<!-- S7 -->
|
||||
<rect x="60" y="370" width="210" height="60" rx="10" fill="#fde68a" stroke="#f59e0b" stroke-width="2"/>
|
||||
<text x="72" y="398" class="t">S7 · Pending issuance</text>
|
||||
<text x="72" y="418" class="s">awaiting bot credential</text>
|
||||
|
||||
<!-- S1 -->
|
||||
<rect x="390" y="90" width="220" height="66" rx="10" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
|
||||
<text x="402" y="116" class="t">S1 · Active — one-time</text>
|
||||
<text x="402" y="136" class="s">BSActive · expiry · no auto-renew</text>
|
||||
<text x="402" y="151" class="lab">actions: Extend · Subscribe</text>
|
||||
<!-- S2 -->
|
||||
<rect x="390" y="250" width="220" height="70" rx="10" fill="#bbf7d0" stroke="#15803d" stroke-width="2.5"/>
|
||||
<text x="402" y="276" class="t">S2 · Active — subscription</text>
|
||||
<text x="402" y="296" class="s">BSActive · auto-renew ON</text>
|
||||
<text x="402" y="311" class="lab">shows expiry + next renewal · Cancel</text>
|
||||
<!-- S4 -->
|
||||
<rect x="390" y="420" width="220" height="64" rx="10" fill="#ffedd5" stroke="#ea580c" stroke-width="2"/>
|
||||
<text x="402" y="446" class="t">S4 · Grace / billing retry</text>
|
||||
<text x="402" y="466" class="s">payment issue — fix payment method</text>
|
||||
<!-- S5 -->
|
||||
<rect x="390" y="580" width="220" height="64" rx="10" fill="#f1f5f9" stroke="#94a3b8" stroke-width="2"/>
|
||||
<text x="402" y="606" class="t">S5 · Expired</text>
|
||||
<text x="402" y="626" class="s">BSExpired (dim ≤31d, then hidden)</text>
|
||||
|
||||
<!-- S3 -->
|
||||
<rect x="730" y="250" width="215" height="70" rx="10" fill="#dbeafe" stroke="#2563eb" stroke-width="2"/>
|
||||
<text x="742" y="276" class="t">S3 · Canceled — active</text>
|
||||
<text x="742" y="296" class="s">auto-renew OFF, in period</text>
|
||||
<text x="742" y="311" class="lab">until period end · Resubscribe</text>
|
||||
<!-- S8 -->
|
||||
<rect x="730" y="420" width="215" height="64" rx="10" fill="#f3e8ff" stroke="#9333ea" stroke-width="2"/>
|
||||
<text x="742" y="446" class="t">S8 · Recover needed</text>
|
||||
<text x="742" y="466" class="s">entitled but badge missing/expired</text>
|
||||
|
||||
<!-- ============ arrows ============ -->
|
||||
<!-- S0 -> S6 -->
|
||||
<line x1="165" y1="150" x2="165" y2="230" stroke="#475569" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="120" y="182" width="120" height="16"/><text x="180" y="194" text-anchor="middle" class="lab">buy · Stripe</text>
|
||||
<!-- S0 -> S7 (Apple/Google, curve right) -->
|
||||
<path d="M 270 120 C 330 120, 330 360, 270 388" fill="none" stroke="#475569" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="300" y="238" width="118" height="16"/><text x="359" y="250" text-anchor="middle" class="lab">buy · Apple/Google</text>
|
||||
<!-- S6 -> S7 -->
|
||||
<line x1="165" y1="290" x2="165" y2="370" stroke="#475569" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="140" y="322" width="52" height="16"/><text x="166" y="334" text-anchor="middle" class="lab">paid</text>
|
||||
<!-- S7 -> S1 -->
|
||||
<path d="M 270 388 C 340 380, 340 130, 390 125" fill="none" stroke="#16a34a" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="300" y="250" width="90" height="16"/><text x="345" y="262" text-anchor="middle" class="lab">one-time issued</text>
|
||||
<!-- S7 -> S2 -->
|
||||
<line x1="270" y1="400" x2="390" y2="290" stroke="#15803d" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="290" y="352" width="98" height="16"/><text x="339" y="364" text-anchor="middle" class="lab">subscription issued</text>
|
||||
|
||||
<!-- S1 -> S7 (Extend loops; Subscribe) -->
|
||||
<path d="M 500 156 C 500 200, 300 210, 270 372" fill="none" stroke="#475569" stroke-width="1.6" marker-end="url(#a)" stroke-dasharray="5,3"/>
|
||||
<rect class="lb" x="360" y="190" width="150" height="16"/><text x="435" y="202" text-anchor="middle" class="lab">Extend / Subscribe → pay</text>
|
||||
|
||||
<!-- S2 -> S3 -->
|
||||
<line x1="610" y1="285" x2="730" y2="285" stroke="#2563eb" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="628" y="266" width="150" height="16"/><text x="703" y="278" text-anchor="middle" class="lab">Cancel (store / portal)</text>
|
||||
<!-- S3 -> S2 -->
|
||||
<line x1="730" y1="305" x2="610" y2="305" stroke="#2563eb" stroke-width="1.6" marker-end="url(#a)" stroke-dasharray="5,3"/>
|
||||
<rect class="lb" x="648" y="308" width="96" height="16"/><text x="696" y="320" text-anchor="middle" class="lab">Resubscribe</text>
|
||||
<!-- S3 -> S5 -->
|
||||
<path d="M 837 320 C 837 520, 640 560, 611 600" fill="none" stroke="#475569" stroke-width="1.6" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="720" y="470" width="120" height="16"/><text x="780" y="482" text-anchor="middle" class="lab">period ends</text>
|
||||
|
||||
<!-- S2 -> S4 -->
|
||||
<line x1="500" y1="320" x2="500" y2="420" stroke="#ea580c" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="452" y="362" width="120" height="16"/><text x="512" y="374" text-anchor="middle" class="lab">payment fails</text>
|
||||
<!-- S4 -> S2 -->
|
||||
<path d="M 420 420 C 400 380, 400 340, 420 322" fill="none" stroke="#ea580c" stroke-width="1.6" marker-end="url(#a)" stroke-dasharray="5,3"/>
|
||||
<rect class="lb" x="352" y="372" width="70" height="16"/><text x="387" y="384" text-anchor="middle" class="lab">recovered</text>
|
||||
<!-- S4 -> S5 -->
|
||||
<line x1="500" y1="484" x2="500" y2="580" stroke="#475569" stroke-width="1.6" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="452" y="524" width="120" height="16"/><text x="512" y="536" text-anchor="middle" class="lab">grace expires</text>
|
||||
|
||||
<!-- S2 -> S8 (renewal / near expiry) -->
|
||||
<path d="M 610 300 C 690 340, 700 380, 780 420" fill="none" stroke="#9333ea" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="628" y="352" width="150" height="16"/><text x="703" y="364" text-anchor="middle" class="lab">renewal / near expiry</text>
|
||||
<!-- S8 -> S7 (re-request) -->
|
||||
<path d="M 730 452 C 430 470, 320 460, 272 428" fill="none" stroke="#9333ea" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="470" y="486" width="150" height="16"/><text x="545" y="498" text-anchor="middle" class="lab">re-request (silent) → S7</text>
|
||||
|
||||
<!-- S5 -> S7 (renew) -->
|
||||
<path d="M 390 612 C 250 610, 175 560, 165 432" fill="none" stroke="#475569" stroke-width="1.8" marker-end="url(#a)"/>
|
||||
<rect class="lb" x="200" y="560" width="150" height="16"/><text x="275" y="572" text-anchor="middle" class="lab">Renew / Subscribe → pay</text>
|
||||
|
||||
<!-- legend -->
|
||||
<rect x="60" y="670" width="885" height="70" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||
<text x="74" y="692" font-size="12" font-weight="700" fill="#0f172a">Legend</text>
|
||||
<rect x="74" y="704" width="16" height="12" fill="#bbf7d0" stroke="#15803d"/><text x="96" y="714" class="lab">entitled & active (BSActive)</text>
|
||||
<rect x="250" y="704" width="16" height="12" fill="#fde68a" stroke="#f59e0b"/><text x="272" y="714" class="lab">pending (payment / issuance)</text>
|
||||
<rect x="470" y="704" width="16" height="12" fill="#ffedd5" stroke="#ea580c"/><text x="492" y="714" class="lab">payment issue (grace/retry)</text>
|
||||
<rect x="690" y="704" width="16" height="12" fill="#f1f5f9" stroke="#94a3b8"/><text x="712" y="714" class="lab">no active entitlement</text>
|
||||
<text x="74" y="734" class="lab">Solid = automatic/paid transition · Dashed = user action or recovery · S1–S3 map to the user's 3 screens · maps 1:1 with the mermaid diagram in §3.</text>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="590" viewBox="0 0 1100 590"><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}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:10}.good{fill:#ecfdf5;stroke:#10b981}.warn{fill:#fffbeb;stroke:#f59e0b}.bad{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;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="590" fill="#f8fafc"/><text x="550" y="35" text-anchor="middle" class="title">Derived UX states — payment × badge × operation</text><g><rect class="box" x="40" y="80" width="180" height="70"/><text x="130" y="108" text-anchor="middle" class="h">No badge</text><text x="130" y="132" text-anchor="middle" class="b">One-time · Monthly · Yearly</text><rect class="box warn" x="290" y="80" width="180" height="70"/><text x="380" y="108" text-anchor="middle" class="h">Payment pending</text><text x="380" y="132" text-anchor="middle" class="b">Continue · Check</text><rect class="box warn" x="540" y="80" width="180" height="70"/><text x="630" y="108" text-anchor="middle" class="h">Paid, issuing</text><text x="630" y="132" text-anchor="middle" class="b">automatic retry</text><rect class="box good" x="790" y="60" width="260" height="70"/><text x="920" y="88" text-anchor="middle" class="h">Active one-time</text><text x="920" y="112" text-anchor="middle" class="b">Subscribe monthly / yearly</text><rect class="box good" x="790" y="150" width="260" height="70"/><text x="920" y="178" text-anchor="middle" class="h">Active subscription</text><text x="920" y="202" text-anchor="middle" class="b">Monthly/Yearly · Cancel</text><rect class="box warn" x="790" y="250" width="260" height="70"/><text x="920" y="278" text-anchor="middle" class="h">Canceled, active</text><text x="920" y="302" text-anchor="middle" class="b">Ends on · Resume</text><rect class="box warn" x="540" y="250" width="180" height="70"/><text x="630" y="278" text-anchor="middle" class="h">Payment issue</text><text x="630" y="302" text-anchor="middle" class="b">Fix payment</text><rect class="box bad" x="290" y="250" width="180" height="70"/><text x="380" y="278" text-anchor="middle" class="h">Entitled, missing</text><text x="380" y="302" text-anchor="middle" class="b">issue / retry</text><rect class="box bad" x="40" y="250" width="180" height="70"/><text x="130" y="278" text-anchor="middle" class="h">Expired</text><text x="130" y="302" text-anchor="middle" class="b">Buy once · Subscribe</text></g><path class="line" d="M220 115 H290"/><path class="line" d="M470 115 H540"/><path class="line" d="M720 105 H790"/><path class="line" d="M720 125 C755 125 755 185 790 185"/><path class="line" d="M920 220 V250"/><path class="line" d="M790 185 C750 185 750 285 720 285"/><path class="line" d="M540 285 H470"/><path class="line" d="M290 285 H220"/><path class="line" d="M380 250 V155 H630 V150"/><path class="line" d="M130 250 V150"/><text x="550" y="405" text-anchor="middle" class="h">Overlays</text><rect class="box" x="210" y="430" width="280" height="70"/><text x="350" y="458" text-anchor="middle" class="h">Offline / stale</text><text x="350" y="482" text-anchor="middle" class="b">keep valid badge + cached snapshot</text><rect class="box bad" x="610" y="430" width="280" height="70"/><text x="750" y="458" text-anchor="middle" class="h">Needs update</text><text x="750" y="482" text-anchor="middle" class="b">unknown key / protocol; no perk</text><text x="550" y="550" text-anchor="middle" class="muted b">View state is derived. Payment and badge remain independent persisted state machines.</text></svg>
|
||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 3.8 KiB |