simplify api, receipt is payment type

This commit is contained in:
Evgeny @ SimpleX Chat
2026-08-10 19:26:23 +00:00
parent 8fdff7750f
commit 6a2e03db83
9 changed files with 36 additions and 36 deletions
@@ -30,7 +30,7 @@ m20260806_badge_service_schema =
<> withPrefix
servicePrefix
[r|
ALTER TABLE @badge_purchases ADD COLUMN receipt_hash BYTEA;
ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA;
|]
down_m20260806_badge_service_schema :: Text
@@ -31,7 +31,7 @@ m20260806_badge_service_schema =
<> withPrefix
servicePrefix
[sql|
ALTER TABLE @badge_purchases ADD COLUMN receipt_hash BLOB;
ALTER TABLE @payments ADD COLUMN receipt_hash BLOB;
|]
down_m20260806_badge_service_schema :: Query
+4 -4
View File
@@ -12,7 +12,7 @@ A request is an envelope: `version` — the client's protocol version; `purchase
Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`, and a key it holds no record of with `unknown_purchase_key`.
A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `code`; `transferBadge` creates the receiving one (post-MVP).
A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, `code`, or `receipt`.
## Idempotency
@@ -22,7 +22,7 @@ A timeout hides the outcome, so the client repeats the identical signed request
- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing.
- `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing.
- `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing.
- `transferBadge` a receipt used by the same key returns the same result; used by another key, `receipt_used`.
- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`.
## Commands
@@ -30,11 +30,11 @@ A timeout hides the outcome, so the client repeats the identical signed request
- `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): `badgeType`, `months`, `price`, `discount`, the upgrade `credit`, `amount` = price discount credit, `expiresAt`, and `paymentTo``url` for card; `address` and `cryptoAmount` for crypto. `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`.
- `purchaseBadge``badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`), records the credit, and issues the first credential, in one round trip. `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` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none.
- 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.
- `issueBadge``badgeCredential` — issues the next period from the balance, the only source of issuance. 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).
- `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13).
- `transferBadge` (post-MVP) → `badgeCredential` — the receipt moves the remaining balance and the provider binding to the signing key; the transferred period's re-issue debits a month, and the response holds a fresh receipt, retiring the presented one. Lifetime badges are not transferable by the command — support handles them.
## Upgrades
+8 -6
View File
@@ -81,6 +81,14 @@
},
"code": {
"properties": {"code": {"type": "string"}}
},
"receipt": {
"properties": {
"receipt": {
"type": "string",
"metadata": {"comment": "transfer of unissued months"}
}
}
}
}
},
@@ -297,12 +305,6 @@
},
"pauseBadge": {
"optionalProperties": {}
},
"transferBadge": {
"properties": {
"badgeRequest": {"ref": "badgeRequest"},
"receipt": {"type": "string"}
}
}
}
},
@@ -3,7 +3,7 @@
**Date:** 2026-07-31
**Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` (referenced below as UX §n)
**Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`)
**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge | transferBadge` are defined in the protocol, post-MVP; no `use_from`, `paused_at`.
**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge` and the `receipt` payment are defined in the protocol, post-MVP; no `use_from`, `paused_at`.
## 1. Client schema
@@ -160,11 +160,10 @@ A request is an envelope: `version`; `purchaseKey`? (optional for `getBadgeCatal
|---|---|---|---|
| `getBadgeCatalog` (signature optional) | — | `badgeCatalog` | `catalog`<br>`badgeStatement`? (for signed requests) |
| `getBadgeInvoice` | `priceId`<br>`offerId`? (absent for 1 month at the badge price)<br>`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`<br>`paymentVia``card`: `provider`; `crypto`: `currency`<br>`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoiceId`<br>`badgeType`<br>`months`<br>`price`<br>`discount`?<br>`credit`?<br>`amount` (= price discount credit)<br>`currency`<br>`expiresAt`<br>`paymentTo``card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount` |
| `purchaseBadge` | `badgeRequest``masterKey`, `badgeInfo`<br>`payment``apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`<br>`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`<br>`receipt`? (not provided for lifetime badges)<br>`statement` |
| `purchaseBadge` | `badgeRequest``masterKey`, `badgeInfo`<br>`payment``apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`; `receipt`: `receipt` (transfer, post-MVP)<br>`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`<br>`receipt`? (not provided for lifetime badges)<br>`statement` |
| `upgradeBadgeSubscription` | `badgeRequest`<br>`payment``apple`: `jws`; `google`: `token`<br>`balance` | `badgeCredential` | `credential`?<br>`statement` |
| `issueBadge` | `badgeRequest`<br>`balance` | `badgeCredential` | `credential`? (absent when the balance is exhausted)<br>`statement` |
| `pauseBadge` (post-MVP) | — | `badgeCredential` | `credential`?<br>`statement` |
| `transferBadge` (post-MVP) | `badgeRequest`<br>`receipt` | `badgeCredential` | `credential`<br>`receipt`?<br>`statement` |
| any, on failure | — | `error` | `code` (incl. `payment_pending`, `code_invalid` / `code_used` / `code_expired`)<br>`message`?<br>`retryAfter`? |
`statement` — record: `entries` — ledger entries; `previousEntryId`? — matches the client's asserted entryId, absent for the full ledger.
@@ -219,7 +218,7 @@ Purchase is two commands because the store purchase runs in the app between them
- `APIGetBadgeInvoice` starts every purchase: core loads or creates the live purchase row for the badge type's slot (per-user lock + `idx_badge_purchases_live`) — a `failed` row of the same slot is reused — creates the payment row, and points the badge row's `payment_id` at it (UX §3: the current payment). For `card` and `crypto` core sends `getBadgeInvoice` with the pinned `priceId` and the `offerId` of the chosen duration, and responds with the invoice — the Stripe link or the crypto screen data (UX 2.1). For `apple` and `google` core writes the rows, generates the invoice id itself, and sends nothing, since prices come from the store and SKUs from app config (§4); the app states the store because core is the same on both platforms. The invoice fields are stored on the payment row (§1), so pending-payment screens re-render after a restart; after invoice expiry a new `APIGetBadgeInvoice` creates a new invoice and payment row (UX 2.1).
- The app passes the invoice id to the store as the account token — Apple `Product.PurchaseOption.appAccountToken` (a UUID, echoed in the signed transaction), Google `BillingFlowParams.setObfuscatedAccountId` — so the store transaction states which payment it settles, and the service reads it from the verified store payload at `purchaseBadge`.
- `APIPurchaseBadge` completes a store purchase — the only payment whose result is delivered to the app: the store hands the app the evidence, and only that evidence ties the store transaction to the purchase, because the store flow knows neither purchase keys nor the service. Core records it on the payment row; the worker sends `purchaseBadge` — the service verifies, records the credit, and issues in one round trip (§6). Card and crypto payments need no completion command and carry no evidence: the service records their settlement from the provider webhook (UX §7 notifications); the worker's next `issueBadge` returns the credential, or `payment_pending` until the webhook arrives (§4).
- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (`transferBadge`, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7).
- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (the `receipt` payment, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7).
- `APIGetBadgeState` loads the badge state into the app model at start (and on profile switch); events only update the model afterward, so without the initial read it would hold nothing at first render — the 2.2 banner is rendered from it. It reads stored state and sends nothing itself. The same call re-reads state when a badge screen is opened or regains focus, and signals the worker (§6); reconciliation results follow as `CEvtBadgeChanged`. Screen re-focus covers the returns that fire no core trigger: the store cancellation sheet close — UX §7 "the engine sends `status` on return"; the in-app sheet fires no foreground trigger — and return to a pending-payment screen after payment (UX 2.1), which on desktop produces no foreground event either.
`ChatResponse`:
@@ -392,7 +391,7 @@ Each UX plan point and its implementation home:
| §3 catalog rules | `badge_prices` / `badge_offers`; reconciliation (delivery 4) |
| §3 payments, charges | §1; the statement's payments and charges (§4) |
| §3 ledger, issuances | replicas (§1); the `balance` assertion and the `opening` restatement (§4); service transitions (delivery 7); tests (delivery 8) |
| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); `transferBadge` post-MVP |
| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); the `receipt` payment post-MVP |
| §4 wire protocol | `Badges/Service.hs`; `docs/protocol` |
| §5 providers | delivery 7; the §5 command bullets |
| §6 decisions 1114 | 11 — `renews_at` / `cancelled` / charges kept; 12 — catalog seed (delivery 2); 13 — `sundayAfter`; 14 — `paidThrough` in `UserBadgeState` |
+1 -4
View File
@@ -82,10 +82,6 @@ data BadgeServiceCommand
balance :: BadgeBalance
}
| BSCPauseBadge
| BSCTransferBadge
{ badgeRequest :: BadgeRequest,
receipt :: Text
}
data ServicePaymentMethod
= SPMCard {provider :: CardProvider}
@@ -107,6 +103,7 @@ data ServicePayment
| SPGoogle {token :: Text}
| SPInvoice {invoiceId :: InvoiceId}
| SPCode {code :: Text}
| SPReceipt {receipt :: Text} -- transfer of unissued months
deriving (Show)
data BadgeUpgrade = BadgeUpgrade
+3 -3
View File
@@ -9,7 +9,7 @@ module Simplex.Chat.Badges.Store
BadgePlan (..),
BadgeItemStatus (..),
OfferDiscount (..),
BadgeProvider (..),
PaymentProvider (..),
BadgePaymentStatus (..),
BadgePurchaseStatus (..),
LedgerEntryType (..),
@@ -62,7 +62,7 @@ data OfferDiscount
deriving (Eq, Show)
-- unconfirmed draft
data BadgeProvider = BPRApple | BPRGoogle | BPRStripe | BPRBtc | BPRXmr | BPRCode
data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt
deriving (Eq, Show)
-- unconfirmed draft
@@ -133,7 +133,7 @@ data BadgePayment = BadgePayment
months :: Maybe Int,
amount :: Maybe Int64,
currency :: Maybe Text,
provider :: BadgeProvider,
provider :: PaymentProvider,
providerRef :: Maybe Text,
invoiceUrl :: Maybe Text,
invoiceAddress :: Maybe Text,
@@ -30,6 +30,7 @@ CREATE TABLE @invoices(
currency TEXT NOT NULL,
payment_url TEXT,
payment_address TEXT,
payment_crypto_currency TEXT,
payment_crypto_amount TEXT,
expires_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL,
@@ -46,7 +47,7 @@ CREATE TABLE @payments(
currency TEXT,
status TEXT NOT NULL,
exception TEXT,
renews_at TIMESTAMPTZ,
subscription_renews_at TIMESTAMPTZ,
grace_until TIMESTAMPTZ,
cancelled SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
@@ -57,7 +58,7 @@ CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref);
CREATE INDEX @idx_payments_invoice ON @payments(invoice_id);
CREATE TABLE @charges(
CREATE TABLE @subscription_charges(
charge_id TEXT NOT NULL PRIMARY KEY,
payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE,
provider_charge_ref TEXT NOT NULL,
@@ -144,7 +145,7 @@ CREATE TABLE @badge_ledger(
entry_credit_type TEXT,
entry_debit_type TEXT,
payment_id TEXT REFERENCES @payments,
charge_id TEXT REFERENCES @charges,
charge_id TEXT REFERENCES @subscription_charges,
from_purchase_id BIGINT REFERENCES @badge_purchases,
to_purchase_id BIGINT REFERENCES @badge_purchases
);
@@ -184,7 +185,7 @@ DROP TABLE @badge_ledger;
DROP TABLE @badge_subscription_changes;
DROP TABLE @badge_invoices;
DROP TABLE @badge_purchases;
DROP TABLE @charges;
DROP TABLE @subscription_charges;
DROP TABLE @payments;
DROP TABLE @invoices;
DROP TABLE @badge_offers;
@@ -199,8 +200,6 @@ ALTER TABLE badge_purchases ADD COLUMN user_id BIGINT REFERENCES users ON DELETE
ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BYTEA;
ALTER TABLE badge_purchases ADD COLUMN receipt_code TEXT;
ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT;
ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT;
@@ -209,6 +208,8 @@ ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TIMESTAMPTZ;
ALTER TABLE payments ADD COLUMN evidence BYTEA;
ALTER TABLE payments ADD COLUMN receipt_code TEXT;
ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;
@@ -31,6 +31,7 @@ CREATE TABLE @invoices(
currency TEXT NOT NULL,
payment_url TEXT,
payment_address TEXT,
payment_crypto_currency TEXT,
payment_crypto_amount TEXT,
expires_at TEXT NOT NULL,
status TEXT NOT NULL,
@@ -47,7 +48,7 @@ CREATE TABLE @payments(
currency TEXT,
status TEXT NOT NULL,
exception TEXT,
renews_at TEXT,
subscription_renews_at TEXT,
grace_until TEXT,
cancelled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
@@ -58,7 +59,7 @@ CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref);
CREATE INDEX @idx_payments_invoice ON @payments(invoice_id);
CREATE TABLE @charges(
CREATE TABLE @subscription_charges(
charge_id TEXT NOT NULL PRIMARY KEY,
payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE,
provider_charge_ref TEXT NOT NULL,
@@ -145,7 +146,7 @@ CREATE TABLE @badge_ledger(
entry_credit_type TEXT,
entry_debit_type TEXT,
payment_id TEXT REFERENCES @payments,
charge_id TEXT REFERENCES @charges,
charge_id TEXT REFERENCES @subscription_charges,
from_purchase_id INTEGER REFERENCES @badge_purchases,
to_purchase_id INTEGER REFERENCES @badge_purchases
);
@@ -185,7 +186,7 @@ DROP TABLE @badge_ledger;
DROP TABLE @badge_subscription_changes;
DROP TABLE @badge_invoices;
DROP TABLE @badge_purchases;
DROP TABLE @charges;
DROP TABLE @subscription_charges;
DROP TABLE @payments;
DROP TABLE @invoices;
DROP TABLE @badge_offers;
@@ -200,8 +201,6 @@ ALTER TABLE badge_purchases ADD COLUMN user_id INTEGER REFERENCES users ON DELET
ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BLOB;
ALTER TABLE badge_purchases ADD COLUMN receipt_code TEXT;
ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT;
ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT;
@@ -210,6 +209,8 @@ ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TEXT;
ALTER TABLE payments ADD COLUMN evidence BLOB;
ALTER TABLE payments ADD COLUMN receipt_code TEXT;
ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0;
ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT;