core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659)

This commit is contained in:
spaced4ndy
2026-10-08 13:46:27 +00:00
committed by GitHub
parent c0c5ba8393
commit b4f1249436
18 changed files with 174 additions and 129 deletions
+6 -3
View File
@@ -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)")
}
}
}
+4 -3
View File
@@ -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)")
}
+29 -10
View File
@@ -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<Error, Never>()
private var transactionUpdates: Task<Void, Never>? = 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<Transaction>) -> 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
@@ -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)
@@ -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 {
@@ -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<StatementEntry>): 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
}
@@ -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<ChatError>()
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
@@ -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) {
@@ -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) {
@@ -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 {
@@ -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)) {
@@ -3196,8 +3196,6 @@
<string name="badges_waiting_for_approval_title">Waiting for approval</string>
<string name="badges_waiting_for_approval_body">Nothing has been charged.</string>
<string name="badges_checking_purchases_title">Checking your purchases</string>
<string name="badges_store_purchase_title">Store purchase</string>
<string name="badges_store_purchase_processing">A store purchase is being processed.</string>
<string name="badges_buy_in_browser">Buy in browser</string>
<string name="badges_redeem_code_title">Redeem code</string>
<string name="badges_link_confirm_title">Add badge to your profile?</string>
+2 -1
View File
@@ -221,7 +221,8 @@ undocumentedEvents =
"CEvtSndFileRedirectStartXFTP",
"CEvtSndFileStart", -- legacy SMP files
"CEvtSndStandaloneFileComplete",
"CEvtStorePurchaseResolved",
"CEvtStorePurchaseCredited",
"CEvtStorePurchaseRefused",
"CEvtConnectionsDiff",
"CEvtSubscriptionEnd",
"CEvtTerminalEvent",
+3 -2
View File
@@ -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}
+14 -9
View File
@@ -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
+7 -7
View File
@@ -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 =
+2 -1
View File
@@ -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
+58 -37
View File
@@ -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 </)
rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
@@ -2098,7 +2105,7 @@ testInvoiceOtherProfile 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 </)
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)]
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 </)
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)]
@@ -2152,8 +2159,8 @@ testInvoiceAgedOut ps =
alice
<### [ ConsoleString ("store purchase open: invoice " <> 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 </)
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 </)
storeReceiptRows (chatController alice) `shouldReturn` [(1, Nothing, True)]
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)]
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
testStoreReceiptCleanup :: HasCallStack => 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