From c3629d026612e7d6235fac8b812010dc3ae7381c Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:39:16 +0400 Subject: [PATCH] core, ios, android: drop the store purchase checking state, and hide unpaid records after a week --- apps/ios/Shared/Model/AppAPITypes.swift | 3 - apps/ios/Shared/Model/SimpleXAPI.swift | 4 - apps/ios/Shared/Views/Badges/BadgeStore.swift | 50 ++--------- .../Badges/BadgesPurchaseStateView.swift | 15 ++-- apps/ios/Shared/Views/Badges/BadgesView.swift | 6 +- .../chat/simplex/common/model/SimpleXAPI.kt | 9 -- .../simplex/common/views/badges/BadgeStore.kt | 47 ++-------- .../views/badges/BadgesPurchaseStateView.kt | 12 ++- .../simplex/common/views/badges/BadgesView.kt | 11 ++- .../commonMain/resources/MR/base/strings.xml | 4 +- bots/src/API/Docs/Commands.hs | 1 - src/Simplex/Chat/Controller.hs | 1 - src/Simplex/Chat/Library/Commands.hs | 8 +- src/Simplex/Chat/Store/Badges.hs | 27 +++--- .../M20260925_badge_store_receipts.hs | 7 +- .../Store/Postgres/Migrations/chat_schema.sql | 3 +- .../M20260925_badge_store_receipts.hs | 7 +- .../Store/SQLite/Migrations/chat_schema.sql | 1 - tests/Bots/BadgeService/BotTests.hs | 89 ++++++++----------- 19 files changed, 101 insertions(+), 204 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 5a32493d24..7bc4d2d4c7 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -195,7 +195,6 @@ enum ChatCommand: ChatCmdProtocol { case apiRedeemBadgeCode(userId: Int64, code: String) case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment) case apiCreateBadgeInvoice(userId: Int64) - case apiCloseBadgeInvoice(userId: Int64, invoiceId: String) case apiGetBadgeState(userId: Int64) case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) @@ -425,7 +424,6 @@ enum ChatCommand: ChatCmdProtocol { case let .apiPurchaseBadge(userId, echoedInvoiceId, payment): return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))" case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)" - case let .apiCloseBadgeInvoice(userId, invoiceId): return "/_badge invoice close \(userId) \(invoiceId)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): @@ -620,7 +618,6 @@ enum ChatCommand: ChatCmdProtocol { case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" case .apiPurchaseBadge: return "apiPurchaseBadge" case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice" - case .apiCloseBadgeInvoice: return "apiCloseBadgeInvoice" case .apiGetBadgeState: return "apiGetBadgeState" case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b5767f2348..ed7bae201d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2224,10 +2224,6 @@ func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { throw r.unexpected } -func apiCloseBadgeInvoice(_ userId: Int64, _ invoiceId: String) async throws { - try await sendCommandOkResp(.apiCloseBadgeInvoice(userId: userId, invoiceId: invoiceId)) -} - // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened func redeemErrorText(_ error: Error, purchase: Bool) -> String { if case let .error(.badgeRedeemError(e)) = error as? ChatError { diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 885f63a53d..398f4d3f77 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -86,7 +86,6 @@ enum BadgePurchaseOutcome { enum BadgePurchaseState { case issuing case waitingForApproval - case checking } enum BadgeStoreError: Error { @@ -108,8 +107,8 @@ final class BadgeStore: ObservableObject { @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 this run has an open store sheet for, and whose interactive presentation has not returned - @Published private var buying: Set = [] + // whether this run has an open store sheet, or an interactive presentation that has not returned + @Published private var buying = false // 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 = [] @@ -127,12 +126,13 @@ final class BadgeStore: ObservableObject { let held = Set(unfinished.values.compactMap { $0.echoedInvoiceId }) if purchases.contains(where: { $0.invoiceId.map(held.contains) == true }) { return .issuing } if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } - if purchases.contains(where: { $0.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking } return nil } + var checkingPurchases: Bool { !reconciledOnce } + func canBuy(_ userId: Int64?) -> Bool { - reconciledOnce && buying.isEmpty && purchaseState(userId) == nil + reconciledOnce && !buying && purchaseState(userId) == nil } func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) { @@ -204,21 +204,19 @@ final class BadgeStore: ObservableObject { } let invoice = try await apiCreateBadgeInvoice(userId) guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) } - await MainActor.run { _ = buying.insert(invoice) } + await MainActor.run { buying = true } await loadBadgeStateAsync(userId) do { let outcome = try await storePurchase(product, invoiceId) switch outcome { // finished only once the service answers for it, as an unfinished transaction is what the store re-delivers case let .purchased(receipt) where receipt.signatureVerified: await presentPurchase(receipt, interactive: true) - case .purchased, .cancelled: await closeInvoice(userId, invoice) - case .pending: break + case .purchased, .cancelled, .pending: break } - await MainActor.run { _ = buying.remove(invoice) } + await MainActor.run { buying = false } return outcome } catch let error { - await closeInvoice(userId, invoice) - await MainActor.run { _ = buying.remove(invoice) } + await MainActor.run { buying = false } throw error } } @@ -273,39 +271,12 @@ final class BadgeStore: ObservableObject { // at launch, on return to the foreground and on a profile switch, never on a timer func presentUnfinished() async { await listenForTransactions() - var held: Set = [] for await verification in Transaction.unfinished { - if let invoiceId = storeReceipt(verification).echoedInvoiceId { held.insert(invoiceId) } await reconcile(verification) } - await closeAbandoned(held) await MainActor.run { reconciledOnce = true } } - // A record no held transaction names and no purchase here waits on. If the store still charges for one, - // as when this runs between an invoice's creation and its purchase starting, the receipt reopens it. - private func closeAbandoned(_ held: Set) async { - guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } - await loadBadgeStateAsync(userId) - let abandoned = await MainActor.run { - openStorePurchases(userId) - .compactMap { $0.transactionRef == nil ? $0.invoiceId : nil } - .filter { !held.contains($0) && !buying.contains($0) && !waitingForApproval.contains($0) } - } - for invoiceId in abandoned { - await closeInvoice(userId, invoiceId) - } - } - - private func closeInvoice(_ userId: Int64, _ invoiceId: String) async { - do { - try await apiCloseBadgeInvoice(userId, invoiceId) - } catch let error { - logger.error("BadgeStore.closeInvoice: \(responseError(error))") - } - await loadCurrentBadgeState() - } - // transactions the store settles outside a purchase call, such as an approved Ask to Buy @MainActor private func listenForTransactions() { @@ -325,9 +296,6 @@ final class BadgeStore: ObservableObject { await receipt.transaction.finish() } else if receipt.signatureVerified { await presentPurchase(receipt, interactive: false) - } else if let invoiceId = receipt.echoedInvoiceId, - let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) { - await closeInvoice(userId, invoiceId) } } diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift index bb9600651a..84049e0079 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -11,7 +11,8 @@ import SwiftUI struct BadgesPurchaseStateView: View { @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) private var dismiss - let purchaseState: BadgePurchaseState + let title: LocalizedStringKey + let message: LocalizedStringKey var showsAsSheet: Bool = false var body: some View { @@ -64,20 +65,20 @@ struct BadgesPurchaseStateView: View { } } } +} - private var title: LocalizedStringKey { - switch purchaseState { +extension BadgePurchaseState { + var title: LocalizedStringKey { + switch self { case .issuing: "Issuing your badge" case .waitingForApproval: "Waiting for approval" - case .checking: "Checking your purchase" } } - private var message: LocalizedStringKey { - switch purchaseState { + var message: LocalizedStringKey { + switch self { case .issuing: "Your payment is complete. The badge will be added to this profile." case .waitingForApproval: "Nothing has been charged." - case .checking: "The store has not confirmed a purchase yet." } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index fd74c1ae6e..07760a5284 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -28,7 +28,10 @@ struct BadgesView: View { .transition(.opacity) } else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) { // holds the purchase screens' slot, so a consumable cannot be bought twice - BadgesPurchaseStateView(purchaseState: purchaseState, showsAsSheet: showsAsSheet) + BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, showsAsSheet: showsAsSheet) + .transition(.opacity) + } else if store.checkingPurchases { + BadgesPurchaseStateView(title: "Checking your purchases", message: "Purchases not yet delivered are checked first.", showsAsSheet: showsAsSheet) .transition(.opacity) } else { BadgesSupportSimplexView(showsAsSheet: showsAsSheet) @@ -37,6 +40,7 @@ struct BadgesView: View { } .animation(.default, value: shownBadge != nil) .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) + .animation(.default, value: store.checkingPurchases) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3a224f8d0c..eeac652c94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -612,12 +612,6 @@ object ChatController { throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}") } - suspend fun apiCloseBadgeInvoice(rh: Long?, userId: Long, invoiceId: String) { - val r = sendCmd(rh, CC.ApiCloseBadgeInvoice(userId, invoiceId)) - if (r is API.Result && r.res is CR.CmdOk) return - throw Exception("apiCloseBadgeInvoice: unexpected ${r.responseType}") - } - // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened fun redeemErrorText(err: ChatError?, purchase: Boolean): String { if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { @@ -4114,7 +4108,6 @@ sealed class CC { class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC() class ApiCreateBadgeInvoice(val userId: Long): CC() - class ApiCloseBadgeInvoice(val userId: Long, val invoiceId: String): CC() class ApiGetBadgeState(val userId: Long): CC() class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() @@ -4342,7 +4335,6 @@ sealed class CC { is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}" is ApiCreateBadgeInvoice -> "/_badge invoice $userId" - is ApiCloseBadgeInvoice -> "/_badge invoice close $userId $invoiceId" is ApiGetBadgeState -> "/_badge state $userId" is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" @@ -4529,7 +4521,6 @@ sealed class CC { is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" is ApiPurchaseBadge -> "apiPurchaseBadge" is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" - is ApiCloseBadgeInvoice -> "apiCloseBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" 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 a6a633baaf..8f4b0da27b 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 @@ -123,8 +123,7 @@ sealed class BadgePurchaseOutcome { enum class BadgePurchaseState { Issuing, - WaitingForApproval, - Checking + WaitingForApproval } sealed class BadgeStoreError: Exception() { @@ -155,8 +154,8 @@ object BadgeStore { 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 this run has an open store sheet for, and whose interactive presentation has not returned - private val buying = mutableStateOf>(emptySet()) + // whether this run has an open store sheet, or an interactive presentation that has not returned + private val buying = mutableStateOf(false) // by invoice id, so a pending purchase shows only under the profile whose record it names private val waitingForApproval = mutableStateOf>(emptySet()) // set when the first sweep has returned or failed, which is after every held purchase has been presented @@ -173,13 +172,14 @@ object BadgeStore { return when { purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval - purchases.any { it.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking else -> null } } + val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value + fun canBuy(userId: Long?): Boolean = - badgeStoreAvailable && reconciledOnce.value && buying.value.isEmpty() && purchaseState(userId) == null + badgeStoreAvailable && reconciledOnce.value && !buying.value && purchaseState(userId) == null fun setStorePurchases(rhId: Long?, userId: Long, purchases: List) { storePurchases.value = Triple(rhId, userId, purchases) @@ -242,22 +242,18 @@ object BadgeStore { val rhId = chatModel.remoteHostId() val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId) - withContext(Dispatchers.Main) { buying.value += invoiceId } + withContext(Dispatchers.Main) { buying.value = true } chatModel.controller.loadBadgeState(rhId) try { val outcome = storePurchase(id, invoiceId) when (outcome) { // finished only once the service answers for it, as an unfinished purchase is what the store re-delivers is BadgePurchaseOutcome.Purchased -> presentPurchase(outcome.receipt, interactive = true) - is BadgePurchaseOutcome.Cancelled -> closeInvoice(userId, invoiceId) - is BadgePurchaseOutcome.Pending -> {} + is BadgePurchaseOutcome.Cancelled, is BadgePurchaseOutcome.Pending -> {} } return outcome - } catch (e: Exception) { - closeInvoice(userId, invoiceId) - throw e } finally { - withContext(Dispatchers.Main + NonCancellable) { buying.value -= invoiceId } + withContext(Dispatchers.Main + NonCancellable) { buying.value = false } } } @@ -331,37 +327,12 @@ object BadgeStore { } // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } - val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet() purchases.forEach { reconcile(it) } - closeAbandoned(held) } finally { withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } } } - // A record no held purchase names and no purchase here waits on. If the store still charges for one, - // as when this runs between an invoice's creation and its purchase starting, the receipt reopens it. - private suspend fun closeAbandoned(held: Set) { - val userId = chatModel.currentUser.value?.userId ?: return - chatModel.controller.loadBadgeState(chatModel.remoteHostId()) - val abandoned = withContext(Dispatchers.Main) { - openStorePurchases(userId) - .mapNotNull { if (it.transactionRef == null) it.invoiceId else null } - .filter { it !in held && it !in buying.value && it !in waitingForApproval.value } - } - abandoned.forEach { closeInvoice(userId, it) } - } - - private suspend fun closeInvoice(userId: Long, invoiceId: String) { - val rhId = chatModel.remoteHostId() - try { - chatModel.controller.apiCloseBadgeInvoice(rhId, userId, invoiceId) - } catch (e: Exception) { - Log.e(TAG, "BadgeStore.closeInvoice: ${e.message}") - } - chatModel.controller.loadBadgeState(rhId) - } - suspend fun reconcile(outcome: BadgePurchaseOutcome) { when (outcome) { is BadgePurchaseOutcome.Purchased -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt index 7ce4e8aa01..0c6425a4a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton import chat.simplex.res.MR @Composable -fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState, onDismiss: () -> Unit) { +fun BadgesPurchaseStateView(title: StringResource, message: StringResource, onDismiss: () -> Unit) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -24,7 +24,7 @@ fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState, onDismiss: () -> maxIntrinsicSize = true, ) { Text( - stringResource(purchaseState.title), + stringResource(title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -33,7 +33,7 @@ fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState, onDismiss: () -> ) Text( - stringResource(purchaseState.message), + stringResource(message), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() @@ -56,16 +56,14 @@ fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState, onDismiss: () -> } } -private val BadgePurchaseState.title: StringResource +val BadgePurchaseState.title: StringResource get() = when (this) { BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title - BadgePurchaseState.Checking -> MR.strings.badges_checking_title } -private val BadgePurchaseState.message: StringResource +val BadgePurchaseState.message: StringResource get() = when (this) { BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body - BadgePurchaseState.Checking -> MR.strings.badges_checking_body } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index ef2ecae607..b48e120eca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -9,6 +9,7 @@ import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.res.MR @OptIn(ExperimentalAnimationApi::class) @Composable @@ -19,15 +20,17 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { // the card look is a modal setting, so the modal is composed here to follow the screen shown ModalView(close, cardScreen = shownBadge != null) { AnimatedContent( - targetState = shownBadge to BadgeStore.purchaseState(chatModel.currentUser.value?.userId), + targetState = Triple(shownBadge, BadgeStore.purchaseState(chatModel.currentUser.value?.userId), BadgeStore.checkingPurchases), transitionSpec = { fadeIn() with fadeOut() }, - contentKey = { (badgeState, purchaseState) -> (badgeState != null) to purchaseState } - ) { (badgeState, purchaseState) -> + contentKey = { (badgeState, purchaseState, checkingPurchases) -> Triple(badgeState != null, purchaseState, checkingPurchases) } + ) { (badgeState, purchaseState, checkingPurchases) -> if (badgeState != null) { BadgesYourBadgeView(badgeState, modalManager) } else if (purchaseState != null) { // holds the purchase screens' slot, so a consumable cannot be bought twice - BadgesPurchaseStateView(purchaseState, onDismiss = close) + BadgesPurchaseStateView(purchaseState.title, purchaseState.message, onDismiss = close) + } else if (checkingPurchases) { + BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, MR.strings.badges_checking_purchases_body, onDismiss = close) } else { BadgesSupportSimplexView(modalManager, unwindToDepth) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 636fa4845c..5eb521d1c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3189,8 +3189,8 @@ Your payment is complete. The badge will be added to this profile. Waiting for approval Nothing has been charged. - Checking your purchase - The store has not confirmed a purchase yet. + Checking your purchases + Purchases not yet delivered are checked first. Store purchase A store purchase is being processed. Buy in browser diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index c94f5eb51a..ae1789f47e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -356,7 +356,6 @@ undocumentedCommands = "APIChatUnread", "APICheckToken", "APIClearChat", - "APICloseBadgeInvoice", "APIConnectContactViaAddress", "APIConnectPreparedContact", "APIConnectPreparedGroup", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index df1026e852..0ce92d7543 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -662,7 +662,6 @@ data ChatCommand | 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 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 | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first -- episode is last because it is free text: it is the value that makes one occurrence of an diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d36f899abb..98808a3890 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3573,10 +3573,6 @@ processChatCommand cxt nm = \case invoiceId <- UUID.toText <$> liftIO V4.nextRandom _ <- withStore' $ \db -> createBadgeStoreReceipt db g user (Just invoiceId) Nothing now pure $ CRBadgeInvoice user invoiceId - APICloseBadgeInvoice userId invoiceId -> withUserId userId $ \user -> do - now <- liftIO getCurrentTime - withStore' $ \db -> closeBadgeStoreInvoice db user invoiceId now - ok user APIGetBadgeState userId -> withUserId' userId $ \user -> do -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user @@ -5503,7 +5499,8 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now badgeStateResponse :: User -> CM ChatResponse badgeStateResponse user = do badgeState <- getUserBadgeState user - CRBadgeState user badgeState <$> withStore' (`getOpenStorePurchases` user) + now <- badgeNow + CRBadgeState user badgeState <$> withStore' (\db -> getOpenStorePurchases db user now) -- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. getUserBadgeState :: User -> CM (Maybe BadgeState) @@ -6176,7 +6173,6 @@ chatCommandP = "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), "/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP), - "/_badge invoice close " *> (APICloseBadgeInvoice <$> A.decimal <* A.space <*> textP), "/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal), "/_badge state " *> (APIGetBadgeState <$> A.decimal), "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 98686b017b..21d990a520 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -21,7 +21,6 @@ module Simplex.Chat.Store.Badges createBadgeCodeRedemption, getBadgeStoreReceiptUserId, attachBadgeStoreReceipt, - closeBadgeStoreInvoice, getOpenStorePurchases, getBadgeStoreReceipt, createBadgeStoreReceipt, @@ -46,7 +45,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Maybe (isJust, mapMaybe) import Data.Text (Text) -import Data.Time.Clock (UTCTime) +import Data.Time.Clock (UTCTime, addUTCTime, nominalDay) import Simplex.Chat.Badges import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) @@ -123,36 +122,30 @@ 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 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 - "UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, closed_at = NULL WHERE invoice_id = ?" + "UPDATE badge_store_receipts SET provider = ?, transaction_ref = ? WHERE invoice_id = ?" (provider, transactionRef, invoiceId) pure userId_ --- | Only a record no receipt has reached can be closed. -closeBadgeStoreInvoice :: DB.Connection -> User -> Text -> UTCTime -> IO () -closeBadgeStoreInvoice db User {userId} invoiceId now = - DB.execute - db - "UPDATE badge_store_receipts SET closed_at = ? WHERE user_id = ? AND invoice_id = ? AND transaction_ref IS NULL" - (now, userId, invoiceId) - -getOpenStorePurchases :: DB.Connection -> User -> IO [OpenStorePurchase] -getOpenStorePurchases db User {userId} = +getOpenStorePurchases :: DB.Connection -> User -> UTCTime -> IO [OpenStorePurchase] +getOpenStorePurchases db User {userId} now = map (uncurry OpenStorePurchase) <$> DB.query db [sql| SELECT r.invoice_id, r.transaction_ref FROM badge_store_receipts r - WHERE r.user_id = ? AND r.closed_at IS NULL + WHERE r.user_id = ? AND (r.transaction_ref IS NOT NULL OR r.created_at > ?) AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = r.badge_store_receipt_id) ORDER BY r.badge_store_receipt_id |] - (Only userId) + (userId, receiptDueSince) + where + -- a week outlasts an Ask to Buy request (24 hours) and a Play slow payment (days), whose waiting screen + -- needs the record; one a receipt reached is paid for, so it is listed until its badge arrives + receiptDueSince = addUTCTime (negate $ 7 * nominalDay) now getBadgeStoreReceipt :: DB.Connection -> User -> StoreTransactionRef -> IO (Maybe BadgeStash) getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transactionRef} = 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 023434e538..ca60f7f44a 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,9 @@ module Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_store_receipts whe import Data.Text (Text) import Text.RawString.QQ (r) --- | invoice_id is null when a receipt arrives naming no invoice, or one another row already holds: that row --- is then keyed by its transaction alone. 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 receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. 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| @@ -22,7 +22,6 @@ CREATE TABLE badge_store_receipts( purchase_priv_key BYTEA NOT NULL, master_key BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, - closed_at TIMESTAMPTZ, UNIQUE(provider, transaction_ref) ); diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index fee1dd342b..2ae58845e8 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -305,8 +305,7 @@ CREATE TABLE test_chat_schema.badge_store_receipts ( purchase_key bytea NOT NULL, purchase_priv_key bytea NOT NULL, master_key bytea NOT NULL, - created_at timestamp with time zone NOT NULL, - closed_at timestamp with time zone + created_at timestamp with time zone NOT NULL ); 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 7cd44500b1..e4884395f8 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,9 @@ 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 when a receipt arrives naming no invoice, or one another row already holds: that row --- is then keyed by its transaction alone. 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 receipt naming one another row holds: the transaction still has to be +-- creditable, and its reference identifies it. 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| @@ -21,7 +21,6 @@ CREATE TABLE badge_store_receipts( purchase_priv_key BLOB NOT NULL, master_key BLOB NOT NULL, created_at TEXT NOT NULL, - closed_at TEXT, UNIQUE(provider, transaction_ref) ) STRICT; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b47850946a..5484db74ff 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -992,7 +992,6 @@ CREATE TABLE badge_store_receipts( purchase_priv_key BLOB NOT NULL, master_key BLOB NOT NULL, created_at TEXT NOT NULL, - closed_at TEXT, UNIQUE(provider, transaction_ref) ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index ad45fc7498..0cabd45e11 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -147,11 +147,10 @@ badgeServiceTests = do it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile it "should resolve the same receipt to the same record, and replay its credential" testInvoiceSameReceiptTwice it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown - it "should reopen a closed record for a late receipt, and credit it" testInvoiceReopened - it "should close only a record no receipt has reached" testInvoiceClose + it "should attach a late receipt to its aged-out record, and credit the profile that created it" testInvoiceLateReceipt + it "should stop listing a record no receipt has reached once it ages out, and keep listing a presented one" testInvoiceAgedOut it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge it "should refuse a receipt for an invoice while a badge is held, keeping the record for a retry" testInvoiceWhileBadgeHeld - it "should not close another profile's invoice" testInvoiceCloseOtherProfile it "should list only the asking profile's open store purchases" testInvoiceStateOtherProfile badgeProfile :: Profile @@ -1836,7 +1835,7 @@ testInvoiceOtherProfile ps = alice <## "supporter badge - active" alice <##. "expires " (alice "/user alice" showActiveUser alice "alice (Alice, * supporter)" @@ -1851,7 +1850,7 @@ testInvoiceSameReceiptTwice ps = alice <##. "expires " alice ##> purchaseWithInvoice 1 invoiceId supporterPlay alice <## "badge already redeemed" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 testInvoiceUnknown :: HasCallStack => TestParams -> IO () @@ -1866,44 +1865,41 @@ testInvoiceUnknown ps = alice <## "badge redeemed" alice <## "supporter badge - active" alice <##. "expires " - storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True, False)] + storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] -testInvoiceReopened :: HasCallStack => TestParams -> IO () -testInvoiceReopened ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - invoiceId <- createInvoice alice 1 - alice ##> ("/_badge invoice close 1 " <> invoiceId) - alice <## "ok" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, True)] - alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - alice <## "badge redeemed" - alice <## "supporter badge - active" - alice <##. "expires " - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] - -testInvoiceClose :: HasCallStack => TestParams -> IO () -testInvoiceClose ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsStore = store} -> +testInvoiceLateReceipt :: HasCallStack => TestParams -> IO () +testInvoiceLateReceipt ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) alice ##> "/_badge state 1" - alice <## ("store purchase open: invoice " <> invoiceId) - alice ##> ("/_badge invoice close 1 " <> invoiceId) - alice <## "ok" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, True)] - -- the late receipt finds the closed record, so a presented record exists and cannot be closed - let unsettled = googlePayment "badge_supporter_01" googlePendingToken - alice ##> purchaseWithInvoice 1 invoiceId unsettled - alice <## "cannot get badge: badge service error: payment_pending" - alice ##> ("/_badge invoice close 1 " <> invoiceId) - alice <## "ok" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] - settlePending store - alice ##> purchaseWithInvoice 1 invoiceId unsettled - alice <## "badge redeemed" + (alice "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + alice <## "[user: alice] badge redeemed" alice <## "supporter badge - active" alice <##. "expires " + (alice TestParams -> IO () +testInvoiceAgedOut ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock = clock} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + unpaid <- createInvoice alice 1 + presented <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) + alice <## "cannot get badge: badge service error: payment_pending" + alice ##> "/_badge state 1" + alice <## ("store purchase open: invoice " <> unpaid) + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay) + alice ##> "/_badge state 1" + alice <##. ("store purchase open: invoice " <> presented <> ", transaction ") + (alice TestParams -> IO () testInvoiceRefusedBeforeCharge ps = do @@ -1928,20 +1924,9 @@ testInvoiceWhileBadgeHeld ps = redeemFirstBadge alice code alice ##> purchaseWithInvoice 1 invoiceId supporterPlay alice <## "cannot get badge: badge already active" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] rowCount cc "sx_badge_service_payments" `shouldReturn` 0 -testInvoiceCloseOtherProfile :: HasCallStack => TestParams -> IO () -testInvoiceCloseOtherProfile ps = - withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> - withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - invoiceId <- createInvoice alice 1 - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> ("/_badge invoice close 2 " <> invoiceId) - alice <## "ok" - storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), False, False)] - testInvoiceStateOtherProfile :: HasCallStack => TestParams -> IO () testInvoiceStateOtherProfile ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> @@ -1967,10 +1952,10 @@ createInvoice cc userId = do purchaseWithInvoice :: Int -> String -> ServicePayment -> String purchaseWithInvoice userId invoiceId payment = "/_badge purchase " <> show userId <> " invoice=" <> invoiceId <> " " <> paymentArg payment --- | Each store purchase record's profile, invoice id, whether a receipt reached it, and whether it is closed. -storeReceiptRows :: ChatController -> IO [(Int64, Maybe Text, Bool, Bool)] +-- | Each store purchase record's profile, invoice id, and whether a receipt reached it. +storeReceiptRows :: ChatController -> IO [(Int64, Maybe Text, Bool)] storeReceiptRows ChatController {chatStore} = withTransaction chatStore $ \db -> DB.query_ db $ - "SELECT user_id, invoice_id, transaction_ref IS NOT NULL, closed_at IS NOT NULL " + "SELECT user_id, invoice_id, transaction_ref IS NOT NULL " <> "FROM badge_store_receipts ORDER BY badge_store_receipt_id"