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,