From b4f12494367f4e95576704a62df2f889bfc1d604 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:46:27 +0000 Subject: [PATCH] core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659) --- apps/ios/Shared/Model/AppAPITypes.swift | 9 +- apps/ios/Shared/Model/SimpleXAPI.swift | 7 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 39 ++++++-- apps/ios/Shared/Views/Badges/BadgesView.swift | 6 -- .../Views/Badges/BadgesYourBadgeView.swift | 10 -- .../chat/simplex/common/model/SimpleXAPI.kt | 16 ++-- .../simplex/common/views/badges/BadgeStore.kt | 47 ++++++--- .../views/badges/BadgesCheckOrderView.kt | 2 +- .../views/badges/BadgesPurchaseStateView.kt | 2 +- .../simplex/common/views/badges/BadgesView.kt | 12 +-- .../views/badges/BadgesYourBadgeView.kt | 8 -- .../moko-resources/base/strings.xml | 2 - bots/src/API/Docs/Events.hs | 3 +- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Library/Commands.hs | 23 +++-- src/Simplex/Chat/Store/Badges.hs | 14 +-- src/Simplex/Chat/View.hs | 3 +- tests/Bots/BadgeService/BotTests.hs | 95 +++++++++++-------- 18 files changed, 174 insertions(+), 129 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index bf7ee67a7e..7099fc4d7f 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1283,7 +1283,8 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) - case storePurchaseResolved(user: UserRef) + case storePurchaseCredited(user: UserRef, invoiceId: String) + case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) var responseType: String { switch self { @@ -1358,7 +1359,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" - case .storePurchaseResolved: "storePurchaseResolved" + case .storePurchaseCredited: "storePurchaseCredited" + case .storePurchaseRefused: "storePurchaseRefused" } } @@ -1443,7 +1445,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) - case .storePurchaseResolved: return noDetails + case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)") + case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)") } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index bcb592ee1a..2a9c82a966 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -3094,9 +3094,10 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } - case .storePurchaseResolved: - // whichever profile owns it: only the app can finish the store transaction - Task { await BadgeStore.shared.presentUnfinished() } + case let .storePurchaseCredited(user, invoiceId): + Task { await BadgeStore.shared.storePurchaseResolved(user, invoiceId) } + case let .storePurchaseRefused(user, invoiceId, refusal): + Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 0c338655cd..f5da8d62e1 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -111,7 +111,6 @@ final class BadgeStore: ObservableObject { // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again @Published private var reconciledOnce = false - let refusals = PassthroughSubject() private var transactionUpdates: Task? = nil private init() {} @@ -233,7 +232,7 @@ final class BadgeStore: ObservableObject { // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. private func handOver(_ receipt: BadgeStoreReceipt) async throws { guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } - // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer do { switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { case let .held(user, badgeState, storePurchases): @@ -252,22 +251,41 @@ final class BadgeStore: ObservableObject { if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } } } - await resolve(receipt, refusal: nil) + await receipt.transaction.finish() } } catch let error where badgeReceiptRefused(error) { logger.error("BadgeStore.handOver: \(responseError(error))") - await resolve(receipt, refusal: error) + await receipt.transaction.finish() } } - private func resolve(_ receipt: BadgeStoreReceipt, refusal: Error?) async { - await receipt.transaction.finish() - if let refusal { - await MainActor.run { refusals.send(refusal) } + func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async { + await MainActor.run { + if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) { + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: refusal.purchaseText) + } + } + await storePurchaseResolved(user, invoiceId) + } + + func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's + await MainActor.run { + if active(user), let open = storePurchases, open.userId == user.userId { + storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId }) + } + } + for await verification in Transaction.unfinished { + let receipt = storeReceipt(verification) + if receipt.echoedInvoiceId == invoiceId { + await receipt.transaction.finish() + return + } } } - // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + // at launch, on return to the foreground and on a profile switch, never on a timer func presentUnfinished() async { await listenForTransactions() for await verification in Transaction.unfinished { @@ -339,7 +357,8 @@ private func storeReceipt(_ verification: VerificationResult) -> Ba ) } -// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer private func badgeReceiptRefused(_ error: Error) -> Bool { if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { return code == .receiptInvalid || code == .receiptUsed diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index 28fa1a02e8..2d37860c45 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -30,12 +30,6 @@ struct BadgesView: View { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) .transition(.opacity) - .onReceive(store.refusals) { refusal in - // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own - if purchaseState == .issuing { - showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(refusal, purchase: true)) - } - } } else if store.checkingPurchases { BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) .transition(.opacity) diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 6a67dad8c2..60c6a0ab77 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,7 +14,6 @@ struct BadgesYourBadgeView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) private var dismiss @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @ObservedObject private var store = BadgeStore.shared let badgeState: BadgeState var showsAsSheet: Bool = false @@ -79,15 +78,6 @@ struct BadgesYourBadgeView: View { } } } - if store.purchaseState(chatModel.currentUser?.userId) != nil { - Section { - Text("A store purchase is being processed.") - .foregroundColor(theme.colors.secondary) - } header: { - Text("Store purchase") - .foregroundColor(theme.colors.secondary) - } - } if developerTools { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { 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 91424ec8f2..3c0443090b 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 @@ -3612,9 +3612,10 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } - is CR.StorePurchaseResolved -> - // whichever profile owns it: only the app can finish the store purchase - withLongRunningApi { BadgeStore.presentUnfinished() } + is CR.StorePurchaseCredited -> + withLongRunningApi { BadgeStore.storePurchaseResolved(rhId, r.user, r.invoiceId) } + is CR.StorePurchaseRefused -> + withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -6963,7 +6964,8 @@ sealed class CR { @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() - @Serializable @SerialName("storePurchaseResolved") class StorePurchaseResolved(val user: UserRef): CR() + @Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -7156,7 +7158,8 @@ sealed class CR { is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" - is StorePurchaseResolved -> "storePurchaseResolved" + is StorePurchaseCredited -> "storePurchaseCredited" + is StorePurchaseRefused -> "storePurchaseRefused" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7366,7 +7369,8 @@ sealed class CR { is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) - is StorePurchaseResolved -> withUser(user, noDetails()) + is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId") + is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}") is Response -> json is Invalid -> str } 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 ae6ecd3876..6e846678c2 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 @@ -6,10 +6,12 @@ import androidx.compose.runtime.mutableStateOf import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency @@ -157,7 +159,6 @@ object BadgeStore { // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses private val reconciledOnce = mutableStateOf(false) - val refusals = MutableSharedFlow() fun purchaseState(userId: Long?): BadgePurchaseState? { if (!badgeStoreAvailable) return null @@ -292,27 +293,50 @@ object BadgeStore { if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) } } - resolve(receipt, refusal = null) + finish(receipt) } - // a refusal is announced where it reads as its own, so only an unexpected answer reaches the buyer + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer is BadgePurchaseResult.Failed -> if (badgeReceiptRefused(r.err)) { Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") - resolve(receipt, refusal = r.err) + finish(receipt) } else { throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") } } } - private suspend fun resolve(receipt: BadgeStoreReceipt, refusal: ChatError?) { - finish(receipt) - if (refusal != null) { - withContext(Dispatchers.Main) { refusals.emit(refusal) } + suspend fun storePurchaseRefused(rhId: Long?, user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, user) && openStorePurchases(user.userId).any { it.invoiceId == invoiceId }) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = refusal.purchaseText) + } + } + storePurchaseResolved(rhId, user, invoiceId) + } + + suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the purchase is finished whoever owns it, as only the app can, while the cached row is the active profile's + withContext(Dispatchers.Main) { + val (readOn, readFor, purchases) = storePurchases.value ?: return@withContext + if (chatModel.controller.activeUser(rhId, user) && readOn == rhId && readFor == user.userId) { + storePurchases.value = Triple(readOn, readFor, purchases.filter { it.invoiceId != invoiceId }) + } + } + if (rhId != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return + try { + platform.androidUnfinishedBadgePurchases() + .firstNotNullOfOrNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.takeIf { r -> r.invoiceId == invoiceId } } + ?.let { finish(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + // the purchase stays unfinished, and the next sweep hands it over and finishes it + Log.e(TAG, "BadgeStore.storePurchaseResolved: ${e.message}") } } - // at launch, on return to the foreground, on a profile switch and when core resolves a purchase, never on a timer + // at launch, on return to the foreground and on a profile switch, never on a timer suspend fun presentUnfinished() { try { if (!useBadgeTestProducts && platform.androidHasPlatformStore) { @@ -390,7 +414,8 @@ private fun compactPrice(product: BadgeProduct): String { } } -// the codes core refuses a receipt with for good, after which the store may stop re-delivering it +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer private fun badgeReceiptRefused(err: ChatError?): Boolean { val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt index 90f459a520..fbb3d1a017 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { 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 4c808d60a6..4648a12774 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 dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.BadgeIssueFailure import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { 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 d631735e3e..c0050c8406 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 @@ -7,11 +7,9 @@ import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel -import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @OptIn(ExperimentalAnimationApi::class) @Composable @@ -31,14 +29,6 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) { } else if (purchaseState != null) { // holds the purchase screens' slot, so a consumable cannot be bought twice BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) - LaunchedEffect(purchaseState) { - BadgeStore.refusals.collect { refusal -> - // only the issuing screen belongs to the active profile's held purchase, so a refusal shown there reads as its own - if (purchaseState == BadgePurchaseState.Issuing) { - AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = chatModel.controller.redeemErrorText(refusal, purchase = true)) - } - } - } } else if (checkingPurchases) { BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c40a80f774..f6f6ecb12d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -78,14 +78,6 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { } SectionSpacer() } - if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) { - SectionView(stringResource(MR.strings.badges_store_purchase_title)) { - SectionItemView { - Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary) - } - } - SectionSpacer() - } if (appPrefs.developerTools.get()) { val clipboard = LocalClipboardManager.current SectionView(stringResource(MR.strings.badges_credential)) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 280096d38d..2990970884 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -3196,8 +3196,6 @@ Waiting for approval Nothing has been charged. Checking your purchases - Store purchase - A store purchase is being processed. Buy in browser Redeem code Add badge to your profile? diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index e35cd6ffe5..c43b0fe3e4 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -221,7 +221,8 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", - "CEvtStorePurchaseResolved", + "CEvtStorePurchaseCredited", + "CEvtStorePurchaseRefused", "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1c9fd9d68b..e8fe0b44f8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), OpenStorePurchase (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeIssueFailure, BadgeState (..), OpenStorePurchase (..)) import Simplex.Chat.PaymentService (ServicePayment) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -999,7 +999,8 @@ data ChatEvent | CEvtServiceReplySent {connectionId :: AgentConnId} | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} - | CEvtStorePurchaseResolved {user :: User} -- a held store receipt was credited or refused, so its store transaction can be finished + | CEvtStorePurchaseCredited {user :: User, invoiceId :: Text} -- the held store receipt of this invoice was credited, so its store transaction can be finished + | CEvtStorePurchaseRefused {user :: User, invoiceId :: Text, refusal :: BadgeIssueFailure} -- the held store receipt of this invoice was refused | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b309687589..d7c7d70226 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5458,7 +5458,7 @@ runStoreReceiptWorker a userId Worker {doWork} = do forever $ do lift $ waitForWork doWork withWork_ a doWork (getDueReceipt scheduled) $ \case - BadgeReceiptRecord {receiptId, status = RSHeld {stash, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash payment retryDelay + BadgeReceiptRecord {receiptId, status = RSHeld {stash, invoiceId, payment, retryDelay}} -> creditStoreReceipt a userId receiptId stash invoiceId payment retryDelay BadgeReceiptRecord {receiptId} -> notHeld receiptId where getDueReceipt scheduled = @@ -5479,31 +5479,36 @@ runStoreReceiptWorker a userId Worker {doWork} = do atomically $ modifyTVar' scheduled $ S.delete receiptId void $ atomically $ tryPutTMVar doWork () -data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused | SRORetry | SRODeferred +data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused BadgeIssueFailure | SRORetry | SRODeferred -- | Under the owner's badge lock, which keeps a profile's requests to the service serial. -creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Text -> Maybe Int64 -> CM () -creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} payment retryDelay = do +creditStoreReceipt :: AgentClient -> UserId -> Int64 -> BadgeStash -> Maybe Text -> Text -> Maybe Int64 -> CM () +creditStoreReceipt a userId receiptId stash@BadgeStash {masterKey} invoiceId_ payment retryDelay = do ChatConfig {badgeRetryInterval = ri, badgeConsecutiveRetries} <- asks config withRetryIntervalCount (maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) retryDelay) $ \n delay loop -> do liftIO $ waitWhileSuspended a liftIO $ waitForUserNetwork a + -- decided under the lock and acted on outside it: the retry sleeps, and announcing takes the chat lock outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ tryAllErrors attempt >>= \case Right (Right (present_, _)) -> pure $ SROCredited present_ Right (Left (code, retryAfter)) - | storeReceiptRefused code -> SRORefused <$ withStore' (\db -> refuseStoreReceipt db receiptId $ serviceFailure code retryAfter) + | storeReceiptRefused code -> do + let failure = serviceFailure code retryAfter + SRORefused failure <$ withStore' (\db -> refuseStoreReceipt db receiptId failure) | otherwise -> failed ri delay (serviceFailure code retryAfter) retryAfter Left e -> failed ri delay (badgeIssueFailure e) Nothing - -- only a settlement is announced, and the sweep it prompts signals this worker only for a receipt still held, so it cannot loop + -- the invoice id is the app's only key to the store transaction, so without one nothing is emitted and its sweep finishes it case outcome of SROCredited present_ -> do user <- withStore $ \db -> getUser db userId toView . CEvtBadgeChanged user =<< getUserBadgeState user - toView $ CEvtStorePurchaseResolved user - -- last, so a failed broadcast cannot lose the settlement; outside the badge lock, as it takes the chat lock + forM_ invoiceId_ $ toView . CEvtStorePurchaseCredited user + -- last, so a failed broadcast cannot lose the settlement mapM_ presentUserBadgeToContacts present_ - SRORefused -> withStore (`getUser` userId) >>= toView . CEvtStorePurchaseResolved + SRORefused failure -> forM_ invoiceId_ $ \invoiceId -> do + user <- withStore (`getUser` userId) + toView $ CEvtStorePurchaseRefused user invoiceId failure SRORetry | n + 1 < badgeConsecutiveRetries -> loop _ -> pure () where diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index e749a7fc37..28db3e5d67 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -118,7 +118,7 @@ data BadgeReceiptRecord = BadgeReceiptRecord } data BadgeReceiptStatus - = RSHeld {stash :: BadgeStash, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} + = RSHeld {stash :: BadgeStash, invoiceId :: Maybe Text, payment :: Text, nextAttemptAt :: UTCTime, retryDelay :: Maybe Int64} | RSCredited {badgePurchaseId :: Int64} | RSRefused {refusal :: Maybe BadgeIssueFailure} @@ -171,18 +171,18 @@ getReceiptRecord db StoreTransactionRef {provider, transactionRef} = db [sql| SELECT r.badge_store_receipt_id, r.user_id, r.purchase_key, r.purchase_priv_key, r.master_key, - r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error + r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay, p.badge_purchase_id, r.credit_error FROM badge_store_receipts r LEFT JOIN badge_purchases p ON p.badge_store_receipt_id = r.badge_store_receipt_id WHERE r.provider = ? AND r.transaction_ref = ? |] (provider, transactionRef) where - toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = + toReceiptRecord ((receiptId, ownerId, purchaseKey, purchasePrivKey, mk) :. (invoiceId, payment_, nextAttemptAt_, retryDelay, purchaseId_, refusal)) = BadgeReceiptRecord {receiptId, ownerId, status} where status = case (payment_, nextAttemptAt_) of - (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), payment, nextAttemptAt, retryDelay} + (Just payment, Just nextAttemptAt) -> RSHeld {stash = toBadgeStash BSRStoreReceipt (receiptId, purchaseKey, purchasePrivKey, mk), invoiceId, payment, nextAttemptAt, retryDelay} _ -> maybe (RSRefused refusal) RSCredited purchaseId_ -- | The record made when Buy is tapped, before any receipt: the store echoes its invoice id. @@ -220,7 +220,7 @@ getNextHeldStoreReceipt db userId = DB.query db [sql| - SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.next_attempt_at, r.retry_delay + SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.invoice_id, r.payment, r.next_attempt_at, r.retry_delay FROM badge_store_receipts r JOIN users u ON u.user_id = r.user_id WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL @@ -229,8 +229,8 @@ getNextHeldStoreReceipt db userId = |] (Only userId) where - toHeld (stashRow@(receiptId, _, _, _) :. (payment, nextAttemptAt, retryDelay)) = - BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, payment, nextAttemptAt, retryDelay}} + toHeld (stashRow@(receiptId, _, _, _) :. (invoiceId, payment, nextAttemptAt, retryDelay)) = + BadgeReceiptRecord {receiptId, ownerId = userId, status = RSHeld {stash = toBadgeStash BSRStoreReceipt stashRow, invoiceId, payment, nextAttemptAt, retryDelay}} recordStoreReceiptFailure :: DB.Connection -> Int64 -> Int64 -> UTCTime -> BadgeIssueFailure -> IO () recordStoreReceiptFailure db receiptId retryDelay nextAttemptAt failure = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 30211ea181..76c588776c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -484,7 +484,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert - CEvtStorePurchaseResolved u -> ttyUser u ["store purchase resolved"] + CEvtStorePurchaseCredited u invoiceId -> ttyUser u ["store purchase credited: invoice " <> plain invoiceId] + CEvtStorePurchaseRefused u invoiceId refusal -> ttyUser u ["store purchase refused: invoice " <> plain invoiceId <> ", " <> plain (safeDecodeUtf8 $ strEncode refusal)] CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 82d8eeed1c..13e90fe7e2 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -1941,9 +1941,10 @@ testPurchaseBadge :: HasCallStack => TestParams -> IO () testPurchaseBadge ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + let purchase = purchaseWithInvoice 1 invoiceId supporterPlay alice ##> purchase - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] -- the app hands a purchase over until it is answered credited, and one already credited adds nothing alice ##> purchase alice <## "badge already redeemed" @@ -1957,19 +1958,21 @@ testPurchaseBadgeAppStore :: HasCallStack => TestParams -> IO () testPurchaseBadgeAppStore ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleLegendJWS}} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleLegendJWS}) - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: legend"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleLegendJWS} + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: legend"] storePayments cc `shouldReturn` [("apple", Just 7000, Just "USD", 1)] testPurchaseStash :: HasCallStack => TestParams -> IO () testPurchaseStash ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 let records = rowCount (chatController alice) "badge_store_receipts" - refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") + refused = purchaseWithInvoice 1 invoiceId (googlePayment "badge_supporter_01" "not-a-purchase") -- the store does not vouch for it, so it can never be credited, and the record says so alice ##> refused - alice <### [StartsWith (openPurchase ""), "store purchase resolved"] + alice <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_invalid")] alice ##> refused alice <## "cannot get badge: badge service error: receipt_invalid" records `shouldReturn` 1 @@ -1992,14 +1995,16 @@ testPurchaseStashReceiptUsed ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do - let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + -- the store echoes the buyer's invoice on any device the purchase reaches + let purchase = purchaseWithInvoice 1 invoiceId supporterPlay alice ##> purchase - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] bob ##> purchase - bob <### [StartsWith (openPurchase ""), "store purchase resolved"] + bob <### [StartsWith (invoicePurchase "" invoiceId), ConsoleString (refusedLine "" invoiceId "service_error final receipt_used")] bob ##> purchase bob <## "cannot get badge: badge service error: receipt_used" - storeReceiptRows (chatController bob) `shouldReturn` [(1, Nothing, True)] + storeReceiptRows (chatController bob) `shouldReturn` [(1, Just (T.pack invoiceId), True)] heldStoreReceipts (chatController bob) `shouldReturn` 0 rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1 rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 @@ -2033,12 +2038,13 @@ testPurchaseSameReceiptOtherProfile :: HasCallStack => TestParams -> IO () testPurchaseSameReceiptOtherProfile ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) - inAnyOrder alice [[openPurchase ""], creditedLines "" "1: supporter"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] alice ##> "/create user alisa" showActiveUser alice "alisa" -- the store transaction is the device's, so it stays with the profile it was bought under - alice ##> ("/_badge purchase 2 " <> paymentArg supporterPlay) + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay alice <## "[user: alice] badge already redeemed" rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 alice ##> "/p" @@ -2049,17 +2055,18 @@ testPurchaseStrandedUnderOtherProfile ps = do broken <- newIORef True withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do - let purchase userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg supporterPlay + invoiceId <- createInvoice alice 1 + let purchase userId = purchaseWithInvoice userId invoiceId supporterPlay alice ##> purchase 1 - storePurchaseOpen alice "" + alice <##. invoicePurchase "" invoiceId waitStoreReceiptError (chatController alice) "service_error final internal" alice ##> "/create user alisa" showActiveUser alice "alisa" -- handed over again under whichever profile is active, the purchase stays with the keys alice holds alice ##> purchase 2 - storePurchaseOpen alice "[user: alice] " + alice <##. invoicePurchase "[user: alice] " invoiceId writeIORef broken False - mapM_ (alice <##.) $ creditedLines "[user: alice] " "1: supporter" + mapM_ (alice <##.) $ creditedLines "[user: alice] " invoiceId "1: supporter" (alice "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] (alice "/user alice" @@ -2110,7 +2117,7 @@ testInvoiceSameReceiptTwice ps = withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do invoiceId <- createInvoice alice 1 alice ##> purchaseWithInvoice 1 invoiceId supporterPlay - inAnyOrder alice [["store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] alice ##> purchaseWithInvoice 1 invoiceId supporterPlay alice <## "badge already redeemed" storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)] @@ -2125,7 +2132,7 @@ testInvoiceUnknown ps = -- a reinstall or a second device: the store echoes an invoice this database never created let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11" alice ##> purchaseWithInvoice 2 unknown supporterPlay - inAnyOrder alice [["store purchase open: invoice " <> unknown <> ", transaction "], creditedLines "" "1: supporter"] + inAnyOrder alice [[invoicePurchase "" unknown], creditedLines "" unknown "1: supporter"] storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True)] testInvoiceLateReceipt :: HasCallStack => TestParams -> IO () @@ -2137,7 +2144,7 @@ testInvoiceLateReceipt ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> purchaseWithInvoice 2 invoiceId supporterPlay - inAnyOrder alice [["[user: alice] store purchase open: invoice " <> invoiceId <> ", transaction "], creditedLines "[user: alice] " "1: supporter"] + inAnyOrder alice [[invoicePurchase "[user: alice] " invoiceId], creditedLines "[user: alice] " invoiceId "1: supporter"] (alice unpaid), ConsoleString ("store purchase open: invoice " <> presented), - StartsWith ("store purchase open: invoice " <> refused <> ", transaction "), - "store purchase resolved" + StartsWith (invoicePurchase "" refused), + ConsoleString (refusedLine "" refused "service_error final receipt_invalid") ] alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken) alice <## ("store purchase open: invoice " <> unpaid) @@ -2216,11 +2223,12 @@ testStoreReceiptRetried ps = do broken <- newIORef True withBadgeServiceVerifier ps (brokenGoogle broken) $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do - alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) - storePurchaseOpen alice "" + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <##. invoicePurchase "" invoiceId waitStoreReceiptError (chatController alice) "service_error final internal" writeIORef broken False - mapM_ (alice <##.) $ creditedLines "" "1: supporter" + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 testStoreReceiptNotBlocked :: HasCallStack => TestParams -> IO () @@ -2231,8 +2239,9 @@ testStoreReceiptNotBlocked ps = alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable" - alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) - inAnyOrder alice [[openPurchase "", openPurchase ""], creditedLines "" "1: supporter"] + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleSupporterJWS} + inAnyOrder alice [[openPurchase "", invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] heldStoreReceipts (chatController alice) `shouldReturn` 1 testStoreRefusalKept :: HasCallStack => TestParams -> IO () @@ -2243,7 +2252,10 @@ testStoreRefusalKept ps = do withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do let refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase") alice ##> refused - alice <### [StartsWith (openPurchase ""), "store purchase resolved"] + storePurchaseOpen alice "" + -- with no invoice id the app has no key to the store transaction, so the refusal is not announced + waitHeldStoreReceipts (chatController alice) 0 + (alice refused alice <## "cannot get badge: badge service error: receipt_invalid" alice ##> "/_badge state 1" @@ -2264,7 +2276,7 @@ testStoreSettledOtherProfile ps = alice <### [ StartsWith ("[user: alice] store purchase open: invoice " <> shown <> ", transaction "), ConsoleString ("store purchase open: invoice " <> hidden), - "[user: alice] store purchase resolved" + ConsoleString (refusedLine "[user: alice] " shown "service_error final receipt_invalid") ] alice ##> "/_hide user 1 \"password\"" alice <## "user alice:" @@ -2280,11 +2292,12 @@ testStoreReceiptTwoAtOnce :: HasCallStack => TestParams -> IO () testStoreReceiptTwoAtOnce ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do - let handOver = void $ sendChatCmdStr (chatController alice) ("/_badge purchase 1 " <> paymentArg supporterPlay) + invoiceId <- createInvoice alice 1 + let handOver = void $ sendChatCmdStr (chatController alice) (purchaseWithInvoice 1 invoiceId supporterPlay) concurrentlyN_ [handOver, handOver] - mapM_ (alice <##.) $ creditedLines "" "1: supporter" + mapM_ (alice <##.) $ creditedLines "" invoiceId "1: supporter" (alice TestParams -> IO () @@ -2294,9 +2307,11 @@ testStoreReceiptCleanup ps = withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do unpaid <- createInvoice alice 1 alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")) - alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), "store purchase resolved"] + alice <## ("store purchase open: invoice " <> unpaid) + storePurchaseOpen alice "" + waitHeldStoreReceipts (chatController alice) 0 alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) - inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], creditedLines "" "1: supporter"] + alice <### [ConsoleString ("store purchase open: invoice " <> unpaid), StartsWith (openPurchase ""), StartsWith "1: supporter"] alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken)) alice <##. "1: supporter" alice <## ("store purchase open: invoice " <> unpaid) @@ -2435,6 +2450,12 @@ storePurchaseOpen cc userPrefix = cc <##. openPurchase userPrefix openPurchase :: String -> String openPurchase userPrefix = userPrefix <> "store purchase open: invoice none, transaction " +invoicePurchase :: String -> String -> String +invoicePurchase userPrefix invoiceId = userPrefix <> "store purchase open: invoice " <> invoiceId <> ", transaction " + -- | The worker's credit as the owner's terminal shows it: the badge, then the settlement. -creditedLines :: String -> String -> [String] -creditedLines userPrefix badge = [userPrefix <> badge, userPrefix <> "store purchase resolved"] +creditedLines :: String -> String -> String -> [String] +creditedLines userPrefix invoiceId badge = [userPrefix <> badge, userPrefix <> "store purchase credited: invoice " <> invoiceId] + +refusedLine :: String -> String -> String -> String +refusedLine userPrefix invoiceId failure = userPrefix <> "store purchase refused: invoice " <> invoiceId <> ", " <> failure