mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-02 19:19:04 +00:00
badge service: only a store's verdict about a purchase is terminal
This commit is contained in:
@@ -42,9 +42,9 @@ data StoreEnvironment = SEProduction | SETest
|
||||
|
||||
-- | The reasons are for the service's log alone and must never quote the receipt.
|
||||
data StoreRefusal
|
||||
= SRInvalid Text -- the store does not vouch for it: forged, malformed, another app's, unknown or refunded
|
||||
= SRInvalid Text -- a verdict that cannot change, and the client consumes the purchase: forged, malformed, another app's, refunded
|
||||
| SRPending -- a real purchase the store has not settled; it may yet
|
||||
| SRUnreachable Text -- the store was not asked, or did not answer
|
||||
| SRUnreachable Text -- no verdict: the store was not asked, did not answer, or does not know the token (a Play 404 may be lag)
|
||||
| SRVerifierFailed Text -- a bug, not the store's answer
|
||||
| SRNotConfigured -- no verifier for this store is deployed; the purchase may be real
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -32,7 +32,13 @@ No command lets the client state what is signed. The tier is that of whatever fu
|
||||
- `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config.
|
||||
- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`.
|
||||
- `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when another key redeemed it, `code_expired` past a redemption deadline.
|
||||
- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`: the tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store does not vouch for, whether forged, malformed, unknown or refunded, and for a test purchase, which cost nothing; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it and `provider_unavailable` while the store cannot be asked, both recording nothing; `provider_not_configured` when this deployment has no verifier for the store, recording nothing and terminal for the request, since retrying cannot deploy one. A receipt already credited to the signing key is answered from the service's record without asking the store. A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries the same id. The client does not finish the store transaction, and keeps the keys it signed with, on `product_unavailable` or `provider_not_configured`: the purchase is paid, and a product the service does not price or a store it cannot verify is its operator's error, so the transaction is presented again at the next trigger rather than retried now. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
|
||||
- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`: the tier and months are those of the product the funding proves, and the expiry is the one every credential for that week shares, so the client has nothing to state. Errors: `receipt_invalid` for a receipt the store has refused for good, whether forged, malformed, another app's or refunded, and for a test purchase, which cost nothing; `receipt_used` when another key was credited with it; `product_unavailable` for a product that grants no badge; `payment_pending` while the store has not settled it and `provider_unavailable` while the store cannot be asked or has not answered about the purchase, both recording nothing; `provider_not_configured` when this deployment has no verifier for the store, recording nothing and terminal for the request, since retrying cannot deploy one. A receipt already credited to the signing key is answered from the service's record without asking the store. A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries the same id. The client does not finish the store transaction, and keeps the keys it signed with, on `product_unavailable` or `provider_not_configured`: the purchase is paid, and a product the service does not price or a store it cannot verify is its operator's error, so the transaction is presented again at the next trigger rather than retried now. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
|
||||
- A store verifier's answer falls in one of three classes, and the class decides what happens to a paid purchase. On `receipt_invalid` the client deletes the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — so a purchase answered `receipt_invalid` by mistake is lost to its buyer for good: the money has moved and nothing can present the transaction again. The opposite mistake costs one request at each of the client's own triggers, and Play refunds a purchase that is not acknowledged within three days. An answer that is not certainly in the first class is in the third.
|
||||
- Terminal, `receipt_invalid`: the store has answered about this purchase, and the answer cannot change — an Apple signature that does not verify, a chain that does not lead to Apple's root, another app's bundle id, a test purchase (Apple's Sandbox, Play's `purchaseType` test), a revoked or refunded purchase, Play's `purchaseState` canceled.
|
||||
- Pending, `payment_pending`: the store knows the purchase and it is not complete — Play's `purchaseState` pending.
|
||||
- Unreachable, `provider_unavailable`: the store was not asked or has not answered about the purchase — network failures, timeouts, 5xx, quota, this service's own authentication or permission failures, and a 404 when reading the purchase (`purchases.products.get`, `purchases.subscriptionsv2.get`). Play may not yet know a token it has just issued, and no answer tells that apart from a token it never issued. Play is asked under this app's package name, so another app's token is treated the same way, and so is every other Play error response, a 400 or 410 that calls the token invalid included: only a purchase record Play returns is a verdict. A Play product id or token outside the characters Play issues is refused as `receipt_invalid` before Play is asked, so that alphabet must be the one Play documents, not a guess.
|
||||
- Apple is verified offline, so it has no "not yet": a negative answer about the signed evidence itself is terminal. A failure of the verifier's own, such as a root certificate it could not load, is not Apple's answer: it is answered `internal`, on which the client keeps the purchase. Google is asked, so its silence and its 404 are not verdicts.
|
||||
- A product this service does not price is not the store's refusal: the verifier vouches for the transaction, and the service answers `product_unavailable`, on which the client keeps the purchase.
|
||||
- Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them.
|
||||
- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above).
|
||||
- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13).
|
||||
@@ -63,4 +69,4 @@ An assertion that names an entry the service holds is a prefix: the service proc
|
||||
|
||||
## Errors
|
||||
|
||||
`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed, unknown, refunded and test receipts alike, so a guesser learns nothing from the difference; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.
|
||||
`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed, another app's, refunded and test receipts alike, so a guesser learns nothing from the difference; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command.
|
||||
|
||||
Reference in New Issue
Block a user