mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-15 22:01:01 +00:00
core, ios, android: name the resolved store purchase so the app finishes it without a sweep (#7659)
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-6
@@ -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
|
||||
}
|
||||
|
||||
+36
-11
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+1
-11
@@ -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 {
|
||||
|
||||
-8
@@ -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>
|
||||
|
||||
@@ -221,7 +221,8 @@ undocumentedEvents =
|
||||
"CEvtSndFileRedirectStartXFTP",
|
||||
"CEvtSndFileStart", -- legacy SMP files
|
||||
"CEvtSndStandaloneFileComplete",
|
||||
"CEvtStorePurchaseResolved",
|
||||
"CEvtStorePurchaseCredited",
|
||||
"CEvtStorePurchaseRefused",
|
||||
"CEvtConnectionsDiff",
|
||||
"CEvtSubscriptionEnd",
|
||||
"CEvtTerminalEvent",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user