diff --git a/plans/2026-07-20-supporter-badges-v2-implementation.md b/plans/2026-07-20-supporter-badges-v2-implementation.md index 241b73b416..0a9f50045e 100644 --- a/plans/2026-07-20-supporter-badges-v2-implementation.md +++ b/plans/2026-07-20-supporter-badges-v2-implementation.md @@ -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)
+ 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)
+ 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,
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 | diff --git a/plans/2026-07-20-supporter-badges-v2-product.md b/plans/2026-07-20-supporter-badges-v2-product.md index 82e91f89df..996eb92a3d 100644 --- a/plans/2026-07-20-supporter-badges-v2-product.md +++ b/plans/2026-07-20-supporter-badges-v2-product.md @@ -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. diff --git a/plans/assets/badge-v2-e2e.svg b/plans/assets/badge-v2-e2e.svg index 9c5e7dd52d..98a7304563 100644 --- a/plans/assets/badge-v2-e2e.svg +++ b/plans/assets/badge-v2-e2e.svg @@ -1,6 +1,6 @@ -End-to-end lifecycle — ownership at each step +End-to-end lifecycle — ownership at each step CLIENTPayment stateprepare · pending · refreshBadge statereceive · install -BOTPayment serviceverify provider entitlementPaymentCreditavailable · consumed · voidedBadge servicerequest · sign · cache +BOTPayment serviceverify provider entitlementServiceGrantready · fulfilled · revokedBadge servicerequest · sign · cache PROVIDERApple / Google / Stripecharge · renew · refund -RPC evidence/statusAPI / webhookverified slotinternal contractRPC resultnext refresh +RPC evidence/statusAPI / webhookverified grant periodinternal contractRPC resultnext refresh Payment never installs a badge. Badge issuance never interprets provider state. \ No newline at end of file diff --git a/plans/assets/badge-v2-roles.svg b/plans/assets/badge-v2-roles.svg index 962e3c7ce3..4b05e4ec98 100644 --- a/plans/assets/badge-v2-roles.svg +++ b/plans/assets/badge-v2-roles.svg @@ -1,8 +1,8 @@ -Responsibility and trust boundaries +Responsibility and trust boundaries CLIENT ORCHESTRATOR• separate payment + badge machines• native store / hosted browser UI• owns capability + master key• asks through one-off RPC• core verifies and installs badge• reconciliation and local audit -PAYMENT SERVICE• capability authorization• provider verification/status• billing and credit schedule• cancellation where supported• never sees badge master key -PAYMENT CREDIT• provider-neutral product• one monthly service slot -BADGE SERVICE• accepts credit + badge request• signs and caches credential• consumes credit atomically• never imports provider logic• never changes payment state +PAYMENT SERVICE• capability authorization• provider verification/status• billing and grant schedule• cancellation where supported• never sees badge master key +SERVICE GRANT• provider-neutral authorization• one monthly grant period +BADGE SERVICE• accepts grant + badge request• signs and caches credential• fulfills grant atomically• never imports provider logic• never changes payment state PROVIDER ADAPTERSApple · Google · Stripe RPCtyped APIfinal credential response; installation remains client-owned -Only verified payment creates credit; only core verification activates a badge. \ No newline at end of file +Only verified payment creates a grant; only core verification activates a badge. \ No newline at end of file diff --git a/plans/assets/badge-v2-states.svg b/plans/assets/badge-v2-states.svg index c2df558fe1..eda52c921a 100644 --- a/plans/assets/badge-v2-states.svg +++ b/plans/assets/badge-v2-states.svg @@ -1,7 +1,7 @@ -Five separate state machines -CLIENT PAYMENTCPNone → CPPreparing → CPStoreReady / CPCheckoutReady → CPProviderPending → CPVerifying → CPEntitledBranches: CPCanceling → CPCancelAtEnd · CPProblem → retry · CPExpired → new purchase -BOT PAYMENTBPPrepared → BPCheckoutOpen / BPPendingProvider → BPVerifying → BPPaidOneTime / BPActiveProvider branches: BPGrace · BPOnHold · BPPaused · BPCancelAtEnd · BPExpired · BPRefunded · BPRevokedProvider messages and webhooks change this lane only. -PAYMENT CREDITCreditAvailable → CreditConsumedor CreditAvailable → CreditVoidedThe only payment-to-service interface; unique by payment + product + monthly slot. -BOT BADGEBBRequested → BBSigning → BBIssuedor BBRetryableFailure / BBFinalFailureConsumes credit only when the credential is durably cached.It has no installed state and does not read provider/payment state. +Five separate state machines +CLIENT PAYMENTCPNone → CPPreparing → CPStoreReady / CPCheckoutReady → CPAwaitingPayment → CPVerifying → CPEntitledBranches: CPCanceling → CPEndsAtPeriodEnd · CPPaymentProblem → retry · CPExpired → new purchase +BOT PAYMENTBPPrepared → BPCheckoutOpen / BPAwaitingPayment → BPVerifying → BPPaidOneTime / BPSubscriptionActiveProvider branches: BPGrace · BPOnHold · BPPaused · BPEndsAtPeriodEnd · BPExpired · BPRefunded · BPRevokedProvider messages and webhooks change this lane only. +SERVICE GRANTGrantReady → GrantFulfilledor GrantReady → GrantRevokedThe only payment-to-service interface; unique by payment + product + monthly grant period. +BOT BADGEBBRequested → BBSigning → BBIssuedor BBRetryableFailure / BBFinalFailureMarks grant fulfilled only when the credential is durably cached.It has no installed state and does not read provider/payment state. CLIENT BADGECBNone → CBNeeded → CBRequesting → CBReceived → CBInstalling → CBInstalledBranches: CBRetryableFailure → retry · CBFinalFailure → update/supportAn installed active badge remains visible while any payment operation fails. -Arrows between lanes are typed messages or PaymentCredit—not shared mutable state. \ No newline at end of file +Arrows between lanes are typed messages or ServiceGrant—not shared mutable state. \ No newline at end of file