core, ios, android: drop the store purchase checking state, and hide unpaid records after a week

This commit is contained in:
spaced4ndy
2026-10-05 13:39:16 +04:00
parent 15a1782b76
commit c3629d0266
19 changed files with 101 additions and 204 deletions
-3
View File
@@ -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"
-4
View File
@@ -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 {
+9 -41
View File
@@ -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)
}
}
@@ -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"
@@ -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 ->
@@ -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
}
@@ -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>
-1
View File
@@ -356,7 +356,6 @@ undocumentedCommands =
"APIChatUnread",
"APICheckToken",
"APIClearChat",
"APICloseBadgeInvoice",
"APIConnectContactViaAddress",
"APIConnectPreparedContact",
"APIConnectPreparedGroup",
-1
View File
@@ -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
+2 -6
View File
@@ -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),
+10 -17
View File
@@ -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(
+37 -52
View File
@@ -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"