From e3e4709381b822ad276a72969d51150fa97bab2a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:46:35 +0000 Subject: [PATCH] ui: buy a badge in the app through the App Store or Google Play (#7608) * ui: buy a badge in the app through the App Store or Google Play * core, ios, android: create the badge store purchase record before the store charges * core, ios, android: fixes from the store lane review * ios, android: show a store purchase's badge only on the profile it was credited to --- apps/ios/Shared/Model/AppAPITypes.swift | 51 +++- apps/ios/Shared/Model/SimpleXAPI.swift | 56 +++- apps/ios/Shared/SimpleXApp.swift | 1 + apps/ios/Shared/Views/Badges/BadgeStore.swift | 253 ++++++++++++++++-- .../Views/Badges/BadgesCheckOrderView.swift | 161 +++++++++++ .../Views/Badges/BadgesChooseBadgeView.swift | 35 +-- .../Views/Badges/BadgesHowLongView.swift | 127 +++------ .../Badges/BadgesPurchaseStateView.swift | 58 ++++ .../Badges/BadgesSupportSimplexView.swift | 53 ++-- apps/ios/Shared/Views/Badges/BadgesView.swift | 6 + .../Views/Badges/BadgesYourBadgeView.swift | 10 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 + apps/ios/SimpleXChat/APITypes.swift | 8 +- .../foss/java/chat/simplex/app/PlayStore.kt | 6 + .../google/java/chat/simplex/app/PlayStore.kt | 66 +++-- .../main/java/chat/simplex/app/SimplexApp.kt | 7 + .../chat/simplex/common/model/SimpleXAPI.kt | 91 ++++++- .../chat/simplex/common/platform/Platform.kt | 3 + .../simplex/common/views/badges/BadgeStore.kt | 234 +++++++++++++++- .../views/badges/BadgesCheckOrderView.kt | 129 +++++++++ .../views/badges/BadgesChooseBadgeView.kt | 14 +- .../common/views/badges/BadgesHowLongView.kt | 116 ++------ .../views/badges/BadgesPurchaseStateView.kt | 65 +++++ .../views/badges/BadgesRedeemCodeView.kt | 1 + .../views/badges/BadgesSupportSimplexView.kt | 30 ++- .../simplex/common/views/badges/BadgesView.kt | 20 +- .../views/badges/BadgesYourBadgeView.kt | 8 + .../commonMain/resources/MR/base/strings.xml | 18 +- bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Responses.hs | 1 + src/Simplex/Chat/Badges/Types.hs | 11 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Library/Commands.hs | 48 +++- src/Simplex/Chat/Store/Badges.hs | 64 ++++- .../M20260925_badge_store_receipts.hs | 6 +- .../M20260925_badge_store_receipts.hs | 6 +- src/Simplex/Chat/View.hs | 9 +- tests/Bots/BadgeService/BotTests.hs | 159 +++++++++++ 38 files changed, 1607 insertions(+), 343 deletions(-) create mode 100644 apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..2844d5b9cf 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -193,6 +193,9 @@ enum ChatCommand: ChatCmdProtocol { case apiStandaloneFileInfo(url: String) // badges 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) @@ -419,6 +422,10 @@ enum ChatCommand: ChatCmdProtocol { case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + 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): @@ -611,6 +618,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" 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" @@ -667,9 +677,13 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString case let .apiRedeemBadgeCode(userId, code): return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) + case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws))) + case let .apiPurchaseBadge(userId, echoedInvoiceId, .google(productId, token)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .google(productId: productId, token: obfuscate(token))) default: return self } } @@ -719,6 +733,34 @@ enum ChatCommand: ChatCmdProtocol { } } +// core parses it with a "type" tag, not the key-per-case form Swift would synthesize +enum ServicePayment: Encodable { + case apple(jws: String) + case google(productId: String, token: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .apple(jws): + try container.encode("apple", forKey: .type) + try container.encode(jws, forKey: .jws) + case let .google(productId, token): + try container.encode("google", forKey: .type) + try container.encode(productId, forKey: .productId) + try container.encode(token, forKey: .token) + } + } + + private enum CodingKeys: String, CodingKey { + case type, jws, productId, token + } +} + +struct BadgeStorePurchase: Decodable { + var invoiceId: String? + var transactionRef: String? +} + // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. // Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { @@ -1051,7 +1093,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) - case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeInvoice(user: UserRef, invoiceId: String) + case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]?) case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { @@ -1105,6 +1148,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveImported: "archiveImported" case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" + case .badgeInvoice: "badgeInvoice" case .badgeState: "badgeState" case .badgeLedger: "badgeLedger" } @@ -1161,7 +1205,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") - case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId) + case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))") case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..08f1bb12ab 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2196,11 +2196,44 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: throw r.unexpected } +enum BadgePurchaseResult { + case redeemed(user: User, badgeState: BadgeState?) +} + +// log: false because a store receipt is a bearer secret, like a badge code - it is in the command. +// nil when the user cancels the retry alert, which is offered only when retry is set. +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment, retry: Bool) async throws -> BadgePurchaseResult? { + let cmd = ChatCommand.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment) + let r: APIResult? + if retry { + r = await chatApiSendCmdWithRetry(cmd, log: false) + } else { + let res: APIResult = await chatApiSendCmd(cmd, log: false) + r = res + } + guard let r else { return nil } + switch r { + case let .result(.badgeRedeemed(user, _, _, badgeState)): return .redeemed(user: user, badgeState: badgeState) + default: throw r.unexpected + } +} + +func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { + let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId)) + if case let .badgeInvoice(_, invoiceId) = r { return invoiceId } + 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) -> String { if case let .error(.badgeRedeemError(e)) = error as? ChatError { switch e { case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") + case .invalidReceipt: break case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text } @@ -2212,15 +2245,15 @@ func redeemErrorText(_ error: Error) -> String { return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } -func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { +func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]) { let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } -func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { +func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [BadgeStorePurchase]) { let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } @@ -2232,7 +2265,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws - func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, _) = r { return badgeState } throw r.unexpected } @@ -2361,6 +2394,7 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + Task { await BadgeStore.shared.presentUnfinished() } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { @@ -2427,17 +2461,21 @@ func getUserChatData() throws { private func loadBadgeState() { do { let userId = try currentUserId("loadBadgeState") - let badgeState = try apiGetBadgeStateSync(userId) + let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId) BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } -private func loadBadgeStateAsync(_ userId: Int64) async { +func loadBadgeStateAsync(_ userId: Int64) async { do { - let badgeState = try await apiGetBadgeState(userId) - await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + let (badgeState, storePurchases) = try await apiGetBadgeState(userId) + await MainActor.run { + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) + } } catch let error { logger.error("loadBadgeState: \(responseError(error))") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 28aa7e4002..654a31c001 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -101,6 +101,7 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + Task { await BadgeStore.shared.presentUnfinished() } if let url = chatModel.appOpenUrlLater { await MainActor.run { chatModel.appOpenUrlLater = nil diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index c31e45439a..e9de68f82a 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import StoreKit +import SimpleXChat // TODO [badges] product ids will come from app config and prices from the badge service catalog; // hardcoded here so the App Store integration can be tested before the purchase API lands. @@ -27,14 +28,25 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in BadgePeriod.allCases.map { badgeProductId(level, $0) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, -// which is how the service learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +let badgeOneTimeProductIds: Set = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) }) + +// subscriptions are for sale once renewals are delivered +let badgePeriodsForSale: [BadgePeriod] = [.oneMonth] + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction. func newBadgeInvoiceId() -> UUID { UUID() } // the page's app flag rides in the fragment, which never reaches the service let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown storefront does not count, as this decides whether the store sees a link out of the app. +var badgeBrowserAllowed: Bool { + SKPaymentQueue.default().storefront?.countryCode == "USA" +} + enum BadgePrice { case loading case price(String) @@ -56,6 +68,10 @@ struct BadgeStoreReceipt { let invoiceId: UUID? let environment: String? 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() } } enum BadgePurchaseOutcome { @@ -64,9 +80,17 @@ enum BadgePurchaseOutcome { case cancelled } +enum BadgePurchaseState { + case issuing + case waitingForApproval + case checking +} + enum BadgeStoreError: Error { case productUnavailable(productId: String) case unknownPurchaseResult + case noActiveProfile + case invalidInvoiceId(String) } final class BadgeStore: ObservableObject { @@ -76,9 +100,44 @@ final class BadgeStore: ObservableObject { @Published private var state: LoadState = .notLoaded private var products: [String: Product] = [:] + // one-time transactions the store holds unfinished: each is a payment taken and not yet credited + @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: [BadgeStorePurchase])? = nil + // invoices whose purchase this session is still waiting on the store for + @Published private var buying: Set = [] + // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing + @Published private var waitingForApproval = false + // 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 = [] + private var transactionUpdates: Task? = nil private init() {} + func purchaseState(_ userId: Int64?) -> BadgePurchaseState? { + 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.transactionRef == nil && $0.invoiceId.map(buying.contains) != true }) { return .checking } + return nil + } + + func canBuy(_ userId: Int64?) -> Bool { + reconciledOnce && buying.isEmpty && purchaseState(userId) == nil + && !openStorePurchases(userId).contains(where: { $0.transactionRef == nil }) + } + + func setStorePurchases(_ userId: Int64, _ purchases: [BadgeStorePurchase]) { + storePurchases = (userId, purchases) + } + + private func openStorePurchases(_ userId: Int64?) -> [BadgeStorePurchase] { + guard let storePurchases, storePurchases.userId == userId else { return [] } + return storePurchases.purchases + } + func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { switch state { case .notLoaded, .loading: return .loading @@ -122,33 +181,168 @@ final class BadgeStore: ObservableObject { } } - func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> (outcome: BadgePurchaseOutcome, invoiceId: UUID) { let productId = badgeProductId(level, period) guard let product = await MainActor.run(body: { products[productId] }) else { throw BadgeStoreError.productUnavailable(productId: productId) } - switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { - case let .success(verification): - let transaction: Transaction - let signatureVerified: Bool - switch verification { - case let .verified(t): - transaction = t - signatureVerified = true - case let .unverified(t, _): - transaction = t - signatureVerified = false + // a subscription is never sent to core, so nothing would ever finish it later + guard badgeOneTimeProductIds.contains(productId) else { + let invoiceId = newBadgeInvoiceId() + let outcome = try await storePurchase(product, invoiceId) + if case let .purchased(receipt) = outcome { await receipt.transaction.finish() } + return (outcome, invoiceId) + } + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { + throw BadgeStoreError.noActiveProfile + } + let invoice = try await apiCreateBadgeInvoice(userId) + guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) } + await MainActor.run { _ = buying.insert(invoice) } + 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 } - // nothing is delivered in this build, so the transaction is finished right away; once the - // service issues credentials it must only be finished after the credential is stored - await transaction.finish() - return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) - case .pending: return .pending + await MainActor.run { _ = buying.remove(invoice) } + return (outcome, invoiceId) + } catch let error { + await closeInvoice(userId, invoice) + await MainActor.run { _ = buying.remove(invoice) } + throw error + } + } + + private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome { + 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 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 { + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }), + await claim(receipt) + else { return } + do { + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws), retry: interactive) { + case let .redeemed(user, badgeState): + await MainActor.run { + // finished below whichever profile was credited, as it is paid for; only the profile on screen shows it + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + ChatModel.shared.updateUser(user) + if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } + } + } + await finish(receipt) + case nil: + break + } + } catch let error { + 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") + ) + } + } + } + await loadBadgeStateAsync(userId) + await MainActor.run { _ = presenting.remove(receipt.transactionId) } + } + + // at launch and on return to the foreground, never on a timer + func presentUnfinished() async { + await listenForTransactions() + var held: Set = [] + 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) 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) } + } + 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 loadBadgeStateAsync(userId) + } + + // transactions the store settles outside a purchase call, such as an approved Ask to Buy + @MainActor + private func listenForTransactions() { + if transactionUpdates == nil { + transactionUpdates = Task.detached { + for await verification in Transaction.updates { + await BadgeStore.shared.reconcile(verification) + } + } + } + } + + // an unverified one-time transaction is never sent, and left unfinished rather than lost + private func reconcile(_ verification: VerificationResult) async { + let receipt = storeReceipt(verification) + if !badgeOneTimeProductIds.contains(receipt.productId) { + 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) + } + } + + // 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 } + unfinished[receipt.transactionId] = receipt + // an approved Ask to Buy arrives as a transaction, which ends the wait + waitingForApproval = false + return true + } + + private func finish(_ receipt: BadgeStoreReceipt) async { + await receipt.transaction.finish() + await MainActor.run { unfinished[receipt.transactionId] = nil } + } + @MainActor private func startLoading() -> Bool { switch state { @@ -172,15 +366,28 @@ private func compactPrice(_ product: Product) -> String { : product.displayPrice } -private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { +private func storeReceipt(_ verification: VerificationResult) -> BadgeStoreReceipt { + let (t, signatureVerified) = switch verification { + case let .verified(t): (t, true) + case let .unverified(t, _): (t, false) + } var environment: String? = nil if #available(iOS 16.0, *) { environment = t.environment.rawValue } return BadgeStoreReceipt( - jws: jws, + jws: verification.jwsRepresentation, productId: t.productID, transactionId: t.id, invoiceId: t.appAccountToken, environment: environment, - signatureVerified: signatureVerified + signatureVerified: signatureVerified, + transaction: t ) } + +// the only answers after which the receipt will never be credited, so the store may stop re-delivering it +private func badgeReceiptRefused(_ error: Error) -> Bool { + if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { + return code == .receiptInvalid || code == .receiptUsed + } + return false +} diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift new file mode 100644 index 0000000000..113665c620 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -0,0 +1,161 @@ +// +// BadgesCheckOrderView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesCheckOrderView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + let period: BadgePeriod + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Check your order") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 12) { + orderRow("Your badge", Text(level.title)) + orderRow("Duration", Text(period.label)) + orderRow("Total", period.priceText(store.price(level, period))) + } + .padding(.top, 20) + + Text("Charged by the App Store to the account on this device.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: period) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } + } + + private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View { + HStack { + Text(title) + .foregroundColor(theme.colors.secondary) + Spacer() + value + } + .font(.body) + } + + private func payButton() -> some View { + let price = store.price(level, period) + let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) + return Button { + purchase() + } label: { + period.payText(price) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + purchasing = true + Task { + do { + let (outcome, invoiceId) = try await store.purchase(level, period) + if case let .purchased(receipt) = outcome { + if !badgeOneTimeProductIds.contains(receipt.productId) { + await MainActor.run { showPurchasedAlert(receipt, invoiceId) } + } else if !receipt.signatureVerified { + await MainActor.run { + alert = SomeAlert( + alert: mkAlert( + title: "Cannot verify this purchase", + message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store." + ), + id: "badgePurchaseUnverified" + ) + } + } + } + await MainActor.run { purchasing = false } + } catch let error { + logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: String(describing: error)) + ), + id: "badgePurchaseError" + ) + } + } + } + } + + // TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered. + private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { + let returnedInvoice: String + if let returned = receipt.invoiceId { + returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" + } else { + returnedInvoice = "none" + } + var lines = [ + "Product: \(receipt.productId)", + "Invoice: \(invoiceId.uuidString)", + "Invoice returned by Apple: \(returnedInvoice)", + "Transaction: \(receipt.transactionId)" + ] + if let environment = receipt.environment { lines.append("Environment: \(environment)") } + lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") + let summary = lines.joined(separator: "\n") + // logged as well as shown: the alert races StoreKit's own sheets, the log always lands + logger.debug("badge purchase succeeded\n\(summary)") + alert = SomeAlert( + alert: Alert( + title: Text("Purchase successful"), + message: Text(verbatim: summary) + ), + id: "badgePurchased" + ) + } +} + +struct BadgesCheckOrderView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesCheckOrderView(level: .supporter, period: .oneMonth) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift index 077c9e5810..7b96d0d1ab 100644 --- a/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift @@ -50,7 +50,6 @@ struct BadgesChooseBadgeView: View { @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false - @State private var howItWorksActive = false var body: some View { GeometryReader { g in @@ -91,8 +90,15 @@ struct BadgesChooseBadgeView: View { VStack(spacing: 10) { continueButton() .padding(.vertical, 10) - howItWorksButton() - .frame(height: 22) + // redeeming a code is here only when Support SimpleX offers the browser instead + Group { + if badgeBrowserAllowed { + RedeemCodeButton() + } else { + Color.clear + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -160,29 +166,6 @@ struct BadgesChooseBadgeView: View { .hidden() } } - - private func howItWorksButton() -> some View { - ZStack { - Button { - howItWorksActive = true - } label: { - HStack(spacing: 4) { - Image(systemName: "info.circle") - Text("How badges protect your privacy").fontWeight(.medium) - } - .font(.body) - } - - NavigationLink(isActive: $howItWorksActive) { - BadgesHowItWorksView() - .modifier(ThemedBackground()) - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() - } - } } struct BadgesChooseBadgeView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index d2783383de..0f741f45fc 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -64,10 +64,8 @@ struct BadgesHowLongView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .monthly - @State private var purchasing = false - // presented from this view, not AlertManager: its host is behind the sheet these views open in - @State private var alert: SomeAlert? + @State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth + @State private var continueActive = false var body: some View { GeometryReader { g in @@ -91,26 +89,19 @@ struct BadgesHowLongView: View { Spacer(minLength: 20) - // fixedSize + maxHeight on the cards so all three match the tallest one - + // fixedSize + maxHeight on the cards so they all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts HStack(alignment: .top, spacing: 12) { - periodCard(.oneMonth) - periodCard(.monthly) - periodCard(.annual) + ForEach(badgePeriodsForSale) { periodCard($0) } } .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) VStack(spacing: 10) { - payButton() + continueButton() .padding(.vertical, 10) - Text(billingFooter) - .font(.footnote) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(height: 22) + BadgeBillingFooter(period: selectedPeriod) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -123,7 +114,6 @@ struct BadgesHowLongView: View { .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) .task { await store.load() } - .alert(item: $alert) { $0.alert } } private func periodCard(_ period: BadgePeriod) -> some View { @@ -166,85 +156,38 @@ struct BadgesHowLongView: View { period == .annual ? store.annualSavings(level) : nil } - private func payButton() -> some View { - let price = store.price(level, selectedPeriod) - let disabled = !price.canPurchase || purchasing - return Button { - purchase() - } label: { - selectedPeriod.payText(price) - } - .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) - .disabled(disabled) - } - - private func purchase() { - let period = selectedPeriod - let invoiceId = newBadgeInvoiceId() - purchasing = true - Task { - do { - let outcome = try await store.purchase(level, period, invoiceId: invoiceId) - await MainActor.run { - purchasing = false - switch outcome { - case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) - case .pending: - alert = SomeAlert( - alert: mkAlert( - title: "Purchase pending", - message: "The purchase is awaiting approval. This build does not deliver purchases approved later." - ), - id: "badgePurchasePending" - ) - case .cancelled: break - } - } - } catch let error { - logger.error("BadgesHowLongView.purchase: \(String(describing: error))") - await MainActor.run { - purchasing = false - alert = SomeAlert( - alert: Alert( - title: Text("Purchase error"), - message: Text(verbatim: String(describing: error)) - ), - id: "badgePurchaseError" - ) - } + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesCheckOrderView(level: level, period: selectedPeriod) + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } +} - // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. - private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { - let returnedInvoice: String - if let returned = receipt.invoiceId { - returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" - } else { - returnedInvoice = "none" - } - var lines = [ - "Product: \(receipt.productId)", - "Invoice: \(invoiceId.uuidString)", - "Invoice returned by Apple: \(returnedInvoice)", - "Transaction: \(receipt.transactionId)" - ] - if let environment = receipt.environment { lines.append("Environment: \(environment)") } - lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") - lines.append("Token: \(receipt.jws.count) bytes") - let summary = lines.joined(separator: "\n") - // logged as well as shown: the alert races StoreKit's own sheets, the log always lands - logger.debug("badge purchase succeeded\n\(summary)") - alert = SomeAlert( - alert: Alert( - title: Text("Purchase successful"), - message: Text(verbatim: summary), - primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, - secondaryButton: .cancel(Text("Ok")) - ), - id: "badgePurchased" - ) +struct BadgeBillingFooter: View { + @EnvironmentObject var theme: AppTheme + let period: BadgePeriod + + var body: some View { + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) } private var billingFooter: LocalizedStringKey { @@ -252,7 +195,7 @@ struct BadgesHowLongView: View { var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 let stubDate = Calendar.current.date(from: comps) ?? Date() let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) - switch selectedPeriod { + switch period { case .monthly, .annual: return "Renews on \(date). Cancel anytime." case .oneMonth: return "Ends on \(date)." } diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift new file mode 100644 index 0000000000..4164692beb --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -0,0 +1,58 @@ +// +// BadgesPurchaseStateView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct BadgesPurchaseStateView: View { + @EnvironmentObject var theme: AppTheme + let purchaseState: BadgePurchaseState + var showsAsSheet: Bool = false + + var body: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + + ProgressView().scaleEffect(2) + + Spacer() + } + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private var title: LocalizedStringKey { + switch purchaseState { + case .issuing: "Issuing your badge" + case .waitingForApproval: "Waiting for approval" + case .checking: "Checking your purchase" + } + } + + private var message: LocalizedStringKey { + switch purchaseState { + case .issuing: "The store has taken the payment. Keeps trying if you close the app." + case .waitingForApproval: "Nothing has been charged." + case .checking: "The store has not confirmed a purchase yet." + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 22cec4a4f4..df13abdc97 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -11,13 +11,14 @@ import SimpleXChat struct BadgesSupportSimplexView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared // set true when presented as a sheet root (from the chat-list banner) — that path doesn't // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top var showsAsSheet: Bool = false @State private var whyBuiltActive = false @State private var howItWorksActive = false @State private var chooseLevelActive = false - @State private var redeemCodeActive = false var body: some View { GeometryReader { g in @@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and howItWorksButton() moves there, leaving this one alone here. howItWorksButton() Spacer(minLength: 0) @@ -47,10 +46,16 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) VStack(spacing: 10) { - buyInBrowserButton() + chooseLevelButton() .padding(.vertical, 10) - redeemCodeButton() - .frame(height: 22) + Group { + if badgeBrowserAllowed { + buyInBrowserButton() + } else { + RedeemCodeButton() + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -65,15 +70,16 @@ struct BadgesSupportSimplexView: View { .navigationBarTitleDisplayMode(.inline) } - // the in-app purchase path, kept compiling and uncalled until payments return after the MVP private func chooseLevelButton() -> some View { - ZStack { + let disabled = !store.canBuy(chatModel.currentUser?.userId) + return ZStack { Button { chooseLevelActive = true } label: { - Text("Choose your level") + Text("Choose your badge") } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) NavigationLink(isActive: $chooseLevelActive) { BadgesChooseBadgeView() @@ -125,7 +131,23 @@ struct BadgesSupportSimplexView: View { } } - private func redeemCodeButton() -> some View { + private func buyInBrowserButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Buy in browser") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } +} + +struct RedeemCodeButton: View { + @EnvironmentObject var theme: AppTheme + @State private var redeemCodeActive = false + + var body: some View { ZStack { Button { redeemCodeActive = true @@ -146,15 +168,6 @@ struct BadgesSupportSimplexView: View { .hidden() } } - - private func buyInBrowserButton() -> some View { - Button { - UIApplication.shared.open(URL(string: badgePageUrl)!) - } label: { - Text("Buy in browser") - } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) - } } struct PhoneSupporterHero: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index df12094a6b..fd74c1ae6e 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct BadgesView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject private var badgeModel = BadgeModel.shared + @ObservedObject private var store = BadgeStore.shared var showsAsSheet: Bool = false private var shownBadge: BadgeState? { @@ -25,12 +26,17 @@ struct BadgesView: View { if let badgeState = shownBadge { BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) .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) + .transition(.opacity) } else { BadgesSupportSimplexView(showsAsSheet: showsAsSheet) .transition(.opacity) } } .animation(.default, value: shownBadge != nil) + .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) } } diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 60c6a0ab77..6a67dad8c2 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,6 +14,7 @@ struct BadgesYourBadgeView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) private var dismiss @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @ObservedObject private var store = BadgeStore.shared let badgeState: BadgeState var showsAsSheet: Bool = false @@ -78,6 +79,15 @@ struct BadgesYourBadgeView: View { } } } + if store.purchaseState(chatModel.currentUser?.userId) != nil { + Section { + Text("A store purchase is being processed.") + .foregroundColor(theme.colors.secondary) + } header: { + Text("Store purchase") + .foregroundColor(theme.colors.secondary) + } + } if developerTools { Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { if let badge = chatModel.currentUser?.profile.localBadge { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d4171b89ae..05a28d21ce 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,8 @@ 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; }; 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; }; + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; }; + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -560,6 +562,8 @@ 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = ""; }; 647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = ""; }; + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = ""; }; + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -1219,6 +1223,8 @@ children = ( 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */, 647C9E2B30666C640032110E /* BadgesHowLongView.swift */, + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */, + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */, 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, @@ -1672,6 +1678,8 @@ 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */, 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */, + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */, + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..acc4939cf8 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -826,6 +826,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum BadgeRedeemError: Decodable, Hashable { case invalidCode + case invalidReceipt case serviceNotConfigured case badgeActive case serviceError(serviceError: BadgeServiceErrorCode) @@ -846,6 +847,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case paymentNotEntitled case paymentPending case providerUnavailable + case providerNotConfigured case rateLimited case codeInvalid case codeUsed @@ -867,6 +869,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case .paymentNotEntitled: "payment_not_entitled" case .paymentPending: "payment_pending" case .providerUnavailable: "provider_unavailable" + case .providerNotConfigured: "provider_not_configured" case .rateLimited: "rate_limited" case .codeInvalid: "code_invalid" case .codeUsed: "code_used" @@ -890,6 +893,7 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case "payment_not_entitled": self = .paymentNotEntitled case "payment_pending": self = .paymentPending case "provider_unavailable": self = .providerUnavailable + case "provider_not_configured": self = .providerNotConfigured case "rate_limited": self = .rateLimited case "code_invalid": self = .codeInvalid case "code_used": self = .codeUsed @@ -911,8 +915,10 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? { case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message") case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message") + case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message") + case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message") case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable, - .paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil + .paymentNotEntitled, .paymentPending, .providerUnavailable, .providerNotConfigured, .unknown: nil } } diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 1d8fc5e151..b93b0b428d 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} @@ -22,3 +23,8 @@ suspend fun loadBadgeProducts(oneTimeIds: List, subscriptio @Suppress("UNUSED_PARAMETER") suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + +@Suppress("UNUSED_PARAMETER") +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} + +suspend fun unfinishedBadgePurchases(): List = emptyList() diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 4075421e52..859958c38a 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry import chat.simplex.common.platform.mainActivity import chat.simplex.common.views.badges.* +import chat.simplex.common.views.helpers.withLongRunningApi import com.android.billingclient.api.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable val client = connectedBadgeBillingClient() val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) - val details = offer.details - val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details) offer.offerToken?.let { productParams.setOfferToken(it) } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productParams.build())) @@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc if (launched.responseCode != BillingClient.BillingResponseCode.OK) { throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) } - val outcome = purchase.await() - if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) - return outcome + return purchase.await() } finally { badgePurchase = null } } +// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment +suspend fun unfinishedBadgePurchases(): List { + val client = connectedBadgeBillingClient() + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build() + val queried = CompletableDeferred>() + client.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(purchases) + } else { + queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } + } + return queried.await().mapNotNull(::badgePurchaseOutcome) +} + private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> - val pending = badgePurchase ?: return@PurchasesUpdatedListener + val pending = badgePurchase + if (pending == null) { + // settled outside a purchase call, such as a slow payment completing + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } } + } + return@PurchasesUpdatedListener + } when { - result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> - pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> { + val outcomes = purchases.mapNotNull(::badgePurchaseOutcome) + pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled) + withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } } + } result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> pending.complete(BadgePurchaseOutcome.Cancelled) else -> @@ -114,10 +137,9 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p } } -private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { - val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled - if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending - return BadgePurchaseOutcome.Purchased( +private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { + Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending + Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", @@ -125,6 +147,7 @@ private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcom invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId ) ) + else -> null } private suspend fun connectedBadgeBillingClient(): BillingClient { @@ -203,21 +226,20 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { return BadgeOffer(id, product, this, offer.offerToken) } -// nothing is delivered in this build, so the purchase is finished right away; once the service issues -// credentials it must only be finished after the credential is stored. One-time products are consumed -// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged -// purchase after 3 days. -private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { +// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase +// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { + val client = connectedBadgeBillingClient() val done = CompletableDeferred() - if (details.productType == BillingClient.ProductType.SUBS) { - val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() - client.acknowledgePurchase(params) { done.complete(it) } - } else { + if (receipt.productId in badgeOneTimeProductIds) { val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } + } else { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() + client.acknowledgePurchase(params) { done.complete(it) } } val result = done.await() if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1a4c836b2e..1e2a802206 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -28,7 +28,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStore import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } + BadgeStore.presentUnfinished() } } Lifecycle.Event.ON_RESUME -> { @@ -352,6 +355,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt) + + override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9100b5ecea..3bb2bd1000 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -27,6 +27,7 @@ import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeStore import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert @@ -587,11 +588,40 @@ object ChatController { } } + // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. + // null when the user cancels the retry alert, which is offered only when retry is set. + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment, retry: Boolean): BadgePurchaseResult? { + val cmd = CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment) + val r = (if (retry) sendCmdWithRetry(rh, cmd, log = false) else sendCmd(rh, cmd, log = false)) ?: return null + return when { + r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Error -> BadgePurchaseResult.Failed(r.err) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}") + BadgePurchaseResult.Failed(null) + } + } + } + + suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String { + val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId)) + if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId + 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?): String { if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { when (val e = err.errorType.badgeRedeemError) { is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) + is BadgeRedeemError.InvalidReceipt -> {} is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } @@ -602,9 +632,9 @@ object ChatController { return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" } - suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair> { val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) - if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } @@ -637,11 +667,14 @@ object ChatController { // Not thrown: a failed badge read must not stop the app starting, and the model is left alone // rather than set to nil, which would read as "no badge". - private suspend fun loadBadgeState(rhId: Long?) { + suspend fun loadBadgeState(rhId: Long?) { try { val userId = currentUserId("loadBadgeState") - val badgeState = apiGetBadgeState(rhId, userId) - withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { + BadgeModel.set(rhId, userId, badgeState) + BadgeStore.setStorePurchases(rhId, userId, storePurchases) + } } catch (e: Exception) { Log.e(TAG, "loadBadgeState: ${e.message}") } @@ -686,6 +719,7 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + withLongRunningApi { BadgeStore.presentUnfinished() } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e @@ -3622,7 +3656,7 @@ object ChatController { getUserChatData(null) } - private fun activeUser(rhId: Long?, user: UserLike): Boolean = + fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { @@ -3884,6 +3918,15 @@ sealed class BadgeRedeemResult { class Failed(val err: ChatError?): BadgeRedeemResult() } +@Serializable +data class BadgeStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null) + +sealed class BadgePurchaseResult { + class Redeemed(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgePurchaseResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -4064,6 +4107,9 @@ sealed class CC { class ApiStandaloneFileInfo(val url: String): CC() // badges 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() @@ -4289,6 +4335,9 @@ sealed class CC { is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" 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" @@ -4473,6 +4522,9 @@ sealed class CC { is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiPurchaseBadge -> "apiPurchaseBadge" + is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" + is ApiCloseBadgeInvoice -> "apiCloseBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" @@ -4497,8 +4549,12 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) + is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) { + is ServicePayment.Apple -> ServicePayment.Apple(obfuscate(payment.jws)) + is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) + }) else -> this } @@ -4532,6 +4588,13 @@ sealed class CC { } } +// encoded as the base type, so the "type" tag core parses by is included +@Serializable +sealed class ServicePayment { + @Serializable @SerialName("apple") class Apple(val jws: String): ServicePayment() + @Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment() +} + fun onOff(b: Boolean): String = if (b) "on" else "off" // /_badge ack takes the kind in core's text encoding, not the JSON tag @@ -6865,7 +6928,8 @@ sealed class CR { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() - @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() @@ -7056,6 +7120,7 @@ sealed class CR { is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" + is BadgeInvoice -> "badgeInvoice" is BadgeStateR -> "badgeState" is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" @@ -7264,7 +7329,8 @@ sealed class CR { is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") - is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeInvoice -> withUser(user, invoiceId) + is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}") is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) @@ -7323,6 +7389,7 @@ sealed class SimplexDomainError { sealed class BadgeRedeemError { val string: String get() = when (this) { is InvalidCode -> "invalidCode" + is InvalidReceipt -> "invalidReceipt" is ServiceNotConfigured -> "serviceNotConfigured" is BadgeActive -> "badgeActive" is ServiceError -> "serviceError ${serviceError.text}" @@ -7331,6 +7398,7 @@ sealed class BadgeRedeemError { is CredentialNotVerified -> "credentialNotVerified" } @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError() @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() @@ -7352,6 +7420,7 @@ sealed class BadgeServiceErrorCode { object PaymentNotEntitled: BadgeServiceErrorCode() object PaymentPending: BadgeServiceErrorCode() object ProviderUnavailable: BadgeServiceErrorCode() + object ProviderNotConfigured: BadgeServiceErrorCode() object RateLimited: BadgeServiceErrorCode() object CodeInvalid: BadgeServiceErrorCode() object CodeUsed: BadgeServiceErrorCode() @@ -7373,6 +7442,7 @@ sealed class BadgeServiceErrorCode { is PaymentNotEntitled -> "payment_not_entitled" is PaymentPending -> "payment_pending" is ProviderUnavailable -> "provider_unavailable" + is ProviderNotConfigured -> "provider_not_configured" is RateLimited -> "rate_limited" is CodeInvalid -> "code_invalid" is CodeUsed -> "code_used" @@ -7392,6 +7462,8 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version) is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase) is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal) + is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid) + is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used) else -> null } @@ -7409,6 +7481,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer { "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled "payment_pending" -> BadgeServiceErrorCode.PaymentPending "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured "rate_limited" -> BadgeServiceErrorCode.RateLimited "code_invalid" -> BadgeServiceErrorCode.CodeInvalid "code_used" -> BadgeServiceErrorCode.CodeUsed diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 7d126096e8..249c07913b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import kotlinx.coroutines.Job import java.io.Closeable @@ -44,6 +45,8 @@ interface PlatformInterface { return emptyList() } suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidUnfinishedBadgePurchases(): List = emptyList() val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index 2c7dfd05f6..de05c87b64 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -1,7 +1,17 @@ package chat.simplex.common.views.badges +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency import java.util.UUID @@ -29,9 +39,14 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap BadgePeriod.entries.map { badgeStoreProductId(level, it) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service -// learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +val badgeOneTimeProductIds: Set = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() + +// subscriptions are for sale once renewals are delivered +val badgePeriodsForSale: List = listOf(BadgePeriod.OneMonth) + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase. fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() // the page's app flag rides in the fragment, which never reaches the service @@ -39,6 +54,16 @@ val badgePageUrl: String = if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true" else "https://badges.simplex.chat/#/tier?app=desktop" +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown country does not count, as this decides whether the store sees a link out of the app. +@Composable +fun badgeBrowserAllowed(): Boolean { + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() } + } + return androidPlayStoreCountry.value == "US" +} + // what the platform store knows about one product; ProductDetails cannot cross into commonMain data class BadgeProduct( val id: BadgeStoreProductId, @@ -92,16 +117,24 @@ sealed class BadgePurchaseOutcome { object Cancelled: BadgePurchaseOutcome() } +enum class BadgePurchaseState { + Issuing, + WaitingForApproval, + Checking +} + sealed class BadgeStoreError: Exception() { class ProductUnavailable(val productId: String): BadgeStoreError() class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() object StoreUnavailable: BadgeStoreError() + object NoActiveProfile: BadgeStoreError() override val message: String get() = when (this) { is ProductUnavailable -> "productUnavailable(productId: $productId)" is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" is StoreUnavailable -> "storeUnavailable" + is NoActiveProfile -> "noActiveProfile" } } @@ -111,6 +144,42 @@ object BadgeStore { private val state = mutableStateOf(LoadState.NotLoaded) // snapshot state so a composable reading only the products still recomposes when they arrive private val products = mutableStateOf>(emptyMap()) + // one-time purchases the store holds unfinished: each is a payment taken and not yet credited + private val unfinished = mutableStateOf>(emptyMap()) + // core's open store purchases for the profile they were read for: core knows whose a purchase is + private val storePurchases = mutableStateOf>?>(null) + // invoices whose purchase this session is still waiting on the store for + private val buying = mutableStateOf>(emptySet()) + private val waitingForApproval = mutableStateOf(false) + // until the store has been asked once, a purchase made while the app was not running is unknown + private val reconciledOnce = mutableStateOf(false) + // read and written on the main thread only + private val presenting = mutableSetOf() + + fun purchaseState(userId: Long?): BadgePurchaseState? { + if (!platform.androidHasPlatformStore) return null + val purchases = openStorePurchases(userId) + 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.transactionRef == null && it.invoiceId?.let(buying.value::contains) != true } -> BadgePurchaseState.Checking + else -> null + } + } + + fun canBuy(userId: Long?): Boolean = + reconciledOnce.value && buying.value.isEmpty() && purchaseState(userId) == null + && openStorePurchases(userId).none { it.transactionRef == null } + + fun setStorePurchases(rhId: Long?, userId: Long, purchases: List) { + storePurchases.value = Triple(rhId, userId, purchases) + } + + private fun openStorePurchases(userId: Long?): List { + val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList() + return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList() + } fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading @@ -151,11 +220,42 @@ object BadgeStore { } } - suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + suspend fun purchase(level: BadgeLevel, period: BadgePeriod): Pair { val id = badgeStoreProductId(level, period) if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) - if (useBadgeTestProducts) { - return BadgePurchaseOutcome.Purchased( + // a subscription is never sent to core, so nothing would ever finish it later + if (id.productId !in badgeOneTimeProductIds) { + val invoiceId = newBadgeInvoiceId() + val outcome = storePurchase(id, invoiceId) + if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt) + return outcome to invoiceId + } + 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 } + 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 -> {} + } + return outcome to invoiceId + } catch (e: Exception) { + closeInvoice(userId, invoiceId) + throw e + } finally { + withContext(Dispatchers.Main + NonCancellable) { buying.value -= invoiceId } + } + } + + private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val outcome = if (useBadgeTestProducts) { + BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = "test-${UUID.randomUUID()}", productId = id.productId, @@ -164,8 +264,121 @@ object BadgeStore { environment = "test products" ) ) + } else { + platform.androidPurchaseBadge(id, invoiceId) + } + if (outcome is BadgePurchaseOutcome.Pending) withContext(Dispatchers.Main) { waitingForApproval.value = true } + 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) { + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: return + if (!claim(receipt)) return + try { + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token), retry = interactive)) { + is BadgePurchaseResult.Redeemed -> { + withContext(Dispatchers.Main) { + // finished below whichever profile was credited, as it is paid for; only the profile on screen shows it + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + chatModel.updateUser(r.user) + if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) + } + } + finish(receipt) + } + is BadgePurchaseResult.Failed -> { + 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) + ) + } + } + null -> {} + } + } finally { + chatModel.controller.loadBadgeState(rhId) + withContext(Dispatchers.Main + NonCancellable) { presenting.remove(receipt.token) } + } + } + + // at launch and on return to the foreground, never on a timer + suspend fun presentUnfinished() { + try { + if (useBadgeTestProducts || !platform.androidHasPlatformStore) return + val purchases = try { + platform.androidUnfinishedBadgePurchases() + } catch (e: Exception) { + Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") + 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 } } + 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) { + 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 } + } + 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 -> + 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.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 + unfinished.value += receipt.token to receipt + // a slow payment that completes arrives as a purchase, which ends the wait + waitingForApproval.value = false + true + } + + private suspend fun finish(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt) + withContext(Dispatchers.Main) { unfinished.value -= receipt.token } + } catch (e: Exception) { + // still unfinished: the store re-delivers it, and the next answer for it finishes it + Log.e(TAG, "BadgeStore.finish: ${e.message}") } - return platform.androidPurchaseBadge(id, invoiceId) } private fun startLoading(): Boolean = when (state.value) { @@ -190,3 +403,10 @@ private fun compactPrice(product: BadgeProduct): String { product.displayPrice } } + +// the only answers after which the receipt will never be credited, so the store may stop re-delivering it +private fun badgeReceiptRefused(err: ChatError?): Boolean { + val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError + val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError + return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt new file mode 100644 index 0000000000..dee2e6270f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -0,0 +1,129 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.MR + +@Composable +fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager) { + val purchasing = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenPurchaseInFlight(modalManager) + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_check_your_order_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Column(Modifier.fillMaxWidth().padding(top = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OrderRow(MR.strings.badges_your_badge, stringResource(level.title)) + OrderRow(MR.strings.badges_order_duration, stringResource(period.label)) + OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period))) + } + + Text( + stringResource(MR.strings.badges_charged_by_play), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, period, purchasing) + BadgeBillingFooter(period) + } + } +} + +@Composable +private fun OrderRow(title: StringResource, value: String) { + Row(Modifier.fillMaxWidth()) { + Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.body1) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + val price = BadgeStore.price(level, period) + val (labelId, labelArg) = period.payLabel(price) + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = labelId, + labelArg = labelArg, + onboarding = null, + enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId), + onclick = { purchase(level, period, purchasing) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + val (outcome, invoiceId) = BadgeStore.purchase(level, period) + if (outcome is BadgePurchaseOutcome.Purchased && outcome.receipt.productId !in badgeOneTimeProductIds) { + showPurchasedAlert(outcome.receipt, invoiceId) + } + purchasing.value = false + } catch (e: Exception) { + Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = e.toString() + ) + } + } +} + +// TODO [badges] store integration diagnostics - replaced by the issued badge once subscriptions are delivered. +private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String) { + val returnedInvoice = when (receipt.invoiceId) { + null -> "none" + invoiceId -> "yes" + else -> "mismatch: ${receipt.invoiceId}" + } + val lines = mutableListOf( + "Product: ${receipt.productId}", + "Invoice: $invoiceId", + "Invoice returned by Google: $returnedInvoice", + "Order: ${receipt.orderId ?: "none"}" + ) + if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") + val summary = lines.joinToString("\n") + Log.d(TAG, "badge purchase succeeded\n$summary") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_successful), + text = summary + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index b8324cc9c9..54ba29fbe1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -58,6 +58,7 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenPurchaseInFlight(modalManager) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -108,11 +109,12 @@ fun BadgesChooseBadgeView(modalManager: ModalManager) { // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). Column(horizontalAlignment = Alignment.CenterHorizontally) { ContinueButton(selectedLevel, modalManager) - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_how_it_works_button), - icon = painterResource(MR.images.ic_info), - onClick = { modalManager.showModal { BadgesHowItWorksView() } } - ) + // redeeming a code is here only when Support SimpleX offers the browser instead + if (badgeBrowserAllowed()) { + RedeemCodeButton(modalManager) + } else { + TextButtonBelowOnboardingButton("", null) + } } } } @@ -154,7 +156,7 @@ private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager labelId = MR.strings.badges_continue, onboarding = null, onclick = { - modalManager.showModal { BadgesHowLongView(selectedLevel) } + modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 2845e3986a..9926e01569 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -10,9 +10,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -68,12 +65,11 @@ enum class BadgePeriod { } @Composable -fun BadgesHowLongView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } - val purchasing = remember { mutableStateOf(false) } - val clipboard = LocalClipboardManager.current +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) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -102,36 +98,41 @@ fun BadgesHowLongView(level: BadgeLevel) { Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + badgePeriodsForSale.forEach { period -> + PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } } Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a - // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod, purchasing, clipboard) - Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), - Modifier.padding(vertical = 5.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.secondary, - textAlign = TextAlign.Center - ) - } + ContinueButton(level, selectedPeriod, modalManager) + BadgeBillingFooter(selectedPeriod) } } } +// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a +// TextButton so the footer has no hover/click affordance. +@Composable +fun BadgeBillingFooter(period: BadgePeriod) { + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(period)).format(stubBillingDate()), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod @@ -173,76 +174,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null -// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and -// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val price = BadgeStore.price(level, selectedPeriod) - val (labelId, labelArg) = selectedPeriod.payLabel(price) +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = labelId, - labelArg = labelArg, + labelId = MR.strings.badges_continue, onboarding = null, - enabled = price.canPurchase && !purchasing.value, - onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } - ) -} - -private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val invoiceId = newBadgeInvoiceId() - purchasing.value = true - // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls - withLongRunningApi { - try { - val outcome = BadgeStore.purchase(level, period, invoiceId) - purchasing.value = false - when (outcome) { - is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) - is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_pending), - text = generalGetString(MR.strings.badges_purchase_pending_desc) - ) - is BadgePurchaseOutcome.Cancelled -> {} - } - } catch (e: Exception) { - Log.e(TAG, "BadgesHowLongView.purchase: ${e.stackTraceToString()}") - purchasing.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = e.toString() - ) + onclick = { + modalManager.showModal { BadgesCheckOrderView(level, selectedPeriod, modalManager) } } - } -} - -// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. -private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { - val returnedInvoice = when (receipt.invoiceId) { - null -> "none" - invoiceId -> "yes" - else -> "mismatch: ${receipt.invoiceId}" - } - val lines = mutableListOf( - "Product: ${receipt.productId}", - "Invoice: $invoiceId", - "Invoice returned by Google: $returnedInvoice", - "Order: ${receipt.orderId ?: "none"}" - ) - if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") - lines.add("Token: ${receipt.token.length} bytes") - val summary = lines.joinToString("\n") - Log.d(TAG, "badge purchase succeeded\n$summary") - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.badges_purchase_successful), - text = summary, - confirmText = "Copy token", - onConfirm = { - clipboard.setText(AnnotatedString(receipt.token)) - showToast(generalGetString(MR.strings.copied)) - }, - dismissText = generalGetString(MR.strings.ok), - parseHtml = false ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt new file mode 100644 index 0000000000..787c1d62f3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -0,0 +1,65 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.res.MR + +@Composable +fun BadgesPurchaseStateView(purchaseState: BadgePurchaseState) { + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(purchaseState.title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(purchaseState.message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.weight(1f)) + + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + + Spacer(Modifier.weight(1f)) + } +} + +private 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 + 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 + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 7494229aaa..a086615846 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -99,6 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } + CloseWhenPurchaseInFlight(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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index dc65897547..075685649f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. HowItWorksButton(modalManager) Spacer(Modifier.weight(1f)) @@ -66,19 +64,28 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - BuyInBrowserButton(modalManager) - RedeemCodeButton(modalManager) + if (platform.androidHasPlatformStore) { + ChooseLevelButton(modalManager) + if (badgeBrowserAllowed()) { + BuyInBrowserTextButton() + } else { + RedeemCodeButton(modalManager) + } + } else { + BuyInBrowserButton(modalManager) + RedeemCodeButton(modalManager) + } } } } -// the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable private fun ChooseLevelButton(modalManager: ModalManager) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_choose_your_level, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, + enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId), onclick = { modalManager.showModal { BadgesChooseBadgeView(modalManager) } } @@ -111,13 +118,22 @@ private fun HowItWorksButton(modalManager: ModalManager) { } @Composable -private fun RedeemCodeButton(modalManager: ModalManager) { +fun RedeemCodeButton(modalManager: ModalManager) { TextButtonBelowOnboardingButton( text = stringResource(MR.strings.badges_redeem_code_button), onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } } ) } +@Composable +private fun BuyInBrowserTextButton() { + val uriHandler = LocalUriHandler.current + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_buy_in_browser), + onClick = { uriHandler.openUriCatching(badgePageUrl) } + ) +} + @Composable private fun BuyInBrowserButton(modalManager: ModalManager) { val uriHandler = LocalUriHandler.current diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index ff2660497e..8ed6f481c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.badges import androidx.compose.animation.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel @@ -19,9 +20,16 @@ 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, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> + AnimatedContent( + targetState = shownBadge to BadgeStore.purchaseState(chatModel.currentUser.value?.userId), + transitionSpec = { fadeIn() with fadeOut() }, + contentKey = { (badgeState, purchaseState) -> (badgeState != null) to purchaseState } + ) { (badgeState, purchaseState) -> if (badgeState != null) { BadgesYourBadgeView(badgeState, modalManager) + } else if (purchaseState != null) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(purchaseState) } else { BadgesSupportSimplexView(modalManager) } @@ -29,6 +37,16 @@ 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 +@Composable +fun CloseWhenPurchaseInFlight(modalManager: ModalManager) { + val inFlight = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null + LaunchedEffect(inFlight) { + if (inFlight) modalManager.closeModal() + } +} + // ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane fun openBadgesView() { ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c2ca7d7f10..6be88831a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -78,6 +78,14 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { } SectionSpacer() } + if (BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null) { + SectionView(stringResource(MR.strings.badges_store_purchase_title)) { + SectionItemView { + Text(stringResource(MR.strings.badges_store_purchase_processing), color = MaterialTheme.colors.secondary) + } + } + SectionSpacer() + } if (appPrefs.developerTools.get()) { val clipboard = LocalClipboardManager.current SectionView(stringResource(MR.strings.badges_credential)) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 957160041b..c602416945 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3165,7 +3165,6 @@ Support SimpleX Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. - Choose your level Redeem badge code Continue How badges protect your privacy @@ -3180,9 +3179,20 @@ You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer Purchase successful - Purchase pending - The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error + The purchase will be retried, and the badge will arrive. + Check your order + Duration + Total + Charged by Google Play to the account on this device. + Issuing your badge + The store has taken the payment. Keeps trying if you close the app. + Waiting for approval + Nothing has been charged. + Checking your purchase + The store has not confirmed a purchase yet. + Store purchase + A store purchase is being processed. Buy in browser Redeem code Add badge to your profile? @@ -3211,6 +3221,8 @@ This app version is too old for the badge service. Please update the app. The badge service does not recognize this badge. The badge service reported an internal error. + The store did not confirm this purchase. + This purchase was already credited to a badge on another device or profile. The code was accepted, but the badge it grants has already ended. The code could not be redeemed. The code you scanned is not a badge code. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 635a8c7ff6..c94f5eb51a 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -356,11 +356,13 @@ undocumentedCommands = "APIChatUnread", "APICheckToken", "APIClearChat", + "APICloseBadgeInvoice", "APIConnectContactViaAddress", "APIConnectPreparedContact", "APIConnectPreparedGroup", "APIContactInfo", "APIContactQueueInfo", + "APICreateBadgeInvoice", "APICreateChatItems", "APICreateChatTag", "APICreateMemberContact", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..674620487f 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -134,6 +134,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeInvoice", "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index c98b8fad3e..4bbc9ad1a8 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types BadgeAlert (..), BadgeAlertPrice (..), BadgeState (..), + BadgeStorePurchase (..), ) where import Control.Applicative ((<|>)) @@ -282,6 +283,14 @@ 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. +data BadgeStorePurchase = BadgeStorePurchase + { invoiceId :: Maybe Text, + transactionRef :: Maybe Text + } + deriving (Show) + instance TextEncoding BadgePurchaseStatus where textEncode = \case PSAcquiring -> "acquiring" @@ -413,3 +422,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure) $(JQ.deriveJSON defaultJSON ''BadgeIssueError) $(JQ.deriveJSON defaultJSON ''BadgeState) + +$(JQ.deriveJSON defaultJSON ''BadgeStorePurchase) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a90431f7de..48ef80bae1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), BadgeStorePurchase (..)) import Simplex.Chat.PaymentService (ServicePayment) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -660,7 +660,9 @@ data ChatCommand | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service - | APIPurchaseBadge {userId :: UserId, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge + | APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge + | 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 @@ -871,7 +873,8 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} - | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeInvoice {user :: User, invoiceId :: Text} + | CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [BadgeStorePurchase]} | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4bc1dc4e5e..ec967563cc 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3563,11 +3563,23 @@ processChatCommand cxt nm = \case ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText - APIPurchaseBadge userId payment -> withUserId userId $ \user -> purchaseBadge nm user payment + APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> purchaseBadge nm user echoedInvoiceId payment + APICreateBadgeInvoice userId -> withUserId userId $ \user -> do + void badgeServiceTarget + refuseWhileBadgeHeld user + g <- asks random + now <- liftIO getCurrentTime + 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 - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do @@ -3576,7 +3588,7 @@ processChatCommand cxt nm = \case withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil -- after the write, so the pass it signals arms a wake for the snooze rather than raising again lift $ startBadgeWork user - CRBadgeState user <$> getUserBadgeState user + badgeStateResponse user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -5221,7 +5233,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse redeemBadgeCode nm user@User {userId} codeText = do code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText - sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + sendTarget <- badgeServiceTarget g <- asks random now <- liftIO getCurrentTime let codeSent = badgeCodeText code @@ -5244,16 +5256,16 @@ redeemBadgeCode nm user@User {userId} codeText = do -- | The app presents a purchase until this returns its badge, under whichever profile is active; the -- stash stays with the profile it was first presented under, so every retry is the signer first credited. -purchaseBadge :: NetworkRequestMode -> User -> ServicePayment -> CM ChatResponse -purchaseBadge nm presentingUser payment = do +purchaseBadge :: NetworkRequestMode -> User -> Maybe Text -> ServicePayment -> CM ChatResponse +purchaseBadge nm presentingUser echoedInvoiceId payment = do txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment - sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + sendTarget <- badgeServiceTarget g <- asks random now <- liftIO getCurrentTime - user@User {userId} <- withStore $ \db -> liftIO (getBadgeStoreReceiptUserId db txRef) >>= maybe (pure presentingUser) (getUser db) + user@User {userId} <- withStore $ \db -> liftIO (attachBadgeStoreReceipt db echoedInvoiceId txRef) >>= maybe (pure presentingUser) (getUser db) (present_, purchased) <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ do stash_ <- withStore' $ \db -> getBadgeStoreReceipt db user txRef - stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user txRef now + stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user echoedInvoiceId (Just txRef) now requestStashedBadge nm user sendTarget stash BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} terminalReceiptError -- outside the badge lock: the chat lock must not be taken under it mapM_ presentUserBadgeToContacts present_ @@ -5278,9 +5290,15 @@ storeTransactionRef = \case stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash stashBadgeKeys user stash_ createStash = do replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_ - unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive + unless replaying $ refuseWhileBadgeHeld user maybe (withStore' createStash) pure stash_ +badgeServiceTarget :: CM (ConnectTarget 'CMContact) +badgeServiceTarget = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure + +refuseWhileBadgeHeld :: User -> CM () +refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive + -- | A refusal for which stashDead holds drops the stash; any other, and a timeout, keep it. requestStashedBadge :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> (BadgeServiceErrorCode -> Bool) -> CM (Maybe User, ChatResponse) requestStashedBadge nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request stashDead = do @@ -5480,6 +5498,12 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now raised <- atomically $ stateTVar emitted (,occurrence) when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert +-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it. +badgeStateResponse :: User -> CM ChatResponse +badgeStateResponse user = do + badgeState <- getUserBadgeState user + CRBadgeState user badgeState <$> withStore' (`getOpenBadgeStorePurchases` user) + -- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. getUserBadgeState :: User -> CM (Maybe BadgeState) getUserBadgeState user = do @@ -6150,7 +6174,9 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_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 <* A.space <*> jsonP), + "/_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), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index a1c67f8cc3..e17ab89038 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -21,6 +21,9 @@ module Simplex.Chat.Store.Badges getBadgeCodeRedemption, createBadgeCodeRedemption, getBadgeStoreReceiptUserId, + attachBadgeStoreReceipt, + closeBadgeStoreInvoice, + getOpenBadgeStorePurchases, getBadgeStoreReceipt, createBadgeStoreReceipt, deleteBadgeStash, @@ -36,6 +39,7 @@ module Simplex.Chat.Store.Badges where import Control.Concurrent.STM (TVar, atomically) +import Control.Monad (forM, forM_, join) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) @@ -47,7 +51,7 @@ import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..)) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), BadgeStorePurchase (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (UserId) @@ -114,6 +118,47 @@ getBadgeStoreReceiptUserId db StoreTransactionRef {provider, transactionRef} = maybeFirstRow fromOnly $ DB.query db "SELECT user_id FROM badge_store_receipts WHERE provider = ? AND transaction_ref = ?" (provider, transactionRef) +-- | The profile a receipt belongs to, attaching it to the record created when Buy was tapped. A +-- transaction already attached stays with its record, whose keys the service may have credited. +attachBadgeStoreReceipt :: DB.Connection -> Maybe Text -> StoreTransactionRef -> IO (Maybe UserId) +attachBadgeStoreReceipt db invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} = + getBadgeStoreReceiptUserId db txRef >>= \case + Just userId -> pure $ Just userId + Nothing -> fmap join . forM invoiceId_ $ \invoiceId -> do + 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: new keys for a paid transaction would be refused as receipt_used, + -- and the badge the old keys were credited with would be lost + forM_ userId_ $ \_ -> + DB.execute + db + "UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, closed_at = NULL 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) + +getOpenBadgeStorePurchases :: DB.Connection -> User -> IO [BadgeStorePurchase] +getOpenBadgeStorePurchases db User {userId} = + map (uncurry BadgeStorePurchase) + <$> 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 + 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) + getBadgeStoreReceipt :: DB.Connection -> User -> StoreTransactionRef -> IO (Maybe BadgeStash) getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transactionRef} = maybeFirstRow (toBadgeStash BSRStoreReceipt) $ @@ -126,17 +171,24 @@ getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transaction |] (userId, provider, transactionRef) -createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> StoreTransactionRef -> UTCTime -> IO BadgeStash -createBadgeStoreReceipt db g User {userId} StoreTransactionRef {provider, transactionRef} now = do +-- | An invoice id another record already holds is not repeated: the record is then keyed by its transaction alone. +createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> Maybe StoreTransactionRef -> UTCTime -> IO BadgeStash +createBadgeStoreReceipt db g User {userId} invoiceId_ txRef_ now = do (purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g masterKey@(BadgeMasterKey mk) <- generateMasterKey g + invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do + taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64] + pure $ if null taken then Just invoiceId else Nothing + let (provider_, transactionRef_) = case txRef_ of + Just StoreTransactionRef {provider, transactionRef} -> (Just provider, Just transactionRef) + Nothing -> (Nothing, Nothing) DB.execute db [sql| - INSERT INTO badge_store_receipts (user_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at) - VALUES (?,?,?,?,?,?,?) + INSERT INTO badge_store_receipts (user_id, invoice_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?,?,?) |] - (userId, provider, transactionRef, purchaseKey, purchasePrivKey, Binary mk, now) + ((userId, invoiceId', provider_, transactionRef_) :. (purchaseKey, purchasePrivKey, Binary mk, now)) storeReceiptId <- insertedRowId db pure BadgeStash {stashRef = BSRStoreReceipt storeReceiptId, purchaseKey, purchasePrivKey, masterKey} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs index 8c9eb9e607..9051146fcb 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260925_badge_store_receipts.hs @@ -12,12 +12,14 @@ m20260925_badge_store_receipts = CREATE TABLE badge_store_receipts( badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id BIGINT 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 BYTEA NOT NULL, purchase_priv_key BYTEA NOT NULL, master_key BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, + closed_at TIMESTAMPTZ, UNIQUE(provider, transaction_ref) ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs index 34520e1dbd..b215bcab93 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260925_badge_store_receipts.hs @@ -11,12 +11,14 @@ m20260925_badge_store_receipts = 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; diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 84aeef023c..63d38e6f32 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) -import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..)) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), BadgeStorePurchase (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] - CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId] + CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewBadgeStorePurchase storePurchases CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView @@ -1863,6 +1864,10 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} = viewBadgeAlert :: BadgeAlert -> [StyledString] viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] +viewBadgeStorePurchase :: BadgeStorePurchase -> StyledString +viewBadgeStorePurchase BadgeStorePurchase {invoiceId, transactionRef} = + plain $ "store purchase open: invoice " <> fromMaybe "none" invoiceId <> maybe "" (", transaction " <>) transactionRef + viewBadgeLedger :: [StatementEntry] -> [StyledString] viewBadgeLedger [] = ["no ledger entries"] viewBadgeLedger entries = map viewEntry entries diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 1e8ec7ef03..780aac5e89 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -36,6 +36,7 @@ import Data.Char (toLower) import Data.Either (isLeft, isRight) import Data.Int (Int64) import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.List (stripPrefix) import qualified Data.Map.Strict as M import Data.Maybe (isJust, isNothing) import Data.String (fromString) @@ -139,6 +140,15 @@ 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 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 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 badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -1744,3 +1754,152 @@ testPurchaseDeliveredToHiddenProfile ps = (alice "/user alice password" showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceOtherProfile :: HasCallStack => TestParams -> IO () +testInvoiceOtherProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> purchaseWithInvoice 2 invoiceId supporterPlay + alice <## "badge purchase delivered to another profile" + (alice "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testInvoiceSameReceiptTwice :: HasCallStack => TestParams -> IO () +testInvoiceSameReceiptTwice ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <## "badge already redeemed" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] + rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1 + +testInvoiceUnknown :: HasCallStack => TestParams -> IO () +testInvoiceUnknown ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- a reinstall or a second device: the store echoes an invoice this database never created + let unknown = "0b6a5e6c-4f3e-4d51-9f55-3a0f7c2f9e11" + alice ##> purchaseWithInvoice 2 unknown supporterPlay + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + storeReceiptRows (chatController alice) `shouldReturn` [(2, Just (T.pack unknown), True, False)] + +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} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + 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 redeem badge code: 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 <## "supporter badge - active" + alice <##. "expires " + +testInvoiceRefusedBeforeCharge :: HasCallStack => TestParams -> IO () +testInvoiceRefusedBeforeCharge ps = do + withNewTestChatCfg ps testCfg {badgeServiceAddress = Nothing} "bob" bobProfile $ \bob -> do + bob ##> "/_badge invoice 1" + bob <## "cannot redeem badge code: badge service not configured" + storeReceiptRows (chatController bob) `shouldReturn` [] + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> "/_badge invoice 1" + alice <## "cannot redeem badge code: badge already active" + storeReceiptRows (chatController alice) `shouldReturn` [] + +testInvoiceWhileBadgeHeld :: HasCallStack => TestParams -> IO () +testInvoiceWhileBadgeHeld ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + invoiceId <- createInvoice alice 1 + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice ##> purchaseWithInvoice 1 invoiceId supporterPlay + alice <## "cannot redeem badge code: badge already active" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)] + 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} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + _ <- createInvoice alice 1 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_hide user 1 \"password\"" + alice <## "user alice:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + invoiceId <- createInvoice alice 2 + alice ##> "/_badge state 2" + alice <## ("store purchase open: invoice " <> invoiceId) + (alice TestCC -> Int -> IO String +createInvoice cc userId = do + cc ##> ("/_badge invoice " <> show userId) + line <- getTermLine cc + maybe (error $ "expected an invoice, got " <> line) pure $ stripPrefix "badge invoice: " line + +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)] +storeReceiptRows ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db $ + "SELECT user_id, invoice_id, transaction_ref IS NOT NULL, closed_at IS NOT NULL " + <> "FROM badge_store_receipts ORDER BY badge_store_receipt_id"