mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +00:00
184 lines
6.7 KiB
Swift
184 lines
6.7 KiB
Swift
//
|
|
// BadgeStore.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by spaced4ndy on 14.08.2026.
|
|
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
import StoreKit
|
|
|
|
// 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.
|
|
func badgeProductId(_ level: BadgeLevel, _ period: BadgePeriod) -> String {
|
|
switch (level, period) {
|
|
case (.supporter, .oneMonth): "BADGE_SUPPORTER_01"
|
|
case (.supporter, .monthly): "SUBSCR_BADGE_SUPPORTER_MONTH_01"
|
|
case (.supporter, .annual): "SUBSCR_BADGE_SUPPORTER_YEAR_01"
|
|
case (.legend, .oneMonth): "BADGE_LEGEND_01"
|
|
case (.legend, .monthly): "SUBSCR_BADGE_LEGEND_MONTH_01"
|
|
case (.legend, .annual): "SUBSCR_BADGE_LEGEND_YEAR_01"
|
|
}
|
|
}
|
|
|
|
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.
|
|
func newBadgeInvoiceId() -> UUID { UUID() }
|
|
|
|
enum BadgePrice {
|
|
case loading
|
|
case price(String)
|
|
case unavailable
|
|
|
|
var canPurchase: Bool {
|
|
switch self {
|
|
case .price: true
|
|
case .loading, .unavailable: false
|
|
}
|
|
}
|
|
}
|
|
|
|
struct BadgeStoreReceipt {
|
|
// the signed token the badge service verifies - never transaction.jsonRepresentation
|
|
let jws: String
|
|
let productId: String
|
|
let transactionId: UInt64
|
|
let invoiceId: UUID?
|
|
let environment: String?
|
|
let signatureVerified: Bool
|
|
}
|
|
|
|
enum BadgePurchaseOutcome {
|
|
case purchased(BadgeStoreReceipt)
|
|
case pending
|
|
case cancelled
|
|
}
|
|
|
|
enum BadgeStoreError: Error {
|
|
case productUnavailable(productId: String)
|
|
case unknownPurchaseResult
|
|
}
|
|
|
|
final class BadgeStore: ObservableObject {
|
|
static let shared = BadgeStore()
|
|
|
|
private enum LoadState { case notLoaded, loading, loaded, failed }
|
|
|
|
@Published private var state: LoadState = .notLoaded
|
|
private var products: [String: Product] = [:]
|
|
|
|
private init() {}
|
|
|
|
func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice {
|
|
switch state {
|
|
case .notLoaded, .loading: return .loading
|
|
case .loaded, .failed:
|
|
if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) }
|
|
return .unavailable
|
|
}
|
|
}
|
|
|
|
// percentage the annual subscription saves against 12 monthly payments
|
|
func annualSavings(_ level: BadgeLevel) -> Int? {
|
|
guard let monthly = products[badgeProductId(level, .monthly)],
|
|
let annual = products[badgeProductId(level, .annual)]
|
|
else { return nil }
|
|
let year = monthly.price * 12
|
|
guard year > 0, annual.price < year else { return nil }
|
|
let saved = (year - annual.price) / year * 100
|
|
let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded())
|
|
return percent > 0 ? percent : nil
|
|
}
|
|
|
|
func load() async {
|
|
guard await startLoading() else { return }
|
|
do {
|
|
let loaded = try await Product.products(for: badgeProductIds)
|
|
let byId = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { p, _ in p })
|
|
let missing = badgeProductIds.filter { byId[$0] == nil }
|
|
if !missing.isEmpty {
|
|
// the store drops ids it cannot resolve without saying why, so the storefront and
|
|
// the bundle the request was made for are logged with them
|
|
let storefront = await Storefront.current
|
|
logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)")
|
|
}
|
|
await MainActor.run {
|
|
products = byId
|
|
state = .loaded
|
|
}
|
|
} catch let error {
|
|
logger.error("BadgeStore.load: \(String(describing: error))")
|
|
await MainActor.run { state = .failed }
|
|
}
|
|
}
|
|
|
|
func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome {
|
|
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
|
|
}
|
|
// 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
|
|
case .userCancelled: return .cancelled
|
|
@unknown default: throw BadgeStoreError.unknownPurchaseResult
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func startLoading() -> Bool {
|
|
switch state {
|
|
case .notLoaded, .failed:
|
|
state = .loading
|
|
return true
|
|
case .loading, .loaded:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency style;
|
|
// Product.displayPrice remains the exact form for views that need the cents
|
|
private func compactPrice(_ product: Product) -> String {
|
|
var whole = Decimal()
|
|
var price = product.price
|
|
NSDecimalRound(&whole, &price, 0, .plain)
|
|
return whole == product.price
|
|
? product.price.formatted(product.priceFormatStyle.precision(.fractionLength(0)))
|
|
: product.displayPrice
|
|
}
|
|
|
|
private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt {
|
|
var environment: String? = nil
|
|
if #available(iOS 16.0, *) { environment = t.environment.rawValue }
|
|
return BadgeStoreReceipt(
|
|
jws: jws,
|
|
productId: t.productID,
|
|
transactionId: t.id,
|
|
invoiceId: t.appAccountToken,
|
|
environment: environment,
|
|
signatureVerified: signatureVerified
|
|
)
|
|
}
|