docs: clarify badge lifecycle naming

This commit is contained in:
shum
2026-07-21 15:02:32 +00:00
parent 6816d17ebb
commit 67bc10d47e
5 changed files with 177 additions and 212 deletions
@@ -4,7 +4,7 @@
**Status:** implementation-ready
**Companion:** [Product and UX plan](2026-07-20-supporter-badges-v2-product.md)
Payment verification creates a provider-neutral `PaymentCredit`. Badge issuance consumes it. Payment and badge are separate state machines on client and bot.
Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance fulfills it. Payment and badge are separate state machines on client and bot.
![Architecture](assets/badge-v2-roles.svg)
@@ -28,23 +28,16 @@ Payment verification creates a provider-neutral `PaymentCredit`. Badge issuance
```mermaid
flowchart LR
C[Client] -->|one request| R[Service RPC]
R --> O[Bot orchestrator]
O --> P[Payment service]
P --> V[Provider adapter]
P --> X[PaymentCredit]
O --> B[Badge service]
B -->|credential| R
R -->|one final response| C
C --> K[Core verify and install]
C[Client] <-->|Service RPC| B[Badge bot]
B <-->|Provider API / webhook| S[Apple / Google / Stripe]
```
| Component | Owns | Must not own |
|---|---|---|
| Client payment | BBS master key, capability, purchase UI, cached status, retry schedule | bot/provider truth |
| Client badge | credential receipt and installation | billing state |
| Payment service | BBS owner commitment, proof verification, billing state, credit schedule | raw master key, credential |
| Payment credit | product and eligible monthly slot | provider proof, credential |
| Payment service | BBS owner commitment, proof verification, billing state, grant schedule | raw master key, credential |
| Service grant | product and eligible monthly grant period | provider proof, credential |
| Badge service | signing and idempotent credential cache | provider/billing logic |
| Core | signature verification and installed badge | payment status |
@@ -54,43 +47,43 @@ Treat these as separate programs with typed interfaces.
1. Provider verification changes payment state only.
2. Before `Prepare`, client generates one `BadgeMasterKey` with `generateMasterKey`; it is 32 random bytes and BBS message 0.
3. Payment stores `BadgeOwnerId = SHA-256("SimpleX badge payment owner v1" || BadgeMasterKey)` and every later issue must match it.
4. Only `CreditAvailable` plus the matching raw master key enters badge signing.
5. Credit consumption and cached issuance result are atomic/idempotent.
3. Payment stores `BadgeKeyCommitment = SHA-256("SimpleX badge key commitment v1" || BadgeMasterKey)` and every later issue must match it.
4. Only `GrantReady` plus the matching raw master key enters badge signing.
5. Grant fulfillment and cached issuance result are atomic/idempotent.
6. Payment never activates perks; verified credential does.
7. RPC has no caller identity or bot push. Capability authorizes each payment request.
8. Duplicate RPCs/events return the same result. Unknown states preserve prior state.
9. Provider dates create eligibility; retry/request time never changes badge expiry.
### Time and credit
### Time and grant
```haskell
data PaymentCreditState = CreditAvailable | CreditConsumed | CreditVoided
data ServiceGrantState = GrantReady | GrantFulfilled | GrantRevoked
data PaymentCredit = PaymentCredit
{ creditId :: CreditId
data ServiceGrant = ServiceGrant
{ grantId :: GrantId
, paymentId :: PaymentId
, productId :: ServiceProductId
, slotStart :: UTCTime
, creditState :: PaymentCreditState
, grantPeriodStart :: UTCTime
, grantState :: ServiceGrantState
}
```
- One-time: one credit at verified purchase time. Reject another one-time prepare while its prior one-time service period is active.
- Subscription: `slotStart(n) = addCalendarMonths n verifiedAnchor` when `slotStart <= now < paidThrough`.
- Monthly and yearly plans both expose one credit per eligible month.
- Badge service computes expiry as the start of the month two months after `slotStart`.
- Unique credit: `(payment_id, product_id, slot_start)`.
- Example: 21 July slot → badge expires 1 September; monthly billing renews 21 August.
- One-time: one grant at verified purchase time. Reject another one-time prepare while its prior one-time service period is active.
- Subscription: `grantPeriodStart(n) = addCalendarMonths n verifiedAnchor` when `grantPeriodStart(n) <= now < paidThrough`.
- Monthly and yearly plans both expose one grant per eligible month.
- Badge service computes expiry as the start of the month two months after `grantPeriodStart`.
- Unique grant: `(payment_id, product_id, grant_period_start)`.
- Example: 21 July grant period → badge expires 1 September; monthly billing renews 21 August.
Credit eligibility by payment state:
Grant eligibility by payment state:
| State | New credit |
| State | New grant |
|---|---|
| `BPPaidOneTime` | its single unissued credit |
| `BPActive` | current due slot through `paidThrough` |
| `BPPaidOneTime` | its single unissued grant |
| `BPSubscriptionActive` | current due grant period through `paidThrough` |
| `BPGrace` | only while the provider explicitly reports entitlement |
| `BPCancelAtEnd` | due slots until `paidThrough` |
| `BPEndsAtPeriodEnd` | due grant periods until `paidThrough` |
| all other states | none |
![State machines](assets/badge-v2-states.svg)
@@ -107,12 +100,12 @@ These names are canonical. Every transition is validated against the current con
| `CPPreparing` | prepare RPC running |
| `CPStoreReady` | Apple/Google binding ready |
| `CPCheckoutReady` | Stripe URL ready |
| `CPProviderPending` | payment/approval pending |
| `CPAwaitingPayment` | payment/approval pending |
| `CPVerifying` | evidence/status RPC running |
| `CPEntitled` | last bot status is paid |
| `CPCanceling` | management/cancel operation running |
| `CPCancelAtEnd` | renewal off; paid time remains |
| `CPProblem` | typed error + prior snapshot + retry time |
| `CPEndsAtPeriodEnd` | renewal off; paid time remains |
| `CPPaymentProblem` | typed error + prior snapshot + retry time |
| `CPExpired` | no entitlement remains |
### Bot payment
@@ -121,14 +114,14 @@ These names are canonical. Every transition is validated against the current con
|---|---|
| `BPPrepared` | payment/capability/binding stored |
| `BPCheckoutOpen` | Stripe Session stored |
| `BPPendingProvider` | provider not complete |
| `BPAwaitingPayment` | provider not complete |
| `BPVerifying` | reconciliation lease active |
| `BPPaidOneTime` | verified one-time payment |
| `BPActive` | paid subscription, renewal on |
| `BPSubscriptionActive` | paid subscription, renewal on |
| `BPGrace` | provider grants grace |
| `BPOnHold` | failed payment; no new credit |
| `BPOnHold` | failed payment; no new grant |
| `BPPaused` | provider paused entitlement |
| `BPCancelAtEnd` | renewal off; paid time remains |
| `BPEndsAtPeriodEnd` | renewal off; paid time remains |
| `BPExpired` | paid time ended |
| `BPRefunded` | verified refund/chargeback |
| `BPRevoked` | provider revoked entitlement |
@@ -140,7 +133,7 @@ These names are canonical. Every transition is validated against the current con
| State | Meaning |
|---|---|
| `CBNone` | no usable local badge |
| `CBNeeded` | credit available |
| `CBNeeded` | grant available |
| `CBRequesting` | issue RPC running |
| `CBReceived` | response cached, not installed |
| `CBInstalling` | core verification/install running |
@@ -152,13 +145,13 @@ These names are canonical. Every transition is validated against the current con
| State | Meaning |
|---|---|
| `BBRequested` | credit/key idempotency row created |
| `BBRequested` | grant/key idempotency row created |
| `BBSigning` | signing lease active |
| `BBIssued` | credential cached; credit consumed |
| `BBIssued` | credential cached; grant fulfilled |
| `BBRetryableFailure` | same request can retry |
| `BBFinalFailure` | invalid/permanently unsupported request |
Credit states are `CreditAvailable`, `CreditConsumed`, and `CreditVoided`. There is no bot “installed” state.
Grant states are `GrantReady`, `GrantFulfilled`, and `GrantRevoked`. There is no bot “installed” state.
## 3. Contracts
@@ -172,19 +165,19 @@ data ServiceCall = ServiceCall
}
data PaymentInput
= Prepare Provider ServiceProductId PurchaseKind BadgeOwnerId
= Prepare Provider ServiceProductId PurchaseKind BadgeKeyCommitment
| AppleEvidence PaymentId Capability SignedTransactionJWS
| GoogleEvidence PaymentId Capability PurchaseToken
| ExistingPayment PaymentId Capability
| CancelSubscription PaymentId Capability
| CreatePortal PaymentId Capability
data ServiceRequest = IssueBadge BadgeMasterKey (Maybe CreditId)
data ServiceRequest = IssueBadge BadgeMasterKey (Maybe GrantId)
data ServiceResponse = ServiceResponse
{ requestId :: RequestId
, payment :: PaymentSnapshot
, credit :: Maybe PaymentCreditSummary
, grant :: Maybe ServiceGrantSummary
, service :: Maybe (Either ServiceError BadgeCredential)
, retryAfter :: Maybe NominalDiffTime
}
@@ -192,30 +185,30 @@ data ServiceResponse = ServiceResponse
Rules:
- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeOwnerId`, and reuses the key for all renewal badges.
- `Prepare` stores `BadgeOwnerId` but cannot issue a badge.
- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeKeyCommitment`, and reuses the key for all renewal badges.
- `Prepare` stores `BadgeKeyCommitment` but cannot issue a badge.
- Apple/Google evidence may include `IssueBadge`.
- Stripe prepare returns Checkout data; a later `ExistingPayment + IssueBadge` issues.
- Pending response has no credit/result and includes `retryAfter`.
- Capability and `BadgeOwnerId` never enter Stripe metadata or a return URL.
- `creditId` selects only; bot rechecks payment, owner, product, and eligibility.
- Before signing, orchestrator recomputes `BadgeOwnerId` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without consuming credit.
- Pending response has no grant/result and includes `retryAfter`.
- Capability and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
- `grantId` selects only; bot rechecks payment, owner, product, and eligibility.
- Before signing, orchestrator recomputes `BadgeKeyCommitment` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without fulfilling grant.
### Internal interface
```haskell
resolvePayment :: PaymentInput -> Transaction PaymentDecision
fulfillBadge :: PaymentCredit -> BadgeRequest -> Transaction BadgeResult
fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult
```
Order:
1. authorize capability;
2. resolve/verify payment;
3. commit payment and create/load due credit;
4. verify the request key matches the payment `BadgeOwnerId`;
5. pass only credit + request to badge service;
6. cache issuance and consume credit atomically;
3. commit payment and create/load due grant;
4. verify the request key matches the payment `BadgeKeyCommitment`;
5. pass only grant + request to badge service;
6. cache issuance and mark grant fulfilled atomically;
7. return one final response.
### Idempotency and audit
@@ -230,39 +223,31 @@ Order:
Product outcomes are in the Product Plan. These diagrams show implementation boundaries only.
### Common credit → badge path
### Common grant → badge path
```mermaid
sequenceDiagram
participant C as Client
participant RPC as RPC
participant O as Orchestrator
participant P as Payment service
participant X as Credit
participant B as Badge service
participant K as Core
participant B as Bot
Note over C: CPVerifying + CBRequesting
C->>RPC: ServiceCall(payment, IssueBadge)
RPC->>O: Authorized request
O->>P: Resolve payment
Note over P: BPPaidOneTime / BPActive / BPGrace / BPCancelAtEnd
P->>X: Create or load slot
Note over X: CreditAvailable
O->>O: Verify BadgeMasterKey matches BadgeOwnerId
O->>B: Create request
Note over B: BBRequested
O->>B: Claim signing
Note over B: BBSigning
B->>B: Sign and cache
Note over B: BBIssued
B->>X: Commit result
Note over X: CreditConsumed
O-->>RPC: Final status + credential
RPC-->>C: Final response
Note over C: CPEntitled / CPCancelAtEnd / CPProblem(BPGrace)<br/>+ CBReceived
C->>K: Verify and install
C->>B: RPC payment status + IssueBadge
Note over B: Payment: BPPaidOneTime / BPSubscriptionActive / BPGrace / BPEndsAtPeriodEnd
B->>B: Create or load eligible grant period
Note over B: Grant: GrantReady
B->>B: Verify BadgeMasterKey matches BadgeKeyCommitment
B->>B: Create issuance
Note over B: Badge: BBRequested
B->>B: Claim signing
Note over B: Badge: BBSigning
B->>B: Sign and cache credential
Note over B: Badge: BBIssued
B->>B: Mark grant fulfilled
Note over B: Grant: GrantFulfilled
B-->>C: RPC payment status + credential
Note over C: CPEntitled / CPEndsAtPeriodEnd / CPPaymentProblem(BPGrace)<br/>+ CBReceived
C->>C: Verify credential
Note over C: CBInstalling
K-->>C: Installed
C->>C: Install credential
Note over C: CBInstalled
```
@@ -271,20 +256,16 @@ sequenceDiagram
```mermaid
sequenceDiagram
participant C as Client
participant RPC as RPC
participant O as Orchestrator
participant A as Apple adapter
participant P as Payment store
participant B as Bot
participant A as Apple
Note over C: CPVerifying
C->>RPC: Apple evidence
RPC->>O: Authorized request
O->>P: Claim verification lease
Note over P: BPVerifying
O->>A: Verify signed transaction
A->>A: Verify signature, app, product,<br/>binding, dates, revocation
A-->>O: Normalized result
O->>P: Apply result
Note over P: BPPaidOneTime or BPActive
C->>B: RPC AppleEvidence + optional IssueBadge
Note over B: Payment: BPVerifying
B->>B: Verify signed transaction offline
Note over B: Payment: BPPaidOneTime or BPSubscriptionActive
B-->>C: RPC status + optional credential
Note over C: CPEntitled + optional CBReceived
Note over A: No Apple API call for initial evidence
```
This path is offline. Status/restore uses App Store Server API; Notifications V2 only trigger reconciliation.
@@ -294,58 +275,45 @@ This path is offline. Status/restore uses App Store Server API; Notifications V2
```mermaid
sequenceDiagram
participant C as Client
participant RPC as RPC
participant O as Orchestrator
participant G as Google adapter
participant API as Publisher API
participant P as Payment store
participant B as Bot
participant G as Google
Note over C: CPVerifying
C->>RPC: Google evidence
RPC->>O: Authorized request
O->>P: Claim verification lease
Note over P: BPVerifying
O->>G: Verify token
G->>API: productsv2.get / subscriptionsv2.get
API-->>G: Canonical purchase
G-->>O: Normalized result
O->>P: Apply result
Note over P: BPPaidOneTime or BPActive
C->>B: RPC GoogleEvidence + optional IssueBadge
Note over B: Payment: BPVerifying
B->>G: Verify purchase token
G-->>B: Canonical purchase
Note over B: Payment: BPPaidOneTime or BPSubscriptionActive
B-->>C: RPC status + optional credential
Note over C: CPEntitled + optional CBReceived
```
Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never grant from notification payload.
Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never create a service grant from the notification payload.
### Stripe Checkout and webhook
```mermaid
sequenceDiagram
participant C as Client
participant RPC as RPC
participant P as Payment service
participant X as Credit
participant B as Bot
participant S as Stripe
participant W as Webhook endpoint
Note over C: CPPreparing
C->>RPC: Prepare Stripe
RPC->>P: Authorized request
Note over P: BPPrepared
P->>S: Create Checkout Session
S-->>P: Session ID + URL
Note over P: BPCheckoutOpen
P-->>RPC: payment ID + capability + URL
RPC-->>C: Final response
C->>B: RPC Prepare Stripe
Note over B: Payment: BPPrepared
B->>S: Create Checkout Session
S-->>B: Session ID + URL
Note over B: Payment: BPCheckoutOpen
B-->>C: RPC payment ID + capability + URL
Note over C: CPCheckoutReady
C->>C: Open Checkout and start polling
Note over C: CPProviderPending
S-->>W: Signed webhook
W->>W: Verify and persist event
W-->>S: 2xx
W->>P: Reconcile event
Note over P: BPVerifying
P->>S: Retrieve current objects
S-->>P: Canonical payment
Note over P: BPPaidOneTime or BPActive
P->>X: Create or load due slot
Note over X: CreditAvailable
C->>S: Open Checkout
Note over C: CPAwaitingPayment
S-->>B: Signed webhook
Note over B: Payment: BPVerifying
B->>S: Retrieve current payment
S-->>B: Canonical payment
Note over B: Payment: BPPaidOneTime or BPSubscriptionActive
B->>B: Create or load due grant period
Note over B: Grant: GrantReady
Note over C: Still CPAwaitingPayment, no bot push
```
Webhook handler verifies the raw body, persists/deduplicates event ID, returns `2xx`, then workers reconcile. Client remains pending until its next RPC.
@@ -355,19 +323,16 @@ Webhook handler verifies the raw body, persists/deduplicates event ID, returns `
```mermaid
sequenceDiagram
participant C as Client
participant RPC as RPC
participant P as Payment service
participant B as Bot
participant S as Stripe
Note over C: CPVerifying
C->>RPC: ExistingPayment
RPC->>P: Authorized request
Note over P: BPVerifying
P->>S: Retrieve Checkout
S-->>P: Pending / unpaid
Note over P: BPPendingProvider
P-->>RPC: Pending + retryAfter
RPC-->>C: Final response
Note over C: CPProviderPending
C->>B: RPC ExistingPayment
Note over B: Payment: BPVerifying
B->>S: Retrieve Checkout
S-->>B: Pending / unpaid
Note over B: Payment: BPAwaitingPayment
B-->>C: RPC pending + retryAfter
Note over C: CPAwaitingPayment
```
Poll from Checkout open and on return/foreground at 5, 15, 30, 60, 120 seconds. Then use normal reconciliation. Deep links are optional; no localhost listener.
@@ -376,9 +341,9 @@ Poll from Checkout open and on return/foreground at 5, 15, 30, 60, 120 seconds.
| Provider | Client action | Bot action | Confirmed state |
|---|---|---|---|
| Apple | open Apple management UI; status RPC on return | App Store Server API status | `BPCancelAtEnd` |
| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `BPCancelAtEnd` |
| Stripe | `CancelSubscription` RPC | set `cancel_at_period_end=true`, retrieve Subscription | `BPCancelAtEnd` |
| Apple | open Apple management UI; status RPC on return | App Store Server API status | `BPEndsAtPeriodEnd` |
| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `BPEndsAtPeriodEnd` |
| Stripe | `CancelSubscription` RPC | set `cancel_at_period_end=true`, retrieve Subscription | `BPEndsAtPeriodEnd` |
Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. Stripe Portal cancellation is disabled.
@@ -397,23 +362,23 @@ Mirror existing `data CallState` machinery:
References: `Simplex.Chat.Call`, `Store.Profiles`, `Library.Commands`, `Library.Subscriber`, and `Controller`.
Define five separate sums: client payment, client badge, bot payment, credit, bot badge. Do not encode state as one nullable record.
Define five separate sums: client payment, client badge, bot payment, grant, bot badge. Do not encode state as one nullable record.
### Client tables
`badge_payments`: provider/product/plan, payment state payload, encrypted capability and `BadgeMasterKey`, `BadgeOwnerId`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
`badge_payments`: provider/product/plan, payment state payload, encrypted capability and `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
`badges`: payment/credit/slot/key hash, badge state payload, cached credential, expiry, attempt/error, version.
`badges`: payment/grant/grant-period/key hash, badge state payload, cached credential, expiry, attempt/error, version.
Join by payment/credit ID only. Update active profile only after core installation.
Join by payment/grant ID only. Update active profile only after core installation.
### Bot tables
| Table | Unique key / purpose |
|---|---|
| `payments` | provider-object ownership + `BadgeOwnerId`; canonical payment sum |
| `payment_credits` | payment + product + slot |
| `badge_issuances` | credit + master-key hash; cached credential |
| `payments` | provider-object ownership + `BadgeKeyCommitment`; canonical payment sum |
| `service_grants` | payment + product + grant period |
| `badge_issuances` | grant + master-key hash; cached credential |
| `rpc_requests` | request ID; request hash + final response |
| `provider_events` | provider event ID; dedupe/result |
| `outbox` | acknowledge, consume, reconciliation, cleanup |
@@ -432,7 +397,7 @@ reconcile(paymentId):
render cached payment + installed badge
submit unseen Apple/Google evidence
request status for nonterminal payment
if CreditAvailable and no badge covers its slot: CBNeeded -> request IssueBadge
if GrantReady and no badge covers its grant period: CBNeeded -> request IssueBadge
if credential returned: CBReceived -> CBInstalling -> CBInstalled
schedule next check
```
@@ -450,24 +415,24 @@ Every input is one of:
| Input/result | Class | Client | Bot |
|---|---|---|---|
| payment pending | retry | `CPProviderPending`; schedule | `BPPendingProvider`; no credit |
| timeout/429/5xx | retry | `CPProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` |
| payment pending | retry | `CPAwaitingPayment`; schedule | `BPAwaitingPayment`; no grant |
| timeout/429/5xx | retry | `CPPaymentProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` |
| lost response | retry | preserve state; repeat same ID/body | return cached result/state |
| duplicate event/request | idempotent | accept same state/result | preserve state; dedupe/re-fetch |
| ID reused with new body | reject | preserve state; new ID only for new action | preserve state; telemetry |
| invalid capability/binding | reject | preserve state; restore/support | preserve state; rate-limit |
| invalid proof/product | reject | `CPProblem`; no blind retry | preserve `BP…`; quarantine/alert |
| unknown provider state | quarantine | `CPProblem`; retry later | preserve `BP…`; re-fetch, never guess |
| `CreditAvailable` | apply | `CBNeeded``CBRequesting` | `BBRequested` when requested |
| `CreditConsumed` | idempotent | `CBReceived` → install | return cached `BBIssued` |
| signing unavailable | retry | `CBRetryableFailure`; keep old badge | `BBRetryableFailure`; credit stays available |
| invalid key/credential/protocol | reject | `CBFinalFailure` | `BBFinalFailure`; do not consume credit |
| invalid proof/product | reject | `CPPaymentProblem`; no blind retry | preserve `BP…`; quarantine/alert |
| unknown provider state | quarantine | `CPPaymentProblem`; retry later | preserve `BP…`; re-fetch, never guess |
| `GrantReady` | apply | `CBNeeded``CBRequesting` | `BBRequested` when requested |
| `GrantFulfilled` | idempotent | `CBReceived` → install | return cached `BBIssued` |
| signing unavailable | retry | `CBRetryableFailure`; keep old badge | `BBRetryableFailure`; grant stays available |
| invalid key/credential/protocol | reject | `CBFinalFailure` | `BBFinalFailure`; do not fulfill grant |
| install crash | local retry | resume `CBReceived``CBInstalling` | no bot transition |
| cancel timeout | retry | `CPProblem`; still show Renews | preserve `BPActive`/`BPCancelAtEnd` |
| already canceled | idempotent | `CPCancelAtEnd` | return `BPCancelAtEnd` |
| cancel timeout | retry | `CPPaymentProblem`; still show Renews | preserve `BPSubscriptionActive`/`BPEndsAtPeriodEnd` |
| already canceled | idempotent | `CPEndsAtPeriodEnd` | return `BPEndsAtPeriodEnd` |
| user cancels store | exit | restore prior `CP…`/`CB…` | `BPPrepared` expires later |
| Stripe Checkout expired | final attempt | `CPExpired`; new checkout on user action | `BPExpired`; no credit |
| refund/revocation | apply | `CPExpired`; signed badge survives to expiry | `BPRefunded`/`BPRevoked`; void unused credits |
| Stripe Checkout expired | final attempt | `CPExpired`; new checkout on user action | `BPExpired`; no grant |
| refund/revocation | apply | `CPExpired`; signed badge survives to expiry | `BPRefunded`/`BPRevoked`; mark unused grants `GrantRevoked` |
| webhook DB failure | retry delivery | no transition | no transition; non-2xx |
Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_not_entitled`, `ownership_conflict`, `proof_invalid`, `provider_rate_limited`, `provider_unavailable`, `idempotency_mismatch`, `badge_already_issued`, `signing_failed`, `internal_error`.
@@ -476,7 +441,7 @@ Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_
- Before provider call: repeat request.
- Provider succeeds before commit: retrieve by idempotency key/object binding.
- Payment committed before issuance: `CreditAvailable` remains unchanged.
- Payment committed before issuance: `GrantReady` remains unchanged.
- Credential cached before response loss: repeat returns it.
- Response cached before install: resume local installation.
- Duplicate/out-of-order event: dedupe, re-fetch, monotonic transition.
@@ -493,17 +458,17 @@ Provider-state mapping:
| Provider state | Canonical bot state |
|---|---|
| Apple active | `BPActive` |
| Apple active | `BPSubscriptionActive` |
| Apple grace | `BPGrace` while Apple reports entitlement |
| Apple billing retry without entitlement | `BPOnHold` |
| Apple renewal off / expired / refund / revoke | `BPCancelAtEnd` / `BPExpired` / `BPRefunded` / `BPRevoked` |
| Google pending / active / grace | `BPPendingProvider` / `BPActive` / `BPGrace` |
| Apple renewal off / expired / refund / revoke | `BPEndsAtPeriodEnd` / `BPExpired` / `BPRefunded` / `BPRevoked` |
| Google pending / active / grace | `BPAwaitingPayment` / `BPSubscriptionActive` / `BPGrace` |
| Google on-hold / paused | `BPOnHold` / `BPPaused` |
| Google canceled with time remaining / expired | `BPCancelAtEnd` / `BPExpired` |
| Stripe Checkout open or async pending | `BPCheckoutOpen` / `BPPendingProvider` |
| Stripe paid one-time / paid subscription invoice | `BPPaidOneTime` / `BPActive` |
| Google canceled with time remaining / expired | `BPEndsAtPeriodEnd` / `BPExpired` |
| Stripe Checkout open or async pending | `BPCheckoutOpen` / `BPAwaitingPayment` |
| Stripe paid one-time / paid subscription invoice | `BPPaidOneTime` / `BPSubscriptionActive` |
| Stripe past-due, unpaid, or paused | `BPOnHold` / `BPPaused` according to retrieved status |
| Stripe cancel-at-end / deleted | `BPCancelAtEnd` / `BPExpired` |
| Stripe cancel-at-end / deleted | `BPEndsAtPeriodEnd` / `BPExpired` |
| Stripe refund / dispute | `BPRefunded` / `BPRevoked` |
Google linked-token replacement changes subscription identity/period data, then maps the retrieved state using this table.
@@ -512,7 +477,7 @@ Rules:
- Google initial subscription acknowledgement and one-time consumption run from durable outbox.
- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, and redirect URLs.
- Stripe subscription credit requires a paid invoice, not merely active Subscription status.
- Stripe subscription grant requires a paid invoice, not merely active Subscription status.
- Webhook/status/completion page use one reconciliation function; redirects never fulfill.
- Portal is for invoices/payment methods only. Apple/Google normal cancellation is store UI.
@@ -520,7 +485,7 @@ Rules:
- Verify provider signatures/objects server-side; never trust decoded client/redirect fields.
- Hash capabilities; encrypt retained proofs/provider IDs; rotate keys.
- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during ownership verification/signing; persist only domain-separated `BadgeOwnerId`.
- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during ownership verification/signing; persist only domain-separated `BadgeKeyCommitment`.
- Allowlist product, app/package, environment, currency/price, and account binding.
- Rate-limit operation/payment and cap payload sizes.
- Serialize payment mutations with lock/version; events and RPC use the same transitions.
@@ -529,7 +494,7 @@ Rules:
## 9. Delivery and tests
1. **Schema/protocol:** five sums/codecs, migrations, credit boundary, request ledger, Chat Console audit, core install API.
1. **Schema/protocol:** five sums/codecs, migrations, grant boundary, request ledger, Chat Console audit, core install API.
2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI.
3. **Stripe:** Checkout, completion page, webhook, reconciliation, cancel RPC, restricted Portal.
4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup.
@@ -541,9 +506,9 @@ 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;
- monthly/yearly slots and 21 July → 31 August expiry;
- monthly/yearly grant periods and 21 July → 31 August expiry;
- crash/replay at every side-effect boundary;
- capability/credit/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
- capability/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
- Chat Console coverage and redaction snapshots.
Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, store-policy review, complete error handling, operational dashboards.
@@ -557,8 +522,8 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
| RPC/controller/console | calls, response handling, redacted audit |
| client store/migrations | separate payment and badge stores |
| Kotlin/Swift | derive Product UX state |
| bot payment repository | replace `customData`; providers, credits, outbox |
| bot badge repository | credit-only signing/cache; no provider imports |
| bot payment repository | replace `customData`; providers, grants, outbox |
| bot badge repository | grant-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 |
+12 -12
View File
@@ -4,7 +4,7 @@
**Status:** implementation-ready
**Companion:** [Implementation plan](2026-07-20-supporter-badges-v2-implementation.md)
Payment and badge are separate: payment creates a service credit for an eligible monthly slot; the credit issues one badge; core verifies and installs it.
Payment and badge are separate: payment creates a service grant for an eligible monthly grant period; the grant authorizes one badge issuance; core verifies and installs it.
![Lifecycle](assets/badge-v2-e2e.svg)
@@ -31,7 +31,7 @@ Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
- One-time does not stack and is available again after badge expiry.
- Subscribing from one-time starts a new payment; there is no conversion API.
- Monthly and yearly plans issue one badge credit per eligible month.
- Monthly and yearly plans issue one service grant per eligible month.
- Cancellation stops renewal. It does not shorten an issued badge.
- Stripe uses the system browser. No localhost service is required.
@@ -40,7 +40,7 @@ Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
| Payment event | Billing | Badge |
|---|---|---|
| Paid 21 July | monthly renews 21 August; yearly renews 21 July next year | valid through 31 August |
| Eligible slot 21 August | monthly renews 21 September; yearly billing unchanged | new badge valid through 30 September |
| Eligible grant period 21 August | monthly renews 21 September; yearly billing unchanged | new badge valid through 30 September |
| Canceled before renewal | subscription remains paid to provider period end | issued badge remains valid to signed expiry |
Show **Badge valid until** separately from **Renews on** or **Subscription ends on**.
@@ -48,7 +48,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
### Sources of truth
- Core signature and expiry decide badge validity.
- Bot/provider verification decides payment status and credit eligibility.
- Bot/provider verification decides payment status and grant eligibility.
- Store state and Stripe redirects are hints only.
- Client asks through RPC; bot returns one final response and never pushes.
- Before payment, the client generates one 32-byte BBS `BadgeMasterKey`. The payment and every badge issued from it are bound to that key; renewals reuse it.
@@ -65,7 +65,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
| Active subscription | paid, renewing, badge active | interval; badge expiry; renewal | Cancel; Manage |
| Canceled, active | renewal off; paid/badge time remains | badge expiry; subscription end | Resubscribe |
| Payment issue | grace/on-hold/provider error | active badge until expiry | Fix payment; Check again |
| Badge missing | credit exists; no usable badge | issuance error/progress | Retry |
| Badge missing | grant exists; no usable badge | issuance error/progress | Retry |
| Expired | no entitlement or active badge | expired state | Buy once; Monthly; Yearly |
| Needs update | unknown issuer/protocol | unavailable | Update app |
| Offline/stale | refresh failed | cached state + check time | Retry |
@@ -127,7 +127,7 @@ sequenceDiagram
C->>A: Purchase
A-->>C: Pending
Note over C: Payment pending, badge unchanged
Note over B: Prepared, no credit
Note over B: Prepared, no grant
```
#### Canceled
@@ -181,7 +181,7 @@ sequenceDiagram
C->>G: Purchase
G-->>C: Pending
Note over C: Payment pending, badge unchanged
Note over B: Prepared, no credit
Note over B: Prepared, no grant
```
#### Canceled
@@ -214,7 +214,7 @@ sequenceDiagram
C->>S: Open Checkout
Note over C: Payment pending, start polling
S-->>B: Signed webhook
Note over B: Payment entitled, credit available
Note over B: Payment entitled, grant available
C->>B: Status + badge request
B-->>C: Status + badge
Note over C: Entitled, badge installed
@@ -230,7 +230,7 @@ sequenceDiagram
C->>B: Status request
B->>S: Retrieve Checkout
S-->>B: Pending
Note over B: Pending, no credit
Note over B: Pending, no grant
B-->>C: Pending + retry time
Note over C: Poll later, badge unchanged
```
@@ -243,7 +243,7 @@ sequenceDiagram
participant B as Bot
participant S as Stripe
S-->>B: Checkout expired
Note over B: Expired, no credit
Note over B: Expired, no grant
C->>B: Status request
B-->>C: Checkout expired
Note over C: New checkout requires user action
@@ -307,7 +307,7 @@ Never show canceled until the bot confirms renewal is off.
Refresh on launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and payment/badge date boundaries.
If paid credit exists without the current badge, request issuance. Cache the response before core verification/install. There are no bot-initiated client events.
If `GrantReady` exists without the current badge, request issuance. Cache the response before core verification/install. There are no bot-initiated client events.
| Condition | Client action |
|---|---|
@@ -330,7 +330,7 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
- Choices are One-time, Monthly, Yearly; no Extend.
- Payment on 21 July shows badge through 31 August while billing keeps its provider date.
- Apple, Google, and Stripe have separate linear outcomes.
- Payment verification creates provider-neutral credit; badge service has no provider logic.
- Payment verification creates a provider-neutral service grant; badge service has no provider logic.
- Client and bot payment/badge states are separate.
- RPC is client-request/bot-response only and idempotent.
- Stripe needs no localhost/deep-link success and cancels through bot RPC.
+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="430" viewBox="0 0 1100 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.credit{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="430" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">End-to-end lifecycle — ownership at each step</text>
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="430" viewBox="0 0 1100 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="430" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">End-to-end lifecycle — ownership at each step</text>
<text x="28" y="91" class="h">CLIENT</text><rect class="box client" rx="10" x="125" y="60" width="180" height="70"/><text x="215" y="88" text-anchor="middle" class="h">Payment state</text><text x="215" y="111" text-anchor="middle" class="b">prepare · pending · refresh</text><rect class="box client" rx="10" x="795" y="60" width="150" height="70"/><text x="870" y="88" text-anchor="middle" class="h">Badge state</text><text x="870" y="111" text-anchor="middle" class="b">receive · install</text>
<text x="28" y="221" class="h">BOT</text><rect class="box pay" rx="10" x="125" y="180" width="180" height="80"/><text x="215" y="210" text-anchor="middle" class="h">Payment service</text><text x="215" y="233" text-anchor="middle" class="b">verify provider entitlement</text><rect class="box credit" rx="10" x="405" y="180" width="180" height="80"/><text x="495" y="210" text-anchor="middle" class="h">PaymentCredit</text><text x="495" y="233" text-anchor="middle" class="b">available · consumed · voided</text><rect class="box badge" rx="10" x="685" y="180" width="180" height="80"/><text x="775" y="210" text-anchor="middle" class="h">Badge service</text><text x="775" y="233" text-anchor="middle" class="b">request · sign · cache</text>
<text x="28" y="221" class="h">BOT</text><rect class="box pay" rx="10" x="125" y="180" width="180" height="80"/><text x="215" y="210" text-anchor="middle" class="h">Payment service</text><text x="215" y="233" text-anchor="middle" class="b">verify provider entitlement</text><rect class="box grant" rx="10" x="405" y="180" width="180" height="80"/><text x="495" y="210" text-anchor="middle" class="h">ServiceGrant</text><text x="495" y="233" text-anchor="middle" class="b">ready · fulfilled · revoked</text><rect class="box badge" rx="10" x="685" y="180" width="180" height="80"/><text x="775" y="210" text-anchor="middle" class="h">Badge service</text><text x="775" y="233" text-anchor="middle" class="b">request · sign · cache</text>
<text x="28" y="349" class="h">PROVIDER</text><rect class="box provider" rx="10" x="125" y="310" width="180" height="70"/><text x="215" y="338" text-anchor="middle" class="h">Apple / Google / Stripe</text><text x="215" y="361" text-anchor="middle" class="b">charge · renew · refund</text>
<path class="line" d="M215 130 V180"/><text x="225" y="158" class="s">RPC evidence/status</text><path class="line" d="M215 310 V260"/><text x="225" y="291" class="s">API / webhook</text><path class="line" d="M305 220 H405"/><text x="355" y="207" text-anchor="middle" class="s">verified slot</text><path class="line" d="M585 220 H685"/><text x="635" y="207" text-anchor="middle" class="s">internal contract</text><path class="line" d="M775 180 V132 H795"/><text x="780" y="151" class="s">RPC result</text><path class="line" d="M945 95 H1015 V220 H865"/><text x="1000" y="154" text-anchor="middle" class="s">next refresh</text>
<path class="line" d="M215 130 V180"/><text x="225" y="158" class="s">RPC evidence/status</text><path class="line" d="M215 310 V260"/><text x="225" y="291" class="s">API / webhook</text><path class="line" d="M305 220 H405"/><text x="355" y="207" text-anchor="middle" class="s">verified grant period</text><path class="line" d="M585 220 H685"/><text x="635" y="207" text-anchor="middle" class="s">internal contract</text><path class="line" d="M775 180 V132 H795"/><text x="780" y="151" class="s">RPC result</text><path class="line" d="M945 95 H1015 V220 H865"/><text x="1000" y="154" text-anchor="middle" class="s">next refresh</text>
<text x="550" y="414" text-anchor="middle" class="muted b">Payment never installs a badge. Badge issuance never interprets provider state.</text></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+5 -5
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="500" viewBox="0 0 1100 500"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.credit{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="500" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">Responsibility and trust boundaries</text>
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="500" viewBox="0 0 1100 500"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="500" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">Responsibility and trust boundaries</text>
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns capability + master key</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">• capability authorization</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and credit schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
<rect class="box credit" rx="10" x="595" y="120" width="180" height="130"/><text x="685" y="151" text-anchor="middle" class="h">PAYMENT CREDIT</text><text x="615" y="188" class="b">• provider-neutral product</text><text x="615" y="217" class="b">• one monthly service slot</text>
<rect class="box badge" rx="10" x="830" y="75" width="235" height="220"/><text x="947" y="106" text-anchor="middle" class="h">BADGE SERVICE</text><text x="850" y="143" class="b">• accepts credit + badge request</text><text x="850" y="172" class="b">• signs and caches credential</text><text x="850" y="201" class="b">consumes credit atomically</text><text x="850" y="230" class="b">• never imports provider logic</text><text x="850" y="259" class="b">• never changes payment state</text>
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">• capability authorization</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and grant schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
<rect class="box grant" rx="10" x="595" y="120" width="180" height="130"/><text x="685" y="151" text-anchor="middle" class="h">SERVICE GRANT</text><text x="615" y="188" class="b">• provider-neutral authorization</text><text x="615" y="217" class="b">• one monthly grant period</text>
<rect class="box badge" rx="10" x="830" y="75" width="235" height="220"/><text x="947" y="106" text-anchor="middle" class="h">BADGE SERVICE</text><text x="850" y="143" class="b">• accepts grant + badge request</text><text x="850" y="172" class="b">• signs and caches credential</text><text x="850" y="201" class="b">fulfills grant atomically</text><text x="850" y="230" class="b">• never imports provider logic</text><text x="850" y="259" class="b">• never changes payment state</text>
<rect class="box provider" rx="10" x="330" y="350" width="210" height="90"/><text x="435" y="381" text-anchor="middle" class="h">PROVIDER ADAPTERS</text><text x="435" y="407" text-anchor="middle" class="b">Apple · Google · Stripe</text>
<path class="line" d="M265 185 H330"/><text x="297" y="171" text-anchor="middle" class="s">RPC</text><path class="line" d="M540 185 H595"/><path class="line" d="M775 185 H830"/><path class="line" d="M435 350 V295"/><text x="448" y="329" class="s">typed API</text><path class="dash" d="M947 295 V330 H150 V375"/><text x="687" y="320" text-anchor="middle" class="s">final credential response; installation remains client-owned</text>
<text x="550" y="480" text-anchor="middle" class="muted b">Only verified payment creates credit; only core verification activates a badge.</text></svg>
<text x="550" y="480" text-anchor="middle" class="muted b">Only verified payment creates a grant; only core verification activates a badge.</text></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="620" viewBox="0 0 1200 620"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.credit{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1200" height="620" fill="#f8fafc"/><text x="600" y="35" text-anchor="middle" class="title">Five separate state machines</text>
<text x="25" y="103" class="h">CLIENT PAYMENT</text><rect class="box client" rx="8" x="165" y="65" width="1000" height="65"/><text x="185" y="91" class="b">CPNone → CPPreparing → CPStoreReady / CPCheckoutReady → CPProviderPending → CPVerifying → CPEntitled</text><text x="185" y="115" class="b">Branches: CPCanceling → CPCancelAtEnd · CPProblem → retry · CPExpired → new purchase</text>
<text x="25" y="203" class="h">BOT PAYMENT</text><rect class="box pay" rx="8" x="165" y="155" width="1000" height="85"/><text x="185" y="181" class="b">BPPrepared → BPCheckoutOpen / BPPendingProvider → BPVerifying → BPPaidOneTime / BPActive</text><text x="185" y="205" class="b">Provider branches: BPGrace · BPOnHold · BPPaused · BPCancelAtEnd · BPExpired · BPRefunded · BPRevoked</text><text x="185" y="226" class="s">Provider messages and webhooks change this lane only.</text>
<text x="25" y="303" class="h">PAYMENT CREDIT</text><rect class="box credit" rx="8" x="165" y="265" width="1000" height="65"/><text x="185" y="292" class="b">CreditAvailable → CreditConsumed</text><text x="520" y="292" class="b">or CreditAvailable → CreditVoided</text><text x="185" y="315" class="s">The only payment-to-service interface; unique by payment + product + monthly slot.</text>
<text x="25" y="403" class="h">BOT BADGE</text><rect class="box badge" rx="8" x="165" y="355" width="1000" height="85"/><text x="185" y="382" class="b">BBRequested → BBSigning → BBIssued</text><text x="520" y="382" class="b">or BBRetryableFailure / BBFinalFailure</text><text x="185" y="407" class="s">Consumes credit only when the credential is durably cached.</text><text x="185" y="427" class="s">It has no installed state and does not read provider/payment state.</text>
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="620" viewBox="0 0 1200 620"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1200" height="620" fill="#f8fafc"/><text x="600" y="35" text-anchor="middle" class="title">Five separate state machines</text>
<text x="25" y="103" class="h">CLIENT PAYMENT</text><rect class="box client" rx="8" x="165" y="65" width="1000" height="65"/><text x="185" y="91" class="b">CPNone → CPPreparing → CPStoreReady / CPCheckoutReady → CPAwaitingPayment → CPVerifying → CPEntitled</text><text x="185" y="115" class="b">Branches: CPCanceling → CPEndsAtPeriodEnd · CPPaymentProblem → retry · CPExpired → new purchase</text>
<text x="25" y="203" class="h">BOT PAYMENT</text><rect class="box pay" rx="8" x="165" y="155" width="1000" height="85"/><text x="185" y="181" class="b">BPPrepared → BPCheckoutOpen / BPAwaitingPayment → BPVerifying → BPPaidOneTime / BPSubscriptionActive</text><text x="185" y="205" class="b">Provider branches: BPGrace · BPOnHold · BPPaused · BPEndsAtPeriodEnd · BPExpired · BPRefunded · BPRevoked</text><text x="185" y="226" class="s">Provider messages and webhooks change this lane only.</text>
<text x="25" y="303" class="h">SERVICE GRANT</text><rect class="box grant" rx="8" x="165" y="265" width="1000" height="65"/><text x="185" y="292" class="b">GrantReady → GrantFulfilled</text><text x="520" y="292" class="b">or GrantReady → GrantRevoked</text><text x="185" y="315" class="s">The only payment-to-service interface; unique by payment + product + monthly grant period.</text>
<text x="25" y="403" class="h">BOT BADGE</text><rect class="box badge" rx="8" x="165" y="355" width="1000" height="85"/><text x="185" y="382" class="b">BBRequested → BBSigning → BBIssued</text><text x="520" y="382" class="b">or BBRetryableFailure / BBFinalFailure</text><text x="185" y="407" class="s">Marks grant fulfilled only when the credential is durably cached.</text><text x="185" y="427" class="s">It has no installed state and does not read provider/payment state.</text>
<text x="25" y="513" class="h">CLIENT BADGE</text><rect class="box client" rx="8" x="165" y="465" width="1000" height="85"/><text x="185" y="492" class="b">CBNone → CBNeeded → CBRequesting → CBReceived → CBInstalling → CBInstalled</text><text x="185" y="517" class="b">Branches: CBRetryableFailure → retry · CBFinalFailure → update/support</text><text x="185" y="538" class="s">An installed active badge remains visible while any payment operation fails.</text>
<path class="dash" d="M665 130 V155"/><path class="line" d="M665 240 V265"/><path class="line" d="M665 330 V355"/><path class="dash" d="M665 440 V465"/><text x="600" y="595" text-anchor="middle" class="muted b">Arrows between lanes are typed messages or PaymentCredit—not shared mutable state.</text></svg>
<path class="dash" d="M665 130 V155"/><path class="line" d="M665 240 V265"/><path class="line" d="M665 330 V355"/><path class="dash" d="M665 440 V465"/><text x="600" y="595" text-anchor="middle" class="muted b">Arrows between lanes are typed messages or ServiceGrant—not shared mutable state.</text></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB