feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -0,0 +1,297 @@
// AccountCreditPurchaseManagerTests.swift
// OSGKeyboardTests
@testable import OSGKeyboard
import XCTest
@MainActor
final class AccountCreditPurchaseManagerTests: XCTestCase {
func testVerifiedPurchaseFinishesOnlyAfterServerAcknowledgement() async {
let accountID = UUID()
let service = CreditServiceStub(
purchase: AccountCreditPurchase(
transactionID: "2000000000001",
productID: productID,
creditsGranted: 3_000,
balanceAfter: 4_000,
replayed: false
)
)
let finishRecorder = FinishRecorder()
let transaction = AccountStoreTransaction(
id: 2_000_000_000_001,
productID: productID,
appAccountToken: accountID,
signedTransaction: signedTransaction,
finishOperation: { await finishRecorder.record() }
)
let store = CreditStoreStub(outcome: .success(.verified(transaction)))
let manager = AccountCreditPurchaseManager(service: service, store: store)
await manager.prepare(accountID: accountID)
XCTAssertEqual(
manager.options,
[
AccountCreditPurchaseOption(
productID: "500tks",
credits: 500,
displayPrice: "$0.99"
),
AccountCreditPurchaseOption(
productID: "1500tks",
credits: 1_500,
displayPrice: "$1.99"
),
AccountCreditPurchaseOption(
productID: "3000tks",
credits: 3_000,
displayPrice: "$2.99"
)
]
)
let purchased = await manager.purchase(productID: productID, accountID: accountID)
XCTAssertTrue(purchased)
XCTAssertEqual(manager.state, .succeeded(credits: 3_000))
XCTAssertEqual(manager.lastGrantedBalance, 4_000)
let submissions = await service.submittedTransactions()
let finishedCount = await finishRecorder.count()
XCTAssertEqual(submissions, [signedTransaction])
XCTAssertEqual(finishedCount, 1)
manager.dismissSuccessMessage()
XCTAssertEqual(manager.state, .idle)
XCTAssertEqual(manager.lastGrantedBalance, 4_000)
}
func testMismatchedAccountTransactionIsRejectedWithoutServerSubmissionOrFinish() async {
let accountID = UUID()
let service = CreditServiceStub(
purchase: AccountCreditPurchase(
transactionID: "2000000000001",
productID: productID,
creditsGranted: 3_000,
balanceAfter: 4_000,
replayed: false
)
)
let finishRecorder = FinishRecorder()
let transaction = AccountStoreTransaction(
id: 2_000_000_000_001,
productID: productID,
appAccountToken: UUID(),
signedTransaction: signedTransaction,
finishOperation: { await finishRecorder.record() }
)
let store = CreditStoreStub(outcome: .success(.verified(transaction)))
let manager = AccountCreditPurchaseManager(service: service, store: store)
await manager.prepare(accountID: accountID)
let purchased = await manager.purchase(productID: productID, accountID: accountID)
XCTAssertFalse(purchased)
XCTAssertEqual(manager.state, .failed(messageKey: "account.storekit.error.verification"))
let submissions = await service.submittedTransactions()
let finishedCount = await finishRecorder.count()
XCTAssertEqual(submissions, [])
XCTAssertEqual(finishedCount, 0)
}
func testPendingPurchaseRemainsUnfinishedAndCanReturnToIdle() async {
let accountID = UUID()
let service = CreditServiceStub(purchase: nil)
let store = CreditStoreStub(outcome: .pending)
let manager = AccountCreditPurchaseManager(service: service, store: store)
await manager.prepare(accountID: accountID)
let purchased = await manager.purchase(productID: productID, accountID: accountID)
XCTAssertFalse(purchased)
XCTAssertEqual(manager.state, .pending)
manager.clearTransientState()
XCTAssertEqual(manager.state, .idle)
}
func testPurchaseHistoryLoadsServerPagesAndRemovesDuplicates() async {
let accountID = UUID()
let newerDate = Date(timeIntervalSince1970: 2_000)
let olderDate = Date(timeIntervalSince1970: 1_000)
let newerRecord = AccountCreditPurchaseRecord(
transactionID: "2",
productID: "3000tks",
creditsGranted: 3_000,
balanceAfter: 4_000,
purchasedAt: newerDate,
status: .credited
)
let olderRecord = AccountCreditPurchaseRecord(
transactionID: "1",
productID: "500tks",
creditsGranted: 500,
balanceAfter: 1_000,
purchasedAt: olderDate,
status: .credited
)
let service = CreditServiceStub(
purchase: nil,
historyPages: [
AccountCreditPurchaseHistoryPage(
items: [newerRecord],
nextCursor: "next"
),
AccountCreditPurchaseHistoryPage(
items: [newerRecord, olderRecord],
nextCursor: nil
)
]
)
let manager = AccountCreditPurchaseManager(
service: service,
store: CreditStoreStub(outcome: .pending)
)
await manager.prepare(accountID: accountID)
await manager.loadPurchaseHistory(accountID: accountID)
XCTAssertEqual(manager.historyPhase, .loaded([newerRecord]))
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
XCTAssertEqual(manager.historyPhase, .loaded([newerRecord, olderRecord]))
let requests = await service.historyRequests()
XCTAssertEqual(requests, ["50:<nil>", "50:next"])
}
func testUnknownCatalogProductCannotStartPurchase() async {
let accountID = UUID()
let service = CreditServiceStub(purchase: nil)
let store = CreditStoreStub(outcome: .pending)
let manager = AccountCreditPurchaseManager(service: service, store: store)
await manager.prepare(accountID: accountID)
let purchased = await manager.purchase(productID: "unknown", accountID: accountID)
XCTAssertFalse(purchased)
XCTAssertEqual(
manager.state,
.failed(messageKey: "account.storekit.error.productUnavailable")
)
}
private let productID = "3000tks"
private let signedTransaction = String(repeating: "s", count: 100)
}
private actor CreditServiceStub: AccountCenterServicing {
private let purchase: AccountCreditPurchase?
private let historyPages: [AccountCreditPurchaseHistoryPage]
private var submissions: [String] = []
private var historyPageIndex = 0
private var recordedHistoryRequests: [String] = []
init(
purchase: AccountCreditPurchase?,
historyPages: [AccountCreditPurchaseHistoryPage] = []
) {
self.purchase = purchase
self.historyPages = historyPages
}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
throw AccountIntegrationError.unavailable
}
func createReferralCode() async throws -> String {
throw AccountIntegrationError.unavailable
}
func redeemReferral(code: String) async throws {
throw AccountIntegrationError.unavailable
}
func loadCreditProducts() async throws -> [AccountCreditProduct] {
[
AccountCreditProduct(productID: "3000tks", credits: 3_000),
AccountCreditProduct(productID: "1500tks", credits: 1_500),
AccountCreditProduct(productID: "500tks", credits: 500)
]
}
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
submissions.append(signedTransaction)
guard let purchase else { throw AccountIntegrationError.unavailable }
return purchase
}
func loadCreditPurchaseHistory(
limit: Int,
cursor: String?
) async throws -> AccountCreditPurchaseHistoryPage {
recordedHistoryRequests.append("\(limit):\(cursor ?? "<nil>")")
guard historyPageIndex < historyPages.count else {
throw AccountIntegrationError.unavailable
}
defer { historyPageIndex += 1 }
return historyPages[historyPageIndex]
}
func submittedTransactions() -> [String] {
submissions
}
func historyRequests() -> [String] {
recordedHistoryRequests
}
}
@MainActor
private final class CreditStoreStub: AccountCreditStore {
private let outcome: AccountStorePurchaseOutcome
init(outcome: AccountStorePurchaseOutcome) {
self.outcome = outcome
}
func product(for productID: String) async throws -> AccountStoreProduct? {
let displayPrice: String
switch productID {
case "500tks":
displayPrice = "$0.99"
case "1500tks":
displayPrice = "$1.99"
default:
displayPrice = "$2.99"
}
return AccountStoreProduct(
id: productID,
displayPrice: displayPrice
)
}
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
outcome
}
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
AsyncStream { $0.finish() }
}
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
AsyncStream { $0.finish() }
}
func allTransactions() -> AsyncStream<AccountStoreVerification> {
AsyncStream { $0.finish() }
}
}
private actor FinishRecorder {
private var finishedCount = 0
func record() {
finishedCount += 1
}
func count() -> Int {
finishedCount
}
}