diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 7b3c38533a..885f63a53d 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -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 = [] // 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 = [] - // 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] = [:] diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index 8a760f0e41..a6a633baaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -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>(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>(emptyMap()) // core's open store purchases for the profile they were read for: core knows whose a purchase is private val storePurchases = mutableStateOf>?>(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>(emptySet()) // by invoice id, so a pending purchase shows only under the profile whose record it names private val waitingForApproval = mutableStateOf>(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 diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs index e0644ad291..b623944c46 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -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, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 41a97291cf..63191fb642 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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 diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs index cc76a2e290..fe05ecbca4 100644 --- a/src/Simplex/Chat/PaymentService/Types.hs +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -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, diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index f8a956841a..98686b017b 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs index de14f2676b..1412a95ef2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs @@ -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| diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs index 7a92cdb514..073749db73 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs @@ -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|