From 6520851fa0321b5f56bf1d081cb3a9284f5bfb9e Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:20:04 +0000 Subject: [PATCH] split invoice --- docs/protocol/badges-rpc.md | 2 +- docs/protocol/badges-rpc.schema.json | 45 ++++++++++--------- .../2026-07-31-badges-core-implementation.md | 3 +- src/Simplex/Chat/Badges/Service.hs | 24 ++++++---- 4 files changed, 43 insertions(+), 31 deletions(-) diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index dc7aa099ef..7ce3b78b34 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -29,7 +29,7 @@ A timeout hides the outcome, so the client repeats the identical signed request `purchaseBadge`, `upgradeBadgeSubscription`, and `issueBadge` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3); its absence requests a lifetime credential; `badgeExtra` is reserved and must be empty. - `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`. +- `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`. - `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. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 95d0bf305b..00ecc69874 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -67,6 +67,29 @@ } } }, + "invoice": { + "properties": { + "invoiceId": {"type": "string"}, + "price": {"ref": "currencyAmount"}, + "amount": { + "ref": "currencyAmount", + "metadata": {"comment": "price - discount - credit"} + }, + "currency": {"type": "string"}, + "expiresAt": {"type": "timestamp"}, + "paymentTo": {"ref": "paymentTo"} + }, + "optionalProperties": { + "discount": { + "ref": "currencyAmount", + "metadata": {"comment": "discount amount from the price"} + }, + "credit": { + "ref": "currencyAmount", + "metadata": {"comment": "credit for upgrade"} + } + } + }, "payment": { "discriminator": "type", "mapping": { @@ -324,27 +347,9 @@ }, "badgeInvoice": { "properties": { - "invoiceId": {"type": "string"}, + "invoice": {"ref": "invoice"}, "badgeType": {"type": "string"}, - "months": {"type": "uint8"}, - "price": {"ref": "currencyAmount"}, - "amount": { - "ref": "currencyAmount", - "metadata": {"comment": "price - discount - credit"} - }, - "currency": {"type": "string"}, - "expiresAt": {"type": "timestamp"}, - "paymentTo": {"ref": "paymentTo"} - }, - "optionalProperties": { - "discount": { - "ref": "currencyAmount", - "metadata": {"comment": "discount amount from monthly price"} - }, - "credit": { - "ref": "currencyAmount", - "metadata": {"comment": "credit for upgrade"} - } + "months": {"type": "uint8"} } }, "badgeCredential": { diff --git a/plans/2026-07-31-badges-core-implementation.md b/plans/2026-07-31-badges-core-implementation.md index a056b440b5..7b5b6acaba 100644 --- a/plans/2026-07-31-badges-core-implementation.md +++ b/plans/2026-07-31-badges-core-implementation.md @@ -126,6 +126,7 @@ Protocol types — `src/Simplex/Chat/Badges/Service.hs`, one constructor/field p - `BadgeServiceRequest` - `BadgeServiceCommand` - `ServicePaymentMethod` +- `ServiceInvoice` - `CardProvider` - `CryptoCurrency` - `CurrencyAmount` @@ -159,7 +160,7 @@ A request is an envelope: `version`; `purchaseKey`? (optional for `getBadgeCatal | request `type` | request fields (beyond `purchaseKey`, `version`) | response `type` | response fields | |---|---|---|---| | `getBadgeCatalog` (signature optional) | — | `badgeCatalog` | `catalog`
`badgeStatement`? (for signed requests) | -| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoiceId`
`badgeType`
`months`
`price`
`discount`?
`credit`?
`amount` (= price − discount − credit)
`currency`
`expiresAt`
`paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount` | +| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoice` — `invoiceId`, `price`, `discount`?, `credit`?, `amount` (= price − discount − credit), `currency`, `expiresAt`, `paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount`
`badgeType`
`months` | | `purchaseBadge` | `badgeRequest` — `masterKey`, `badgeInfo`
`payment` — `apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`; `receipt`: `receipt` (transfer, post-MVP)
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`
`receipt`? (not provided for lifetime badges)
`statement` | | `upgradeBadgeSubscription` | `badgeRequest`
`payment` — `apple`: `jws`; `google`: `token`
`balance` | `badgeCredential` | `credential`?
`statement` | | `issueBadge` | `badgeRequest`
`balance` | `badgeCredential` | `credential`? (absent when the balance is exhausted)
`statement` | diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 44e5f0861f..b325467344 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -17,6 +17,7 @@ module Simplex.Chat.Badges.Service ServicePayment (..), BadgeUpgrade (..), BadgeServiceResponse (..), + ServiceInvoice (..), ServicePaymentDestination (..), BadgeServiceErrorCode (..), BadgeCatalog (..), @@ -119,16 +120,9 @@ data BadgeServiceResponse badgeStatement :: Maybe BadgeStatement -- for signed getBadgeCatalog } | BSPBadgeInvoice - { invoiceId :: InvoiceId, + { invoice :: ServiceInvoice, badgeType :: BadgeType, - months :: Word8, - price :: CurrencyAmount, - discount :: Maybe CurrencyAmount, -- discount amount from monthly price - credit :: Maybe CurrencyAmount, -- credit for upgrade - amount :: CurrencyAmount, - currency :: Text, - expiresAt :: UTCTime, - paymentTo :: ServicePaymentDestination + months :: Word8 } | BSPBadgeCredential { credential :: Maybe BadgeCredential, -- Nothing when no balance to issueBadge or no current credential for pause @@ -141,6 +135,18 @@ data BadgeServiceResponse retryAfter :: Maybe Word32 } +data ServiceInvoice = ServiceInvoice + { invoiceId :: InvoiceId, + price :: CurrencyAmount, + discount :: Maybe CurrencyAmount, -- discount amount from the price + credit :: Maybe CurrencyAmount, -- credit for upgrade + amount :: CurrencyAmount, -- price - discount - credit + currency :: Text, + expiresAt :: UTCTime, + paymentTo :: ServicePaymentDestination + } + deriving (Show) + data ServicePaymentDestination = SPDCard { provider :: CardProvider,