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.

@@ -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 |

@@ -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.

@@ -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 @@
-