core, ios, android: state the real reason in the badge purchase comments

This commit is contained in:
spaced4ndy
2026-10-05 13:10:30 +04:00
parent 4b5b239fdb
commit bdc7c64f97
8 changed files with 23 additions and 19 deletions
@@ -103,16 +103,18 @@ final class BadgeStore: ObservableObject {
@Published private var state: LoadState = .notLoaded
private var products: [String: Product] = [:]
// one-time transactions the store holds unfinished: each is a payment taken and not yet credited
// transactions this run has started presenting and not finished - filled by claim, not by reading the
// store, so it is empty at launch until the sweep reaches each one
@Published private var unfinished: [UInt64: BadgeStoreReceipt] = [:]
// core's open store purchases for the profile they were read for: core knows whose a purchase is
@Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil
// invoices whose purchase this session is still waiting on the store for
// invoices this run has an open store sheet for, and whose interactive presentation has not returned
@Published private var buying: Set<String> = []
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
// by invoice id, so a pending purchase shows only under the profile whose record it names
@Published private var waitingForApproval: Set<String> = []
// until the store has been asked once, a purchase made while the app was not running is unknown
// set when the first sweep has returned, which is after every held transaction has been presented
// over the network - until then a purchase made while the app was not running is unknown
@Published private var reconciledOnce = false
// whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it
private var presenting: [UInt64: Bool] = [:]
@@ -150,15 +150,17 @@ object BadgeStore {
private val state = mutableStateOf(LoadState.NotLoaded)
// snapshot state so a composable reading only the products still recomposes when they arrive
private val products = mutableStateOf<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
// one-time purchases the store holds unfinished: each is a payment taken and not yet credited
// purchases this run has started presenting and not finished - filled by claim, not by reading the
// store, so it is empty at launch until the sweep reaches each one
private val unfinished = mutableStateOf<Map<String, BadgeStoreReceipt>>(emptyMap())
// core's open store purchases for the profile they were read for: core knows whose a purchase is
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<OpenStorePurchase>>?>(null)
// invoices whose purchase this session is still waiting on the store for
// invoices this run has an open store sheet for, and whose interactive presentation has not returned
private val buying = mutableStateOf<Set<String>>(emptySet())
// by invoice id, so a pending purchase shows only under the profile whose record it names
private val waitingForApproval = mutableStateOf<Set<String>>(emptySet())
// until the store has been asked once, a purchase made while the app was not running is unknown
// set when the first sweep has returned or failed, which is after every held purchase has been presented
// over the network - until then a purchase made while the app was not running is unknown
private val reconciledOnce = mutableStateOf(false)
// whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it;
// read and written on the main thread only
@@ -25,8 +25,8 @@ import Simplex.Messaging.Util (catchOwn')
import System.Timeout (timeout)
-- | What a store vouches for about one completed transaction.
-- TODO [badges] reconcile with PaymentFunding's PFApple and PFGoogle, which record a payment the
-- service stored rather than what a store attested, when PaymentFunding is built out.
-- TODO [badges] PaymentFunding's PFApple and PFGoogle sketch the same thing and have no producer;
-- delete them, or build them out and reconcile.
data VerifiedStoreTransaction = VerifiedStoreTransaction
{ transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about
productId :: Text,
+1 -1
View File
@@ -660,7 +660,7 @@ data ChatCommand
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
| APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase; without an invoice id, as for one this install did not start, it is credited by transaction reference
| APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase; without an invoice id, as for one the app never started, it is credited by transaction reference
| APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes
| APICloseBadgeInvoice {userId :: UserId, invoiceId :: Text} -- a store purchase that came to nothing
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
-2
View File
@@ -138,8 +138,6 @@ data StoredPayment = StoredPayment
deriving (Show)
-- to review
-- TODO [badges] reconcile PFApple and PFGoogle with the badge service's VerifiedStoreTransaction,
-- which is what a store attested rather than a payment recorded here, when this is built out.
data PaymentFunding
= PFInvoice
{ invoiceId :: InvoiceId,
+2 -2
View File
@@ -123,8 +123,8 @@ attachBadgeStoreReceipt db invoiceId_ txRef@StoreTransactionRef {provider, trans
userId_ <-
maybeFirstRow fromOnly $
DB.query db "SELECT user_id FROM badge_store_receipts WHERE invoice_id = ? AND transaction_ref IS NULL" (Only invoiceId)
-- a closed record is reopened: new keys for a paid transaction would be refused as receipt_used,
-- and the badge the old keys were credited with would be lost
-- a closed record is reopened so the purchase stays on the profile that paid: only a record no
-- receipt has reached can be closed, so its keys were never sent and nothing was credited to them
forM_ userId_ $ \_ ->
DB.execute
db
@@ -6,9 +6,10 @@ module Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_store_receipts whe
import Data.Text (Text)
import Text.RawString.QQ (r)
-- | invoice_id is null for a receipt this install did not start — a reinstall, a restored database, or the
-- same store account on another device. provider and transaction_ref are null until a receipt arrives, and
-- distinct NULLs let several rows await one at once.
-- | invoice_id is null for a purchase the app never started, such as a store offer code, since the stores
-- echo back only an id the app set. A reinstall or another device echoes one this database does not know,
-- and that row is keyed by its transaction instead. provider and transaction_ref are null until a receipt
-- arrives, and distinct NULLs let several rows await one at once.
m20260925_badge_store_receipts :: Text
m20260925_badge_store_receipts =
[r|
@@ -5,9 +5,10 @@ module Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_store_receipts where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
-- | invoice_id is null for a receipt this install did not start — a reinstall, a restored database, or the
-- same store account on another device. provider and transaction_ref are null until a receipt arrives, and
-- distinct NULLs let several rows await one at once.
-- | invoice_id is null for a purchase the app never started, such as a store offer code, since the stores
-- echo back only an id the app set. A reinstall or another device echoes one this database does not know,
-- and that row is keyed by its transaction instead. provider and transaction_ref are null until a receipt
-- arrives, and distinct NULLs let several rows await one at once.
m20260925_badge_store_receipts :: Query
m20260925_badge_store_receipts =
[sql|