split invoice

This commit is contained in:
Evgeny @ SimpleX Chat
2026-08-10 21:20:04 +00:00
parent 6a2e03db83
commit 6520851fa0
4 changed files with 43 additions and 31 deletions
+1 -1
View File
@@ -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.
+25 -20
View File
@@ -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": {
@@ -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`<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` |
| `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` | `invoice``invoiceId`, `price`, `discount`?, `credit`?, `amount` (= price discount credit), `currency`, `expiresAt`, `paymentTo``card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount`<br>`badgeType`<br>`months` |
| `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` |
+15 -9
View File
@@ -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,