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