mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-05 20:57:44 +00:00
ios, android: show a pending badge purchase only on the profile that made it, and keep the buyer's alert
This commit is contained in:
@@ -2481,6 +2481,12 @@ func loadBadgeStateAsync(_ userId: Int64) async {
|
||||
}
|
||||
}
|
||||
|
||||
func loadCurrentBadgeState() async {
|
||||
if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
|
||||
await loadBadgeStateAsync(userId)
|
||||
}
|
||||
}
|
||||
|
||||
private func getUserChatDataAsync(keepingChatId: String?) async throws {
|
||||
let m = ChatModel.shared
|
||||
let tm = ChatTagsModel.shared
|
||||
|
||||
@@ -70,13 +70,15 @@ struct BadgeStoreReceipt {
|
||||
let signatureVerified: Bool
|
||||
let transaction: Transaction
|
||||
|
||||
// core mints the id in lower case, and UUID formats it in upper case
|
||||
var echoedInvoiceId: String? { invoiceId?.uuidString.lowercased() }
|
||||
var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) }
|
||||
}
|
||||
|
||||
// core mints the id in lower case, and UUID formats it in upper case
|
||||
func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() }
|
||||
|
||||
enum BadgePurchaseOutcome {
|
||||
case purchased(BadgeStoreReceipt)
|
||||
case pending
|
||||
case pending(invoiceId: String?)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
@@ -107,10 +109,12 @@ final class BadgeStore: ObservableObject {
|
||||
// invoices whose purchase this session is still waiting on the store for
|
||||
@Published private var buying: Set<String> = []
|
||||
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
|
||||
@Published private var waitingForApproval = false
|
||||
// by invoice id, so a pending purchase shows only under the profile whose record it names
|
||||
@Published private var waitingForApproval: Set<String> = []
|
||||
// until the store has been asked once, a purchase made while the app was not running is unknown
|
||||
@Published private var reconciledOnce = false
|
||||
private var presenting: Set<UInt64> = []
|
||||
// whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it
|
||||
private var presenting: [UInt64: Bool] = [:]
|
||||
private var transactionUpdates: Task<Void, Never>? = nil
|
||||
|
||||
private init() {}
|
||||
@@ -119,7 +123,7 @@ final class BadgeStore: ObservableObject {
|
||||
let purchases = openStorePurchases(userId)
|
||||
let held = Set(unfinished.values.compactMap { $0.echoedInvoiceId })
|
||||
if purchases.contains(where: { $0.invoiceId.map(held.contains) == true }) { return .issuing }
|
||||
if waitingForApproval { return .waitingForApproval }
|
||||
if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval }
|
||||
if purchases.contains(where: { $0.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking }
|
||||
return nil
|
||||
}
|
||||
@@ -222,18 +226,20 @@ final class BadgeStore: ObservableObject {
|
||||
switch try await product.purchase(options: [.appAccountToken(invoiceId)]) {
|
||||
case let .success(verification): return .purchased(storeReceipt(verification))
|
||||
case .pending:
|
||||
await MainActor.run { waitingForApproval = true }
|
||||
return .pending
|
||||
let pending = coreInvoiceId(invoiceId)
|
||||
await MainActor.run { _ = waitingForApproval.insert(pending) }
|
||||
return .pending(invoiceId: pending)
|
||||
case .userCancelled: return .cancelled
|
||||
@unknown default: throw BadgeStoreError.unknownPurchaseResult
|
||||
}
|
||||
}
|
||||
|
||||
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
|
||||
func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async {
|
||||
private func presentPurchase(_ receipt: BadgeStoreReceipt, interactive: Bool) async {
|
||||
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }),
|
||||
await claim(receipt)
|
||||
await claim(receipt, interactive: interactive)
|
||||
else { return }
|
||||
var alertText: String? = nil
|
||||
do {
|
||||
switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) {
|
||||
case let .redeemed(user, badgeState):
|
||||
@@ -253,18 +259,14 @@ final class BadgeStore: ObservableObject {
|
||||
logger.error("BadgeStore.presentPurchase: \(responseError(error))")
|
||||
let refused = badgeReceiptRefused(error)
|
||||
if refused { await finish(receipt) }
|
||||
if interactive {
|
||||
let text = redeemErrorText(error)
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Purchase error", comment: "alert title"),
|
||||
message: refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
|
||||
)
|
||||
}
|
||||
}
|
||||
let text = redeemErrorText(error)
|
||||
alertText = refused ? text : text + "\n\n" + NSLocalizedString("The purchase will be retried, and the badge will arrive.", comment: "alert message")
|
||||
}
|
||||
await loadCurrentBadgeState()
|
||||
let userWaiting = await MainActor.run { presenting.removeValue(forKey: receipt.transactionId) == true }
|
||||
if userWaiting, let alertText {
|
||||
await MainActor.run { showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: alertText) }
|
||||
}
|
||||
await loadBadgeStateAsync(userId)
|
||||
await MainActor.run { _ = presenting.remove(receipt.transactionId) }
|
||||
}
|
||||
|
||||
// at launch and on return to the foreground, never on a timer
|
||||
@@ -287,7 +289,7 @@ final class BadgeStore: ObservableObject {
|
||||
let abandoned = await MainActor.run {
|
||||
openStorePurchases(userId)
|
||||
.compactMap { $0.transactionRef == nil ? $0.invoiceId : nil }
|
||||
.filter { !held.contains($0) && !buying.contains($0) }
|
||||
.filter { !held.contains($0) && !buying.contains($0) && !waitingForApproval.contains($0) }
|
||||
}
|
||||
for invoiceId in abandoned {
|
||||
await closeInvoice(userId, invoiceId)
|
||||
@@ -300,7 +302,7 @@ final class BadgeStore: ObservableObject {
|
||||
} catch let error {
|
||||
logger.error("BadgeStore.closeInvoice: \(responseError(error))")
|
||||
}
|
||||
await loadBadgeStateAsync(userId)
|
||||
await loadCurrentBadgeState()
|
||||
}
|
||||
|
||||
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
|
||||
@@ -330,11 +332,15 @@ final class BadgeStore: ObservableObject {
|
||||
|
||||
// one request per transaction: the purchase itself, launch, foreground and the store can each present it
|
||||
@MainActor
|
||||
private func claim(_ receipt: BadgeStoreReceipt) -> Bool {
|
||||
guard presenting.insert(receipt.transactionId).inserted else { return false }
|
||||
private func claim(_ receipt: BadgeStoreReceipt, interactive: Bool) -> Bool {
|
||||
if let waiting = presenting[receipt.transactionId] {
|
||||
presenting[receipt.transactionId] = waiting || interactive
|
||||
return false
|
||||
}
|
||||
presenting[receipt.transactionId] = interactive
|
||||
unfinished[receipt.transactionId] = receipt
|
||||
// an approved Ask to Buy arrives as a transaction, which ends the wait
|
||||
waitingForApproval = false
|
||||
if let invoiceId = receipt.echoedInvoiceId { waitingForApproval.remove(invoiceId) }
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p
|
||||
}
|
||||
|
||||
private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) {
|
||||
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending
|
||||
Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId)
|
||||
Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased(
|
||||
BadgeStoreReceipt(
|
||||
token = purchase.purchaseToken,
|
||||
|
||||
+30
-22
@@ -113,7 +113,7 @@ private val testBadgeProducts: List<BadgeProduct> = listOf(
|
||||
|
||||
sealed class BadgePurchaseOutcome {
|
||||
class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome()
|
||||
object Pending: BadgePurchaseOutcome()
|
||||
class Pending(val invoiceId: String?): BadgePurchaseOutcome()
|
||||
object Cancelled: BadgePurchaseOutcome()
|
||||
}
|
||||
|
||||
@@ -150,11 +150,13 @@ object BadgeStore {
|
||||
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<BadgeStorePurchase>>?>(null)
|
||||
// invoices whose purchase this session is still waiting on the store for
|
||||
private val buying = mutableStateOf<Set<String>>(emptySet())
|
||||
private val waitingForApproval = mutableStateOf(false)
|
||||
// by invoice id, so a pending purchase shows only under the profile whose record it names
|
||||
private val waitingForApproval = mutableStateOf<Set<String>>(emptySet())
|
||||
// until the store has been asked once, a purchase made while the app was not running is unknown
|
||||
private val reconciledOnce = mutableStateOf(false)
|
||||
// whether the purchase the user started waits on the presentation, so its outcome is shown whoever claimed it;
|
||||
// read and written on the main thread only
|
||||
private val presenting = mutableSetOf<String>()
|
||||
private val presenting = mutableMapOf<String, Boolean>()
|
||||
|
||||
fun purchaseState(userId: Long?): BadgePurchaseState? {
|
||||
if (!platform.androidHasPlatformStore) return null
|
||||
@@ -162,7 +164,7 @@ object BadgeStore {
|
||||
val held = unfinished.value.values.mapNotNull { it.invoiceId }.toSet()
|
||||
return when {
|
||||
purchases.any { it.invoiceId?.let(held::contains) == true } -> BadgePurchaseState.Issuing
|
||||
waitingForApproval.value -> BadgePurchaseState.WaitingForApproval
|
||||
purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval
|
||||
purchases.any { it.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking
|
||||
else -> null
|
||||
}
|
||||
@@ -267,15 +269,17 @@ object BadgeStore {
|
||||
} else {
|
||||
platform.androidPurchaseBadge(id, invoiceId)
|
||||
}
|
||||
if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true }
|
||||
if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
|
||||
return outcome
|
||||
}
|
||||
|
||||
// only the purchase the user started may alert: an answer can reveal a profile other than the one on screen
|
||||
suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) {
|
||||
private suspend fun presentPurchase(receipt: BadgeStoreReceipt, interactive: Boolean) {
|
||||
val rhId = chatModel.remoteHostId()
|
||||
val userId = chatModel.currentUser.value?.userId ?: return
|
||||
if (!claim(receipt)) return
|
||||
if (!claim(receipt, interactive)) return
|
||||
var alertText: String? = null
|
||||
var userWaiting = false
|
||||
try {
|
||||
when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) {
|
||||
is BadgePurchaseResult.Redeemed -> {
|
||||
@@ -293,19 +297,17 @@ object BadgeStore {
|
||||
Log.e(TAG, "BadgeStore.presentPurchase: ${r.err?.string}")
|
||||
val refused = badgeReceiptRefused(r.err)
|
||||
if (refused) finish(receipt)
|
||||
if (interactive) {
|
||||
val text = chatModel.controller.redeemErrorText(r.err)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.badges_purchase_error),
|
||||
text = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
|
||||
)
|
||||
}
|
||||
val text = chatModel.controller.redeemErrorText(r.err)
|
||||
alertText = if (refused) text else text + "\n\n" + generalGetString(MR.strings.badges_purchase_will_retry)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
} finally {
|
||||
chatModel.controller.loadBadgeState(rhId)
|
||||
withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) }
|
||||
withContext(NonCancellable) { chatModel.controller.loadBadgeState(chatModel.remoteHostId()) }
|
||||
userWaiting = withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) == true }
|
||||
}
|
||||
if (userWaiting && alertText != null) {
|
||||
AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = alertText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +322,7 @@ object BadgeStore {
|
||||
return
|
||||
}
|
||||
// Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here
|
||||
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.any { it is BadgePurchaseOutcome.Pending } }
|
||||
withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() }
|
||||
val held = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.invoiceId }.toSet()
|
||||
purchases.forEach { reconcile(it) }
|
||||
closeAbandoned(held)
|
||||
@@ -337,7 +339,7 @@ object BadgeStore {
|
||||
val abandoned = withContext(Dispatchers.Main) {
|
||||
openStorePurchases(userId)
|
||||
.mapNotNull { if (it.transactionRef == null) it.invoiceId else null }
|
||||
.filter { it !in held && it !in buying.value }
|
||||
.filter { it !in held && it !in buying.value && it !in waitingForApproval.value }
|
||||
}
|
||||
abandoned.forEach { closeInvoice(userId, it) }
|
||||
}
|
||||
@@ -357,17 +359,23 @@ object BadgeStore {
|
||||
is BadgePurchaseOutcome.Purchased ->
|
||||
if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt)
|
||||
else presentPurchase(outcome.receipt, interactive = false)
|
||||
is BadgePurchaseOutcome.Pending -> withContext(Dispatchers.Main) { waitingForApproval.value = true }
|
||||
is BadgePurchaseOutcome.Pending ->
|
||||
if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId }
|
||||
is BadgePurchaseOutcome.Cancelled -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// one request per purchase: the purchase itself, launch, foreground and the store can each present it
|
||||
private suspend fun claim(receipt: BadgeStoreReceipt): Boolean = withContext(Dispatchers.Main) {
|
||||
if (!presenting.add(receipt.token)) return@withContext false
|
||||
private suspend fun claim(receipt: BadgeStoreReceipt, interactive: Boolean): Boolean = withContext(Dispatchers.Main) {
|
||||
val waiting = presenting[receipt.token]
|
||||
if (waiting != null) {
|
||||
presenting[receipt.token] = waiting || interactive
|
||||
return@withContext false
|
||||
}
|
||||
presenting[receipt.token] = interactive
|
||||
unfinished.value += receipt.token to receipt
|
||||
// a slow payment that completes arrives as a purchase, which ends the wait
|
||||
waitingForApproval.value = false
|
||||
if (receipt.invoiceId != null) waitingForApproval.value -= receipt.invoiceId
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M
|
||||
val purchasing = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
CloseWhenSupportGivesWay(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) {
|
||||
var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
CloseWhenSupportGivesWay(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager) {
|
||||
var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) }
|
||||
|
||||
LaunchedEffect(Unit) { BadgeStore.load() }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
CloseWhenSupportGivesWay(modalManager)
|
||||
|
||||
ColumnWithScrollBar(
|
||||
Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp),
|
||||
|
||||
+3
-2
@@ -99,7 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
|
||||
val code = remember { mutableStateOf(TextFieldValue("")) }
|
||||
val canonicalCode = remember { mutableStateOf<String?>(null) }
|
||||
val submitting = remember { mutableStateOf(false) }
|
||||
CloseWhenPurchaseInFlight(modalManager)
|
||||
CloseWhenSupportGivesWay(modalManager)
|
||||
|
||||
// when the text is unchanged, the field's own value is kept: it carries the cursor position and the
|
||||
// keyboard's composition state, which BasicTextField loses unless they are passed back to it
|
||||
@@ -118,7 +118,8 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
|
||||
withContext(Dispatchers.Main) {
|
||||
submitting.value = false
|
||||
when (outcome) {
|
||||
is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal()
|
||||
// a shown badge closes this screen itself, and a second close would take the screen beneath
|
||||
is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal()
|
||||
is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message)
|
||||
is BadgeRedeemOutcome.Cancelled -> {}
|
||||
}
|
||||
|
||||
+13
-11
@@ -12,11 +12,7 @@ import chat.simplex.common.views.helpers.ModalView
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
|
||||
val shownBadge: BadgeState? = run {
|
||||
if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null
|
||||
val badgeState = BadgeModel.badgeState.value
|
||||
if (badgeState != null && badgeState.shown) badgeState else null
|
||||
}
|
||||
val shownBadge = currentShownBadge()
|
||||
|
||||
// the card look is a modal setting, so the modal is composed here to follow the screen shown
|
||||
ModalView(close, cardScreen = shownBadge != null) {
|
||||
@@ -37,13 +33,19 @@ fun BadgesView(modalManager: ModalManager, close: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
// each purchase screen closes itself when it recomposes with a purchase in flight, as iOS pops them
|
||||
// when Support SimpleX gives way to it
|
||||
fun currentShownBadge(): BadgeState? {
|
||||
if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null
|
||||
val badgeState = BadgeModel.badgeState.value
|
||||
return if (badgeState != null && badgeState.shown) badgeState else null
|
||||
}
|
||||
|
||||
// each purchase screen closes itself when it recomposes with a purchase in flight or a badge shown, as iOS
|
||||
// pops them when Support SimpleX gives way to either
|
||||
@Composable
|
||||
fun CloseWhenPurchaseInFlight(modalManager: ModalManager) {
|
||||
val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null
|
||||
LaunchedEffect(inFlight) {
|
||||
if (inFlight) modalManager.closeModal()
|
||||
fun CloseWhenSupportGivesWay(modalManager: ModalManager) {
|
||||
val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null
|
||||
LaunchedEffect(gaveWay) {
|
||||
if (gaveWay) modalManager.closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ data BadgeServiceErrorCode
|
||||
| BSEPaymentNotEntitled
|
||||
| BSEPaymentPending
|
||||
| BSEProviderUnavailable
|
||||
| BSEProviderNotConfigured
|
||||
| BSEProviderNotConfigured -- no verifier deployed: a dev service, not the store failing to answer
|
||||
| BSERateLimited
|
||||
| BSECodeInvalid
|
||||
| BSECodeUsed
|
||||
@@ -283,8 +283,9 @@ data BadgeState = BadgeState
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | A store purchase this profile started or presented, neither credited nor closed. Neither
|
||||
-- reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
|
||||
-- | One of a profile's open store purchases, neither credited nor closed. The app matches invoiceId
|
||||
-- against the transactions its store still holds; transactionRef is set once a receipt arrived.
|
||||
-- Neither reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
|
||||
data BadgeStorePurchase = BadgeStorePurchase
|
||||
{ invoiceId :: Maybe Text,
|
||||
transactionRef :: Maybe Text
|
||||
|
||||
@@ -985,12 +985,14 @@ CREATE TABLE badge_code_redemptions(
|
||||
CREATE TABLE badge_store_receipts(
|
||||
badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
transaction_ref TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE,
|
||||
provider TEXT,
|
||||
transaction_ref TEXT,
|
||||
purchase_key BLOB NOT NULL,
|
||||
purchase_priv_key BLOB NOT NULL,
|
||||
master_key BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
closed_at TEXT,
|
||||
UNIQUE(provider, transaction_ref)
|
||||
) STRICT;
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
|
||||
@@ -140,7 +140,7 @@ badgeServiceTests = do
|
||||
it "should answer a receipt presented under a second profile as the profile that bought it" testPurchaseSameReceiptOtherProfile
|
||||
it "should deliver a purchase first presented under another profile to that profile" testPurchaseStrandedUnderOtherProfile
|
||||
it "should deliver a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile
|
||||
it "should credit a receipt to the profile that created its invoice, and answer the presenter as delivered" testInvoiceOtherProfile
|
||||
it "should credit a receipt to the profile that created its invoice, and answer as that profile" testInvoiceOtherProfile
|
||||
it "should resolve the same receipt to the same record, and replay its credential" testInvoiceSameReceiptTwice
|
||||
it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown
|
||||
it "should reopen a closed record for a late receipt, and credit it" testInvoiceReopened
|
||||
@@ -1763,7 +1763,9 @@ testInvoiceOtherProfile ps =
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> purchaseWithInvoice 2 invoiceId supporterPlay
|
||||
alice <## "badge purchase delivered to another profile"
|
||||
alice <## "[user: alice] badge redeemed"
|
||||
alice <## "supporter badge - active"
|
||||
alice <##. "expires "
|
||||
(alice </)
|
||||
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
|
||||
alice ##> "/user alice"
|
||||
|
||||
Reference in New Issue
Block a user