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
This commit is contained in:
spaced4ndy
2026-10-01 08:46:35 +00:00
committed by GitHub
parent 0ee34926bc
commit e3e4709381
38 changed files with 1607 additions and 343 deletions
+48 -3
View File
@@ -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))
}
}
+47 -9
View File
@@ -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<ChatResponse2>?
if retry {
r = await chatApiSendCmdWithRetry(cmd, log: false)
} else {
let res: APIResult<ChatResponse2> = 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))")
}
+1
View File
@@ -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
+230 -23
View File
@@ -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<String> = 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<String> = []
// 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<UInt64> = []
private var transactionUpdates: Task<Void, Never>? = 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<String> = []
for await verification in Transaction.unfinished {
if let invoiceId = storeReceipt(verification).echoedInvoiceId { held.insert(invoiceId) }
await reconcile(verification)
}
await closeAbandoned(held)
await MainActor.run { reconciledOnce = true }
}
// A record no held transaction names and no purchase here waits on. If the store still charges for one,
// as when this runs between an invoice's creation and its purchase starting, the receipt reopens it.
private func closeAbandoned(_ held: Set<String>) async {
guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return }
await loadBadgeStateAsync(userId)
let abandoned = await MainActor.run {
openStorePurchases(userId)
.compactMap { $0.transactionRef == nil ? $0.invoiceId : nil }
.filter { !held.contains($0) && !buying.contains($0) }
}
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<Transaction>) 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<Transaction>) -> 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
}
@@ -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)
}
}
}
@@ -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 {
@@ -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)."
}
@@ -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."
}
}
}
@@ -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 {
@@ -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))
}
}
@@ -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 {
@@ -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 = "<group>"; };
647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = "<group>"; };
647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = "<group>"; };
64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = "<group>"; };
64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
@@ -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 */,
+7 -1
View File
@@ -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
}
}
@@ -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<BadgeStoreProductId>, 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<BadgePurchaseOutcome> = emptyList()
@@ -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<BadgePurchaseOutcome> {
val client = connectedBadgeBillingClient()
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
val queried = CompletableDeferred<List<Purchase>>()
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<Purchase>): 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<Purchase>): 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<BillingResult>()
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)
}
}
@@ -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<BadgePurchaseOutcome> = unfinishedBadgePurchases()
@SuppressLint("SourceLockedOrientationActivity")
@Composable
override fun androidLockPortraitOrientation() {
@@ -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<BadgeState?, List<BadgeStorePurchase>> {
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<BadgeStorePurchase> = emptyList()): CR()
@Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List<StatementEntry>): CR()
@Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR()
@Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR()
@@ -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<BadgeServiceErrorCode> {
"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
@@ -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<BadgePurchaseOutcome> = emptyList()
val androidApiLevel: Int? get() = null
// The build distributed via Google Play, which has to follow its policies
val androidIsPlayStoreBuild: Boolean get() = false
@@ -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<BadgeStoreProductId> = 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<String> = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet()
// subscriptions are for sale once renewals are delivered
val badgePeriodsForSale: List<BadgePeriod> = 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<Map<BadgeStoreProductId, BadgeProduct>>(emptyMap())
// one-time purchases the store holds unfinished: each is a payment taken and not yet credited
private val unfinished = mutableStateOf<Map<String, BadgeStoreReceipt>>(emptyMap())
// core's open store purchases for the profile they were read for: core knows whose a purchase is
private val storePurchases = mutableStateOf<Triple<Long?, Long, List<BadgeStorePurchase>>?>(null)
// invoices whose purchase this session is still waiting on the store for
private val buying = mutableStateOf<Set<String>>(emptySet())
private val waitingForApproval = mutableStateOf(false)
// 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<String>()
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<BadgeStorePurchase>) {
storePurchases.value = Triple(rhId, userId, purchases)
}
private fun openStorePurchases(userId: Long?): List<BadgeStorePurchase> {
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<BadgePurchaseOutcome, String> {
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<String>) {
val userId = chatModel.currentUser.value?.userId ?: return
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
val abandoned = withContext(Dispatchers.Main) {
openStorePurchases(userId)
.mapNotNull { if (it.transactionRef == null) it.invoiceId else null }
.filter { it !in held && it !in buying.value }
}
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
}
@@ -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<Boolean>) {
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<Boolean>) {
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
)
}
@@ -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) }
}
)
}
@@ -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<Boolean>, 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<Boolean>, 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
)
}
@@ -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
}
@@ -99,6 +99,7 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) {
val code = remember { mutableStateOf(TextFieldValue("")) }
val canonicalCode = remember { mutableStateOf<String?>(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
@@ -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
@@ -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) }
@@ -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)) {
@@ -3165,7 +3165,6 @@
<string name="badges_support_simplex_title">Support SimpleX</string>
<string name="badges_support_simplex_body">Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile.</string>
<string name="badges_why_simplex_is_built">Why SimpleX is built.</string>
<string name="badges_choose_your_level">Choose your level</string>
<string name="badges_redeem_code_button">Redeem badge code</string>
<string name="badges_continue">Continue</string>
<string name="badges_how_it_works_button">How badges protect your privacy</string>
@@ -3180,9 +3179,20 @@
<string name="badges_banner_dismiss_message">You can support SimpleX later in Settings.</string>
<string name="badges_larger_files_longer">Support SimpleX to send larger files that stay available longer</string>
<string name="badges_purchase_successful">Purchase successful</string>
<string name="badges_purchase_pending">Purchase pending</string>
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
<string name="badges_purchase_error">Purchase error</string>
<string name="badges_purchase_will_retry">The purchase will be retried, and the badge will arrive.</string>
<string name="badges_check_your_order_title">Check your order</string>
<string name="badges_order_duration">Duration</string>
<string name="badges_order_total">Total</string>
<string name="badges_charged_by_play">Charged by Google Play to the account on this device.</string>
<string name="badges_issuing_title">Issuing your badge</string>
<string name="badges_issuing_body">The store has taken the payment. Keeps trying if you close the app.</string>
<string name="badges_waiting_for_approval_title">Waiting for approval</string>
<string name="badges_waiting_for_approval_body">Nothing has been charged.</string>
<string name="badges_checking_title">Checking your purchase</string>
<string name="badges_checking_body">The store has not confirmed a purchase yet.</string>
<string name="badges_store_purchase_title">Store purchase</string>
<string name="badges_store_purchase_processing">A store purchase is being processed.</string>
<string name="badges_buy_in_browser">Buy in browser</string>
<string name="badges_redeem_code_title">Redeem code</string>
<string name="badges_link_confirm_title">Add badge to your profile?</string>
@@ -3211,6 +3221,8 @@
<string name="badges_error_unsupported_version">This app version is too old for the badge service. Please update the app.</string>
<string name="badges_error_unknown_purchase">The badge service does not recognize this badge.</string>
<string name="badges_error_service_internal">The badge service reported an internal error.</string>
<string name="badges_error_receipt_invalid">The store did not confirm this purchase.</string>
<string name="badges_error_receipt_used">This purchase was already credited to a badge on another device or profile.</string>
<string name="badges_error_badge_ended">The code was accepted, but the badge it grants has already ended.</string>
<string name="badges_error_unknown">The code could not be redeemed.</string>
<string name="badges_code_you_scanned_is_not_badge_code">The code you scanned is not a badge code.</string>
+2
View File
@@ -356,11 +356,13 @@ undocumentedCommands =
"APIChatUnread",
"APICheckToken",
"APIClearChat",
"APICloseBadgeInvoice",
"APIConnectContactViaAddress",
"APIConnectPreparedContact",
"APIConnectPreparedGroup",
"APIContactInfo",
"APIContactQueueInfo",
"APICreateBadgeInvoice",
"APICreateChatItems",
"APICreateChatTag",
"APICreateMemberContact",
+1
View File
@@ -134,6 +134,7 @@ undocumentedResponses =
"CRAppSettings",
"CRArchiveExported",
"CRArchiveImported",
"CRBadgeInvoice",
"CRBadgeLedger",
"CRBadgeRedeemed",
"CRBadgeState",
+11
View File
@@ -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)
+6 -3
View File
@@ -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}
+37 -11
View File
@@ -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),
+58 -6
View File
@@ -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}
@@ -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)
);
@@ -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;
+7 -2
View File
@@ -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
+159
View File
@@ -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 </)
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 </)
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True, False)]
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 </)
createInvoice :: HasCallStack => 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"