diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index dc2daf43..95b9fe80 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -28,9 +28,11 @@ jobs: - run: flutter pub get # Regenerated rather than trusted, so a .g.dart left behind by a change to the file it is - # generated from fails here instead of confusing the next person to run build_runner. + # generated from fails here instead of confusing the next person to run build_runner. Pigeon + # first: build_runner reads what it writes. + - run: ./tool/generate_pigeon.sh - run: dart run build_runner build - - run: git diff --exit-code lib + - run: git diff --exit-code lib ios/Runner/FinanceKit # Generated files are excluded: build_runner writes them at its own width, so formatting # them would put every regeneration at odds with the check above. diff --git a/mobile/README.md b/mobile/README.md index 025dfcdc..f0aee3a0 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -22,6 +22,17 @@ mix openapi && mobile/tool/generate_api.sh Needs Docker. CI fails if either the spec or the client has drifted. +## Regenerating the platform channel + +```sh +mobile/tool/generate_pigeon.sh +``` + +Writes `lib/finance_kit/wallet.g.dart` and `ios/Runner/FinanceKit/Wallet.g.swift` from +`pigeons/wallet.dart`. Pigeon runs from a global activation rather than a dev dependency: it pins an +older `analyzer` than build_runner does, and the two cannot share a lockfile. Run it before +build_runner, which reads what it writes. CI fails if either output has drifted. + ## Gates ```sh @@ -44,3 +55,14 @@ Both are external to the codebase and sign-in fails on device without them. and its reversed form as a URL scheme. The **server** needs the same value in its `GOOGLE_IOS_CLIENT_ID` env var, or it rejects the app's ID tokens on audience - `scripts/install.sh` prompts for it. + +## Wallet + +Apple Card, Apple Cash and Apple Savings come from FinanceKit rather than Plaid, which does not +carry them. The entitlement is granted for `fiftysevenmedia.Spendable`; there is no sandbox, so it +only works on a real device, signed in to a US Apple Account with a card in Wallet. The simulator +reports `isAvailable() == false` and the app simply does not offer it. + +`FinanceKitPlugin.swift` is transport and nothing else. Amounts go up unsigned with a +credit/debit indicator beside them, and the server decides the sign, dedupes, and applies every +ledger rule - so a money bug has one place to be, not two. diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index c89ad134..8efb3f18 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -6,6 +6,8 @@ analyzer: - ios/** # Generated from the OpenAPI spec, and not ours to lint. - api/** + # Read by pigeon rather than compiled, so it imports a package the app does not depend on. + - pigeons/** - android/** - web/** - windows/** diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index c0ceea89..21516551 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + FK00000000000000000004 /* FinanceKitPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FK00000000000000000002 /* FinanceKitPlugin.swift */; }; + FK00000000000000000005 /* Wallet.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FK00000000000000000003 /* Wallet.g.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; @@ -48,6 +50,8 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + FK00000000000000000002 /* FinanceKitPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FinanceKitPlugin.swift; sourceTree = ""; }; + FK00000000000000000003 /* Wallet.g.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Wallet.g.swift; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; @@ -124,10 +128,20 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + FK00000000000000000001 /* FinanceKit */, ); path = Runner; sourceTree = ""; }; + FK00000000000000000001 /* FinanceKit */ = { + isa = PBXGroup; + children = ( + FK00000000000000000002 /* FinanceKitPlugin.swift */, + FK00000000000000000003 /* Wallet.g.swift */, + ); + path = FinanceKit; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -284,6 +298,8 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + FK00000000000000000004 /* FinanceKitPlugin.swift in Sources */, + FK00000000000000000005 /* Wallet.g.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c30b367e..1f8ebbf7 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -12,5 +12,8 @@ import UIKit func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + // Registered by hand: FinanceKit is ours rather than a pub package, so nothing generates this. + FinanceKitPlugin.register(with: engineBridge.applicationRegistrar.messenger()) } } diff --git a/mobile/ios/Runner/FinanceKit/FinanceKitPlugin.swift b/mobile/ios/Runner/FinanceKit/FinanceKitPlugin.swift new file mode 100644 index 00000000..84e1c458 --- /dev/null +++ b/mobile/ios/Runner/FinanceKit/FinanceKitPlugin.swift @@ -0,0 +1,233 @@ +import Flutter +import Foundation + +#if canImport(FinanceKit) + import FinanceKit +#endif + +/// Transport only. Amounts stay unsigned and undecided here - the sign, the dedup and every +/// ledger rule live on the server. +final class FinanceKitPlugin: NSObject, WalletApi { + static func register(with messenger: FlutterBinaryMessenger) { + WalletApiSetup.setUp(binaryMessenger: messenger, api: FinanceKitPlugin()) + } + + func isAvailable() throws -> Bool { + #if canImport(FinanceKit) + guard #available(iOS 17.4, *) else { return false } + + return FinanceStore.isDataAvailable(.financialData) + #else + return false + #endif + } + + func authorizationStatus(completion: @escaping (Result) -> Void) { + #if canImport(FinanceKit) + guard #available(iOS 17.4, *), FinanceStore.isDataAvailable(.financialData) else { + return completion(.success(.denied)) + } + + Task { + do { + completion(.success(Self.authorization(try await FinanceStore.shared.authorizationStatus()))) + } catch { + completion(.failure(error)) + } + } + #else + completion(.success(.denied)) + #endif + } + + func requestAuthorization(completion: @escaping (Result) -> Void) { + #if canImport(FinanceKit) + guard #available(iOS 17.4, *), FinanceStore.isDataAvailable(.financialData) else { + return completion(.success(.denied)) + } + + Task { + do { + completion(.success(Self.authorization(try await FinanceStore.shared.requestAuthorization()))) + } catch { + completion(.failure(error)) + } + } + #else + completion(.success(.denied)) + #endif + } + + func read(historyToken: String?, completion: @escaping (Result) -> Void) { + #if canImport(FinanceKit) + guard #available(iOS 17.4, *) else { return completion(.failure(WalletUnavailable())) } + + Task { + do { + completion(.success(try await Self.read(historyToken))) + } catch { + completion(.failure(error)) + } + } + #else + completion(.failure(WalletUnavailable())) + #endif + } +} + +struct WalletUnavailable: Error {} + +#if canImport(FinanceKit) + @available(iOS 17.4, *) + extension FinanceKitPlugin { + /// History is per account and so is its token, but the server holds one token per connection, + /// which it only ever compares for equality. So the tokens travel together under the account + /// they belong to, and an account with no entry - a card added since the last read - is read + /// from the beginning while the others resume. + static func read(_ historyToken: String?) async throws -> WalletChanges { + let store = FinanceStore.shared + let accounts = try await store.accounts(query: AccountQuery()) + let balances = try await latestBalances() + let tokens = decode(historyToken) + + var wallet: [WalletAccount] = [] + var inserted: [WalletCharge] = [] + var updated: [WalletCharge] = [] + var deleted: [String] = [] + var next: [String: HistoryToken] = [:] + + for account in accounts { + let externalId = account.id.uuidString + let balance = balances[account.id].map(self.balance) ?? (value: "0", creditDebit: .debit) + + wallet.append( + WalletAccount( + externalId: externalId, + name: account.displayName, + kind: kind(account), + balance: balance.value, + creditDebit: balance.creditDebit + )) + + // isMonitoring off, or the sequence stays open waiting for the next purchase. + let history = store.transactionHistory( + forAccountID: account.id, since: tokens[externalId], isMonitoring: false) + + for try await batch in history { + inserted.append(contentsOf: batch.inserted.map { charge($0, accountExternalId: externalId) }) + updated.append(contentsOf: batch.updated.map { charge($0, accountExternalId: externalId) }) + deleted.append(contentsOf: batch.deleted.map(\.uuidString)) + + next[externalId] = batch.newToken + } + } + + return WalletChanges( + accounts: wallet, + inserted: inserted, + updated: updated, + deleted: deleted, + historyToken: encode(next) + ) + } + + /// FinanceKit keeps older balances alongside the current one, so the newest per account wins. + static func latestBalances() async throws -> [UUID: Balance] { + var latest: [UUID: Balance] = [:] + + for account in try await FinanceStore.shared.accountBalances(query: AccountBalanceQuery()) { + guard let balance = current(account.currentBalance) else { continue } + + if let held = latest[account.accountID], held.asOfDate >= balance.asOfDate { continue } + + latest[account.accountID] = balance + } + + return latest + } + + static func current(_ balance: CurrentBalance) -> Balance? { + switch balance { + case .available(let available): return available + case .booked(let booked): return booked + case .availableAndBooked(let available, _): return available + @unknown default: return nil + } + } + + /// Apple Card is the only liability Wallet exposes, and it is the one thing that has to read + /// as a card - that is what puts it into the credit card balance rather than into Spendable. + static func kind(_ account: Account) -> WalletAccountKind { + switch account { + case .liability: return .creditCard + case .asset: return .cash + @unknown default: return .cash + } + } + + static func balance(_ balance: Balance) -> (value: String, creditDebit: WalletCreditDebit) { + (decimal(balance.amount.amount), indicator(balance.creditDebitIndicator)) + } + + static func charge(_ transaction: FinanceKit.Transaction, accountExternalId: String) -> WalletCharge { + WalletCharge( + accountExternalId: accountExternalId, + externalId: transaction.id.uuidString, + amount: decimal(transaction.transactionAmount.amount), + creditDebit: indicator(transaction.creditDebitIndicator), + date: dateFormatter.string(from: transaction.transactionDate), + name: transaction.merchantName ?? transaction.transactionDescription, + pending: transaction.status != .booked + ) + } + + static func authorization(_ status: AuthorizationStatus) -> WalletAuthorization { + switch status { + case .authorized: return .authorized + case .denied: return .denied + case .notDetermined: return .notDetermined + @unknown default: return .denied + } + } + + static func indicator(_ value: CreditDebitIndicator) -> WalletCreditDebit { + value == .credit ? .credit : .debit + } + + /// Unsigned and at full precision. A Double would round money, and the sign is the server's. + static func decimal(_ value: Decimal) -> String { + "\(abs(value))" + } + + static func encode(_ tokens: [String: HistoryToken]) -> String { + guard let data = try? JSONEncoder().encode(tokens) else { return "" } + + return data.base64EncodedString() + } + + /// A token FinanceKit will not take is no worse than no token: everything is read again, and + /// the server drops what it already holds. + static func decode(_ token: String?) -> [String: HistoryToken] { + guard + let token, let data = Data(base64Encoded: token), + let tokens = try? JSONDecoder().decode([String: HistoryToken].self, from: data) + else { + return [:] + } + + return tokens + } + + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + } + + @available(iOS 17.4, *) + typealias HistoryToken = FinanceStore.HistoryToken +#endif diff --git a/mobile/ios/Runner/FinanceKit/Wallet.g.swift b/mobile/ios/Runner/FinanceKit/Wallet.g.swift new file mode 100644 index 00000000..1bf9338e --- /dev/null +++ b/mobile/ios/Runner/FinanceKit/Wallet.g.swift @@ -0,0 +1,526 @@ +// Autogenerated from Pigeon (v26.3.3), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +private func doubleEqualsWallet(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs +} + +private func doubleHashWallet(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } +} + +func deepEqualsWallet(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEqualsWallet(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEqualsWallet(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEqualsWallet(lhsKey, rhsKey) { + if deepEqualsWallet(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEqualsWallet(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } +} + +func deepHashWallet(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHashWallet(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHashWallet(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHashWallet(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHashWallet(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHashWallet(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } +} + + +/// Whether the user has let the app read their Wallet. +enum WalletAuthorization: Int { + case notDetermined = 0 + case denied = 1 + case authorized = 2 +} + +/// What the account is, in the app's own words rather than FinanceKit's. Nothing emits `savings` +/// yet: FinanceKit does not say which asset accounts are savings, and guessing from the name is +/// how you end up deleting someone's data over a rename. +enum WalletAccountKind: Int { + case creditCard = 0 + case cash = 1 + case savings = 2 +} + +/// Which way money moved. The server puts the sign on, so nothing here is ever negative. +enum WalletCreditDebit: Int { + case credit = 0 + case debit = 1 +} + +/// Generated class from Pigeon that represents data sent in messages. +struct WalletAccount: Hashable { + var externalId: String + var name: String + var kind: WalletAccountKind + /// Unsigned, as a decimal string. Doubles do not hold money. + var balance: String + var creditDebit: WalletCreditDebit + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> WalletAccount? { + let externalId = pigeonVar_list[0] as! String + let name = pigeonVar_list[1] as! String + let kind = pigeonVar_list[2] as! WalletAccountKind + let balance = pigeonVar_list[3] as! String + let creditDebit = pigeonVar_list[4] as! WalletCreditDebit + + return WalletAccount( + externalId: externalId, + name: name, + kind: kind, + balance: balance, + creditDebit: creditDebit + ) + } + func toList() -> [Any?] { + return [ + externalId, + name, + kind, + balance, + creditDebit, + ] + } + static func == (lhs: WalletAccount, rhs: WalletAccount) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsWallet(lhs.externalId, rhs.externalId) && deepEqualsWallet(lhs.name, rhs.name) && deepEqualsWallet(lhs.kind, rhs.kind) && deepEqualsWallet(lhs.balance, rhs.balance) && deepEqualsWallet(lhs.creditDebit, rhs.creditDebit) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("WalletAccount") + deepHashWallet(value: externalId, hasher: &hasher) + deepHashWallet(value: name, hasher: &hasher) + deepHashWallet(value: kind, hasher: &hasher) + deepHashWallet(value: balance, hasher: &hasher) + deepHashWallet(value: creditDebit, hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct WalletCharge: Hashable { + var accountExternalId: String + var externalId: String + /// Unsigned, as a decimal string. + var amount: String + var creditDebit: WalletCreditDebit + /// `yyyy-MM-dd`. + var date: String + var name: String + var pending: Bool + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> WalletCharge? { + let accountExternalId = pigeonVar_list[0] as! String + let externalId = pigeonVar_list[1] as! String + let amount = pigeonVar_list[2] as! String + let creditDebit = pigeonVar_list[3] as! WalletCreditDebit + let date = pigeonVar_list[4] as! String + let name = pigeonVar_list[5] as! String + let pending = pigeonVar_list[6] as! Bool + + return WalletCharge( + accountExternalId: accountExternalId, + externalId: externalId, + amount: amount, + creditDebit: creditDebit, + date: date, + name: name, + pending: pending + ) + } + func toList() -> [Any?] { + return [ + accountExternalId, + externalId, + amount, + creditDebit, + date, + name, + pending, + ] + } + static func == (lhs: WalletCharge, rhs: WalletCharge) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsWallet(lhs.accountExternalId, rhs.accountExternalId) && deepEqualsWallet(lhs.externalId, rhs.externalId) && deepEqualsWallet(lhs.amount, rhs.amount) && deepEqualsWallet(lhs.creditDebit, rhs.creditDebit) && deepEqualsWallet(lhs.date, rhs.date) && deepEqualsWallet(lhs.name, rhs.name) && deepEqualsWallet(lhs.pending, rhs.pending) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("WalletCharge") + deepHashWallet(value: accountExternalId, hasher: &hasher) + deepHashWallet(value: externalId, hasher: &hasher) + deepHashWallet(value: amount, hasher: &hasher) + deepHashWallet(value: creditDebit, hasher: &hasher) + deepHashWallet(value: date, hasher: &hasher) + deepHashWallet(value: name, hasher: &hasher) + deepHashWallet(value: pending, hasher: &hasher) + } +} + +/// One read of Wallet. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct WalletChanges: Hashable { + var accounts: [WalletAccount] + var inserted: [WalletCharge] + var updated: [WalletCharge] + /// External ids of charges that were reversed or declined. + var deleted: [String] + /// Where this read finished, to be sent back on the next one. + var historyToken: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> WalletChanges? { + let accounts = pigeonVar_list[0] as! [WalletAccount] + let inserted = pigeonVar_list[1] as! [WalletCharge] + let updated = pigeonVar_list[2] as! [WalletCharge] + let deleted = pigeonVar_list[3] as! [String] + let historyToken = pigeonVar_list[4] as! String + + return WalletChanges( + accounts: accounts, + inserted: inserted, + updated: updated, + deleted: deleted, + historyToken: historyToken + ) + } + func toList() -> [Any?] { + return [ + accounts, + inserted, + updated, + deleted, + historyToken, + ] + } + static func == (lhs: WalletChanges, rhs: WalletChanges) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return deepEqualsWallet(lhs.accounts, rhs.accounts) && deepEqualsWallet(lhs.inserted, rhs.inserted) && deepEqualsWallet(lhs.updated, rhs.updated) && deepEqualsWallet(lhs.deleted, rhs.deleted) && deepEqualsWallet(lhs.historyToken, rhs.historyToken) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("WalletChanges") + deepHashWallet(value: accounts, hasher: &hasher) + deepHashWallet(value: inserted, hasher: &hasher) + deepHashWallet(value: updated, hasher: &hasher) + deepHashWallet(value: deleted, hasher: &hasher) + deepHashWallet(value: historyToken, hasher: &hasher) + } +} + +private class WalletPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return WalletAuthorization(rawValue: enumResultAsInt) + } + return nil + case 130: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return WalletAccountKind(rawValue: enumResultAsInt) + } + return nil + case 131: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return WalletCreditDebit(rawValue: enumResultAsInt) + } + return nil + case 132: + return WalletAccount.fromList(self.readValue() as! [Any?]) + case 133: + return WalletCharge.fromList(self.readValue() as! [Any?]) + case 134: + return WalletChanges.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class WalletPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? WalletAuthorization { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? WalletAccountKind { + super.writeByte(130) + super.writeValue(value.rawValue) + } else if let value = value as? WalletCreditDebit { + super.writeByte(131) + super.writeValue(value.rawValue) + } else if let value = value as? WalletAccount { + super.writeByte(132) + super.writeValue(value.toList()) + } else if let value = value as? WalletCharge { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? WalletChanges { + super.writeByte(134) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class WalletPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return WalletPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return WalletPigeonCodecWriter(data: data) + } +} + +class WalletPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = WalletPigeonCodec(readerWriter: WalletPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol WalletApi { + /// False below iOS 17.4, outside the US, or wherever FinanceKit has no data to give. + func isAvailable() throws -> Bool + func authorizationStatus(completion: @escaping (Result) -> Void) + func requestAuthorization(completion: @escaping (Result) -> Void) + /// Everything since [historyToken], or everything Wallet holds when it is null. A token + /// FinanceKit will not take is treated as null rather than as a failure, so a rejected token + /// costs a backfill and nothing else. + func read(historyToken: String?, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class WalletApiSetup { + static var codec: FlutterStandardMessageCodec { WalletPigeonCodec.shared } + /// Sets up an instance of `WalletApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: WalletApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// False below iOS 17.4, outside the US, or wherever FinanceKit has no data to give. + let isAvailableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.spendable.WalletApi.isAvailable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isAvailableChannel.setMessageHandler { _, reply in + do { + let result = try api.isAvailable() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isAvailableChannel.setMessageHandler(nil) + } + let authorizationStatusChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.spendable.WalletApi.authorizationStatus\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + authorizationStatusChannel.setMessageHandler { _, reply in + api.authorizationStatus { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + authorizationStatusChannel.setMessageHandler(nil) + } + let requestAuthorizationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.spendable.WalletApi.requestAuthorization\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + requestAuthorizationChannel.setMessageHandler { _, reply in + api.requestAuthorization { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + requestAuthorizationChannel.setMessageHandler(nil) + } + /// Everything since [historyToken], or everything Wallet holds when it is null. A token + /// FinanceKit will not take is treated as null rather than as a failure, so a rejected token + /// costs a backfill and nothing else. + let readChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.spendable.WalletApi.read\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + readChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let historyTokenArg: String? = nilOrValue(args[0]) + api.read(historyToken: historyTokenArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + readChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 44f334e2..f7fc5397 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -24,6 +24,8 @@ ???? + NSFinancialDataUsageDescription + Spendable reads your Apple Card and Apple Cash activity so it can budget against it, the same way it does for a connected bank. GIDClientID 639137892495-ubgq2uml273ikum45ou4l1bcodsmdi5q.apps.googleusercontent.com diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 54eed416..0933ee32 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -9,6 +9,12 @@ + + com.apple.developer.financekit + + financial-data + com.apple.developer.associated-domains applinks:spendable.money diff --git a/mobile/lib/banks/banks_controller.dart b/mobile/lib/banks/banks_controller.dart index f42be0ad..b61640a6 100644 --- a/mobile/lib/banks/banks_controller.dart +++ b/mobile/lib/banks/banks_controller.dart @@ -3,6 +3,7 @@ import 'package:spendable_api/spendable_api.dart'; import '../api/api_client.dart'; import '../api/api_error.dart'; +import '../finance_kit/wallet_sync.dart'; import 'banks_providers.dart'; import 'pending_plaid_session.dart'; import 'plaid_link_flow.dart'; @@ -55,6 +56,10 @@ class BanksController extends _$BanksController { } }); + /// Reading Wallet is a device thing, so unlike a bank there is nothing to connect to - the + /// user authorizes it and the first read goes up straight away. + Future connectApple() => _write(() => ref.read(walletSyncProvider).connect()); + Future setSync(BankAccount account, {required bool sync}) => _updateAccount(account.id, BankAccountRequest((builder) => builder.sync_ = sync)); @@ -62,9 +67,6 @@ class BanksController extends _$BanksController { Future assignBudget(BankAccount account, String? budgetId) => _updateAccount(account.id, BankAccountRequest((builder) => builder.budgetId = budgetId)); - /// Two years of history, queued. There is no completion signal, so the user pulls to refresh. - Future syncHistory(String memberId) => _write(() => _api.syncBank(id: memberId).orApiError()); - BanksApi get _api => ref.read(apiProvider).getBanksApi(); PendingPlaidSession get _session => ref.read(pendingPlaidSessionProvider); diff --git a/mobile/lib/banks/banks_controller.g.dart b/mobile/lib/banks/banks_controller.g.dart index 9a1f4018..78dff77d 100644 --- a/mobile/lib/banks/banks_controller.g.dart +++ b/mobile/lib/banks/banks_controller.g.dart @@ -50,7 +50,7 @@ final class BanksControllerProvider } } -String _$banksControllerHash() => r'd41105980e07df6f9e14e0c1bd3e1ca97a18a75e'; +String _$banksControllerHash() => r'fd73319a096c7347cdd8935207dedacca723e2d8'; /// Connecting banks and deciding what each account does. Kept alive because a resumed OAuth /// redirect reaches it before any screen is watching, and an auto-disposed notifier would be diff --git a/mobile/lib/banks/banks_screen.dart b/mobile/lib/banks/banks_screen.dart index 5e8deedb..f0b9ae4c 100644 --- a/mobile/lib/banks/banks_screen.dart +++ b/mobile/lib/banks/banks_screen.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spendable_api/spendable_api.dart'; +import '../budgets/budgets_providers.dart'; +import '../finance_kit/wallet_sync.dart'; import '../money.dart'; import '../theme.dart'; -import '../budgets/budgets_providers.dart'; import 'banks_controller.dart'; import 'banks_providers.dart'; @@ -28,8 +29,16 @@ class BanksScreen extends ConsumerWidget { appBar: AppBar( title: const Text('Banks'), actions: [ + if (ref.watch(walletAvailableProvider).value ?? false) + IconButton( + key: const Key('connect-apple'), + tooltip: 'Connect Apple Card', + icon: const Icon(Icons.wallet), + onPressed: busy ? null : ref.read(banksControllerProvider.notifier).connectApple, + ), IconButton( key: const Key('connect-bank'), + tooltip: 'Connect a bank', icon: const Icon(Icons.add), onPressed: busy ? null : ref.read(banksControllerProvider.notifier).connect, ), @@ -67,39 +76,16 @@ class _Member extends ConsumerWidget { leading: member.hasLogo ? _Logo(memberId: member.id) : const Icon(Icons.account_balance), title: Text(member.name), subtitle: connected ? null : const Text('Reconnect', style: TextStyle(color: SpendableColors.negative)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!connected) - TextButton( + trailing: connected + ? null + : TextButton( key: Key('reconnect-${member.id}'), onPressed: () => controller.reconnect(member.id), child: const Text('Reconnect'), ), - IconButton( - key: Key('sync-${member.id}'), - tooltip: 'Sync history', - icon: const Icon(Icons.history), - onPressed: () => _syncHistory(context, ref, member.id), - ), - ], - ), children: [for (final account in member.bankAccounts) _Account(account: account)], ); } - - Future _syncHistory(BuildContext context, WidgetRef ref, String memberId) async { - final queued = await ref.read(banksControllerProvider.notifier).syncHistory(memberId); - - if (!queued || !context.mounted) return; - - // The job answers nothing when it finishes, so say so rather than implying a wait. - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar( - const SnackBar(content: Text('Syncing history. Pull to refresh to see what has landed.')), - ); - } } class _Logo extends ConsumerWidget { diff --git a/mobile/lib/finance_kit/wallet.g.dart b/mobile/lib/finance_kit/wallet.g.dart new file mode 100644 index 00000000..bc0c99e1 --- /dev/null +++ b/mobile/lib/finance_kit/wallet.g.dart @@ -0,0 +1,465 @@ +// Autogenerated from Pigeon (v26.3.3), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + + +/// Whether the user has let the app read their Wallet. +enum WalletAuthorization { + notDetermined, + denied, + authorized, +} + +/// What the account is, in the app's own words rather than FinanceKit's. Nothing emits `savings` +/// yet: FinanceKit does not say which asset accounts are savings, and guessing from the name is +/// how you end up deleting someone's data over a rename. +enum WalletAccountKind { + creditCard, + cash, + savings, +} + +/// Which way money moved. The server puts the sign on, so nothing here is ever negative. +enum WalletCreditDebit { + credit, + debit, +} + +class WalletAccount { + WalletAccount({ + required this.externalId, + required this.name, + required this.kind, + required this.balance, + required this.creditDebit, + }); + + String externalId; + + String name; + + WalletAccountKind kind; + + /// Unsigned, as a decimal string. Doubles do not hold money. + String balance; + + WalletCreditDebit creditDebit; + + List _toList() { + return [ + externalId, + name, + kind, + balance, + creditDebit, + ]; + } + + Object encode() { + return _toList(); } + + static WalletAccount decode(Object result) { + result as List; + return WalletAccount( + externalId: result[0]! as String, + name: result[1]! as String, + kind: result[2]! as WalletAccountKind, + balance: result[3]! as String, + creditDebit: result[4]! as WalletCreditDebit, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! WalletAccount || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(externalId, other.externalId) && _deepEquals(name, other.name) && _deepEquals(kind, other.kind) && _deepEquals(balance, other.balance) && _deepEquals(creditDebit, other.creditDebit); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +class WalletCharge { + WalletCharge({ + required this.accountExternalId, + required this.externalId, + required this.amount, + required this.creditDebit, + required this.date, + required this.name, + required this.pending, + }); + + String accountExternalId; + + String externalId; + + /// Unsigned, as a decimal string. + String amount; + + WalletCreditDebit creditDebit; + + /// `yyyy-MM-dd`. + String date; + + String name; + + bool pending; + + List _toList() { + return [ + accountExternalId, + externalId, + amount, + creditDebit, + date, + name, + pending, + ]; + } + + Object encode() { + return _toList(); } + + static WalletCharge decode(Object result) { + result as List; + return WalletCharge( + accountExternalId: result[0]! as String, + externalId: result[1]! as String, + amount: result[2]! as String, + creditDebit: result[3]! as WalletCreditDebit, + date: result[4]! as String, + name: result[5]! as String, + pending: result[6]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! WalletCharge || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(accountExternalId, other.accountExternalId) && _deepEquals(externalId, other.externalId) && _deepEquals(amount, other.amount) && _deepEquals(creditDebit, other.creditDebit) && _deepEquals(date, other.date) && _deepEquals(name, other.name) && _deepEquals(pending, other.pending); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + +/// One read of Wallet. +class WalletChanges { + WalletChanges({ + required this.accounts, + required this.inserted, + required this.updated, + required this.deleted, + required this.historyToken, + }); + + List accounts; + + List inserted; + + List updated; + + /// External ids of charges that were reversed or declined. + List deleted; + + /// Where this read finished, to be sent back on the next one. + String historyToken; + + List _toList() { + return [ + accounts, + inserted, + updated, + deleted, + historyToken, + ]; + } + + Object encode() { + return _toList(); } + + static WalletChanges decode(Object result) { + result as List; + return WalletChanges( + accounts: (result[0]! as List).cast(), + inserted: (result[1]! as List).cast(), + updated: (result[2]! as List).cast(), + deleted: (result[3]! as List).cast(), + historyToken: result[4]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! WalletChanges || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(accounts, other.accounts) && _deepEquals(inserted, other.inserted) && _deepEquals(updated, other.updated) && _deepEquals(deleted, other.deleted) && _deepEquals(historyToken, other.historyToken); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is WalletAuthorization) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is WalletAccountKind) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is WalletCreditDebit) { + buffer.putUint8(131); + writeValue(buffer, value.index); + } else if (value is WalletAccount) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is WalletCharge) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is WalletChanges) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : WalletAuthorization.values[value]; + case 130: + final value = readValue(buffer) as int?; + return value == null ? null : WalletAccountKind.values[value]; + case 131: + final value = readValue(buffer) as int?; + return value == null ? null : WalletCreditDebit.values[value]; + case 132: + return WalletAccount.decode(readValue(buffer)!); + case 133: + return WalletCharge.decode(readValue(buffer)!); + case 134: + return WalletChanges.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class WalletApi { + /// Constructor for [WalletApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WalletApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// False below iOS 17.4, outside the US, or wherever FinanceKit has no data to give. + Future isAvailable() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.spendable.WalletApi.isAvailable$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as bool; + } + + Future authorizationStatus() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.spendable.WalletApi.authorizationStatus$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as WalletAuthorization; + } + + Future requestAuthorization() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.spendable.WalletApi.requestAuthorization$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as WalletAuthorization; + } + + /// Everything since [historyToken], or everything Wallet holds when it is null. A token + /// FinanceKit will not take is treated as null rather than as a failure, so a rejected token + /// costs a backfill and nothing else. + Future read(String? historyToken) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.spendable.WalletApi.read$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([historyToken]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as WalletChanges; + } +} diff --git a/mobile/lib/finance_kit/wallet_mapper.dart b/mobile/lib/finance_kit/wallet_mapper.dart new file mode 100644 index 00000000..be9219f7 --- /dev/null +++ b/mobile/lib/finance_kit/wallet_mapper.dart @@ -0,0 +1,62 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:spendable_api/spendable_api.dart'; + +import 'wallet.g.dart'; + +/// Turns what the device read out of Wallet into the batch the server takes. +/// +/// Nothing is decided here beyond naming: the amounts stay unsigned and the server puts the sign +/// on, so there is one place that says which way money went rather than two that can disagree. +FinanceKitChanges buildChanges(WalletChanges changes, {required String? historyTokenBefore}) { + return FinanceKitChanges( + (builder) => builder + ..historyTokenBefore = historyTokenBefore + ..historyTokenAfter = changes.historyToken + ..accounts = ListBuilder(changes.accounts.map(buildAccount)) + ..inserted = ListBuilder(changes.inserted.map(buildCharge)) + ..updated = ListBuilder(changes.updated.map(buildCharge)) + ..deleted = ListBuilder(changes.deleted), + ); +} + +FinanceKitAccount buildAccount(WalletAccount account) { + return FinanceKitAccount( + (builder) => builder + ..externalId = account.externalId + ..name = account.name + ..kind = _kinds[account.kind]! + ..balance = account.balance + ..creditDebitIndicator = _indicators[account.creditDebit]!, + ); +} + +FinanceKitCharge buildCharge(WalletCharge charge) { + return FinanceKitCharge( + (builder) => builder + ..accountExternalId = charge.accountExternalId + ..externalId = charge.externalId + ..amount = charge.amount + ..creditDebitIndicator = _chargeIndicators[charge.creditDebit]! + // The plugin formats it yyyy-MM-dd, which is the one shape the generated Date takes. + ..date = DateTime.parse(charge.date).toDate() + ..name = charge.name + ..pending = charge.pending, + ); +} + +const _kinds = { + WalletAccountKind.creditCard: FinanceKitAccountKindEnum.creditCard, + WalletAccountKind.cash: FinanceKitAccountKindEnum.cash, + WalletAccountKind.savings: FinanceKitAccountKindEnum.savings, +}; + +// Two enums saying the same thing, because the generator makes one per schema that declares it. +const _indicators = { + WalletCreditDebit.credit: FinanceKitAccountCreditDebitIndicatorEnum.credit, + WalletCreditDebit.debit: FinanceKitAccountCreditDebitIndicatorEnum.debit, +}; + +const _chargeIndicators = { + WalletCreditDebit.credit: FinanceKitChargeCreditDebitIndicatorEnum.credit, + WalletCreditDebit.debit: FinanceKitChargeCreditDebitIndicatorEnum.debit, +}; diff --git a/mobile/lib/finance_kit/wallet_sync.dart b/mobile/lib/finance_kit/wallet_sync.dart new file mode 100644 index 00000000..421dcb4a --- /dev/null +++ b/mobile/lib/finance_kit/wallet_sync.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:spendable_api/spendable_api.dart'; + +import '../api/api_client.dart'; +import '../api/api_error.dart'; +import 'wallet.g.dart'; +import 'wallet_mapper.dart'; + +part 'wallet_sync.g.dart'; + +/// What the app needs from the device, named by the app so a test can stand in for it. The +/// generated `WalletApi` carries pigeon's own plumbing, which nothing here wants to know about. +abstract interface class Wallet { + Future isAvailable(); + + Future authorizationStatus(); + + Future requestAuthorization(); + + Future read(String? historyToken); +} + +class PigeonWallet implements Wallet { + PigeonWallet([WalletApi? api]) : _api = api ?? WalletApi(); + + final WalletApi _api; + + @override + Future isAvailable() => _api.isAvailable(); + + @override + Future authorizationStatus() => _api.authorizationStatus(); + + @override + Future requestAuthorization() => _api.requestAuthorization(); + + @override + Future read(String? historyToken) => _api.read(historyToken); +} + +@Riverpod(keepAlive: true) +Wallet wallet(Ref ref) => PigeonWallet(); + +@Riverpod(keepAlive: true) +WalletSync walletSync(Ref ref) => WalletSync(ref.read(apiProvider).getBanksApi(), ref.read(walletProvider)); + +/// False below iOS 17.4 and outside the US, where offering to connect Wallet would be a dead end. +@Riverpod(keepAlive: true) +Future walletAvailable(Ref ref) => ref.read(walletSyncProvider).isAvailable; + +/// Wallet is read on launch and again whenever the app comes back, because a purchase made while +/// it was away is exactly what the user opens it to see. +@Riverpod(keepAlive: true) +AppLifecycleListener walletAutoSync(Ref ref) { + final sync = ref.read(walletSyncProvider); + + unawaited(sync.syncIfAuthorized()); + + final listener = AppLifecycleListener(onResume: () => unawaited(sync.syncIfAuthorized())); + + ref.onDispose(listener.dispose); + + return listener; +} + +/// Reads Wallet on the device and sends what it finds to the server. +class WalletSync { + WalletSync(this._api, this._wallet); + + final BanksApi _api; + final Wallet _wallet; + + Future get isAvailable => _wallet.isAvailable(); + + /// Asks for authorization if it has not been asked for yet, then does a first read. Returns + /// false when the user says no, which is not an error worth showing them. + Future connect() async { + if (!await _wallet.isAvailable()) return false; + + final status = await _wallet.authorizationStatus(); + + final authorized = status == WalletAuthorization.authorized + ? status + : await _wallet.requestAuthorization(); + + if (authorized != WalletAuthorization.authorized) return false; + + await sync(); + + return true; + } + + /// One read, sent as one batch. The server refuses a batch that does not start where it thinks + /// the device is, and the answer to that is to read again from where the server actually is - + /// which is what the connection endpoint hands back. + Future sync() async { + final connection = await _api.connectFinanceKit().orApiError(); + final member = connection.data!; + + final changes = await _wallet.read(member.historyToken); + + await _api + .applyFinanceKitChanges( + id: member.id, + financeKitChanges: buildChanges(changes, historyTokenBefore: member.historyToken), + ) + .orApiError(); + } + + /// Sync if Wallet is already authorized, and say nothing when it is not. Called on launch and + /// on resume, where a prompt would come out of nowhere and a failure is not worth a snackbar. + Future syncIfAuthorized() async { + try { + if (!await _wallet.isAvailable()) return; + if (await _wallet.authorizationStatus() != WalletAuthorization.authorized) return; + + await sync(); + } on ApiError { + return; + } on DioException { + return; + } on PlatformException { + return; + } + } +} diff --git a/mobile/lib/finance_kit/wallet_sync.g.dart b/mobile/lib/finance_kit/wallet_sync.g.dart new file mode 100644 index 00000000..3042dcd4 --- /dev/null +++ b/mobile/lib/finance_kit/wallet_sync.g.dart @@ -0,0 +1,184 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'wallet_sync.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(wallet) +final walletProvider = WalletProvider._(); + +final class WalletProvider extends $FunctionalProvider + with $Provider { + WalletProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Wallet create(Ref ref) { + return wallet(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Wallet value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$walletHash() => r'1e2b2fc56a3eeac65c76f6ec30096a5aa0193763'; + +@ProviderFor(walletSync) +final walletSyncProvider = WalletSyncProvider._(); + +final class WalletSyncProvider + extends $FunctionalProvider + with $Provider { + WalletSyncProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletSyncProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletSyncHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + WalletSync create(Ref ref) { + return walletSync(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(WalletSync value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$walletSyncHash() => r'f45b4da85771fcbeedb91759419cd7e2ab251767'; + +/// False below iOS 17.4 and outside the US, where offering to connect Wallet would be a dead end. + +@ProviderFor(walletAvailable) +final walletAvailableProvider = WalletAvailableProvider._(); + +/// False below iOS 17.4 and outside the US, where offering to connect Wallet would be a dead end. + +final class WalletAvailableProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// False below iOS 17.4 and outside the US, where offering to connect Wallet would be a dead end. + WalletAvailableProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletAvailableProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletAvailableHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return walletAvailable(ref); + } +} + +String _$walletAvailableHash() => r'8bd044384a2fb951b307f507c540f61a9bae1a95'; + +/// Wallet is read on launch and again whenever the app comes back, because a purchase made while +/// it was away is exactly what the user opens it to see. + +@ProviderFor(walletAutoSync) +final walletAutoSyncProvider = WalletAutoSyncProvider._(); + +/// Wallet is read on launch and again whenever the app comes back, because a purchase made while +/// it was away is exactly what the user opens it to see. + +final class WalletAutoSyncProvider + extends + $FunctionalProvider< + AppLifecycleListener, + AppLifecycleListener, + AppLifecycleListener + > + with $Provider { + /// Wallet is read on launch and again whenever the app comes back, because a purchase made while + /// it was away is exactly what the user opens it to see. + WalletAutoSyncProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'walletAutoSyncProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$walletAutoSyncHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AppLifecycleListener create(Ref ref) { + return walletAutoSync(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AppLifecycleListener value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$walletAutoSyncHash() => r'2b35fa8bcceb5edddfc7f2bb4159b33f5c61a7d9'; diff --git a/mobile/lib/shell.dart b/mobile/lib/shell.dart index 1bc33b4d..b0d4c8fe 100644 --- a/mobile/lib/shell.dart +++ b/mobile/lib/shell.dart @@ -1,24 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'banks/banks_screen.dart'; import 'budgets/budgets_screen.dart'; +import 'finance_kit/wallet_sync.dart'; import 'splits/splits_screen.dart'; import 'transactions/transactions_screen.dart'; /// IndexedStack rather than a swapped child, so switching tabs keeps each screen's scroll /// position and its loaded pages. -class Shell extends StatefulWidget { +class Shell extends ConsumerStatefulWidget { const Shell({super.key}); @override - State createState() => _ShellState(); + ConsumerState createState() => _ShellState(); } -class _ShellState extends State { +class _ShellState extends ConsumerState { var _tab = 0; @override Widget build(BuildContext context) { + // Here rather than in the app, so nothing reads Wallet before there is a signed-in user to + // send it to. Nothing renders it; it only has to be alive. + ref.watch(walletAutoSyncProvider); + return Scaffold( body: IndexedStack( index: _tab, diff --git a/mobile/pigeons/wallet.dart b/mobile/pigeons/wallet.dart new file mode 100644 index 00000000..33f0bb00 --- /dev/null +++ b/mobile/pigeons/wallet.dart @@ -0,0 +1,100 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/finance_kit/wallet.g.dart', + swiftOut: 'ios/Runner/FinanceKit/Wallet.g.swift', + dartPackageName: 'spendable', + ), +) +/// Whether the user has let the app read their Wallet. +enum WalletAuthorization { notDetermined, denied, authorized } + +/// What the account is, in the app's own words rather than FinanceKit's. Nothing emits `savings` +/// yet: FinanceKit does not say which asset accounts are savings, and guessing from the name is +/// how you end up deleting someone's data over a rename. +enum WalletAccountKind { creditCard, cash, savings } + +/// Which way money moved. The server puts the sign on, so nothing here is ever negative. +enum WalletCreditDebit { credit, debit } + +class WalletAccount { + WalletAccount({ + required this.externalId, + required this.name, + required this.kind, + required this.balance, + required this.creditDebit, + }); + + String externalId; + String name; + WalletAccountKind kind; + + /// Unsigned, as a decimal string. Doubles do not hold money. + String balance; + WalletCreditDebit creditDebit; +} + +class WalletCharge { + WalletCharge({ + required this.accountExternalId, + required this.externalId, + required this.amount, + required this.creditDebit, + required this.date, + required this.name, + required this.pending, + }); + + String accountExternalId; + String externalId; + + /// Unsigned, as a decimal string. + String amount; + WalletCreditDebit creditDebit; + + /// `yyyy-MM-dd`. + String date; + String name; + bool pending; +} + +/// One read of Wallet. +class WalletChanges { + WalletChanges({ + required this.accounts, + required this.inserted, + required this.updated, + required this.deleted, + required this.historyToken, + }); + + List accounts; + List inserted; + List updated; + + /// External ids of charges that were reversed or declined. + List deleted; + + /// Where this read finished, to be sent back on the next one. + String historyToken; +} + +@HostApi() +abstract class WalletApi { + /// False below iOS 17.4, outside the US, or wherever FinanceKit has no data to give. + bool isAvailable(); + + @async + WalletAuthorization authorizationStatus(); + + @async + WalletAuthorization requestAuthorization(); + + /// Everything since [historyToken], or everything Wallet holds when it is null. A token + /// FinanceKit will not take is treated as null rather than as a failure, so a rejected token + /// costs a backfill and nothing else. + @async + WalletChanges read(String? historyToken); +} diff --git a/mobile/test/banks/banks_screen_test.dart b/mobile/test/banks/banks_screen_test.dart index c06231f1..6b1dacde 100644 --- a/mobile/test/banks/banks_screen_test.dart +++ b/mobile/test/banks/banks_screen_test.dart @@ -5,6 +5,8 @@ import 'package:spendable/api/api_client.dart'; import 'package:spendable/banks/banks_screen.dart'; import 'package:spendable/banks/pending_plaid_session.dart'; import 'package:spendable/banks/plaid_link_flow.dart'; +import 'package:spendable/finance_kit/wallet.g.dart'; +import 'package:spendable/finance_kit/wallet_sync.dart'; import '../support/fakes.dart'; @@ -84,6 +86,53 @@ const _budgets = [ }, ]; +class FakeWallet implements Wallet { + FakeWallet({this.available = true, this.granted = true}); + + final bool available; + + /// What the user taps in the system prompt. + final bool granted; + + var authorization = WalletAuthorization.notDetermined; + var requested = false; + final reads = []; + + @override + Future isAvailable() async => available; + + @override + Future authorizationStatus() async => authorization; + + @override + Future requestAuthorization() async { + requested = true; + + return authorization = granted ? WalletAuthorization.authorized : WalletAuthorization.denied; + } + + @override + Future read(String? historyToken) async { + reads.add(historyToken); + + return WalletChanges( + accounts: [ + WalletAccount( + externalId: 'apple-card', + name: 'Apple Card', + kind: WalletAccountKind.creditCard, + balance: '42.00', + creditDebit: WalletCreditDebit.debit, + ), + ], + inserted: [], + updated: [], + deleted: [], + historyToken: 'tok-2', + ); + } +} + Map _replies([Map? extra]) => { 'GET /api/banks': (status: 200, body: [_member()]), 'GET /api/budgets': (status: 200, body: _budgets), @@ -95,6 +144,7 @@ Future<(FakeApi, FakePlaidLinkFlow)> _pump( Map? replies, FakePlaidLinkFlow? plaid, FakePendingPlaidSession? session, + FakeWallet? wallet, }) async { final api = FakeApi(replies ?? _replies()); final link = plaid ?? FakePlaidLinkFlow(); @@ -105,6 +155,7 @@ Future<(FakeApi, FakePlaidLinkFlow)> _pump( apiProvider.overrideWithValue(api.build()), plaidLinkFlowProvider.overrideWithValue(link), pendingPlaidSessionProvider.overrideWithValue(session ?? FakePendingPlaidSession()), + walletProvider.overrideWithValue(wallet ?? FakeWallet(available: false)), ], child: const MaterialApp(home: BanksScreen()), ), @@ -253,16 +304,54 @@ void main() { expect(patch.data, containsPair('budget_id', 'bgt_rent')); }); - testWidgets('a history sync is queued and says there is nothing to wait for', (tester) async { + // Offering it where FinanceKit has nothing to give - below iOS 17.4, or outside the US - would + // be a dead end. + testWidgets('offers Apple only where the device can read Wallet', (tester) async { + await _pump(tester); + + expect(find.byKey(const Key('connect-apple')), findsNothing); + }); + + testWidgets('connecting Apple authorizes and sends the first read', (tester) async { + final wallet = FakeWallet(); + final (api, _) = await _pump( tester, - replies: _replies({'POST /api/banks/bkm_1/sync': (status: 202, body: null)}), + wallet: wallet, + replies: _replies({ + 'POST /api/banks/finance_kit': ( + status: 200, + body: {'id': 'bkm_apple', 'name': 'Apple', 'history_token': null, 'bank_accounts': []}, + ), + 'POST /api/banks/bkm_apple/finance_kit/changes': ( + status: 200, + body: {'applied': 0, 'history_token': 'tok-2'}, + ), + }), ); - await tester.tap(find.byKey(const Key('sync-bkm_1'))); + await tester.tap(find.byKey(const Key('connect-apple'))); + await tester.pumpAndSettle(); + + expect(wallet.requested, isTrue); + // Read from where the server says it is, which is nowhere on a first connect. + expect(wallet.reads, [null]); + + final sent = api.requests.firstWhere((request) => request.path.endsWith('/changes')); + + expect(sent.data, containsPair('history_token_after', 'tok-2')); + }); + + testWidgets('declining Apple sends nothing and says nothing', (tester) async { + final wallet = FakeWallet(granted: false); + + final (api, _) = await _pump(tester, wallet: wallet); + + await tester.tap(find.byKey(const Key('connect-apple'))); await tester.pumpAndSettle(); - expect(api.requests.map((request) => request.path), contains('/api/banks/bkm_1/sync')); - expect(find.textContaining('Pull to refresh'), findsOneWidget); + expect(wallet.reads, isEmpty); + expect(api.requests.map((request) => request.path), isNot(contains('/api/banks/finance_kit'))); + expect(find.byType(SnackBar), findsNothing); }); } diff --git a/mobile/test/finance_kit/wallet_mapper_test.dart b/mobile/test/finance_kit/wallet_mapper_test.dart new file mode 100644 index 00000000..7d70ed9e --- /dev/null +++ b/mobile/test/finance_kit/wallet_mapper_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spendable/finance_kit/wallet.g.dart'; +import 'package:spendable/finance_kit/wallet_mapper.dart'; +import 'package:spendable_api/spendable_api.dart'; + +WalletCharge _charge({ + String amount = '20.00', + WalletCreditDebit creditDebit = WalletCreditDebit.debit, + String date = '2026-08-01', + bool pending = false, +}) => WalletCharge( + accountExternalId: 'apple-card', + externalId: 'txn-1', + amount: amount, + creditDebit: creditDebit, + date: date, + name: 'Coffee', + pending: pending, +); + +WalletAccount _account({ + WalletAccountKind kind = WalletAccountKind.creditCard, + String balance = '42.00', + WalletCreditDebit creditDebit = WalletCreditDebit.debit, +}) => WalletAccount( + externalId: 'apple-card', + name: 'Apple Card', + kind: kind, + balance: balance, + creditDebit: creditDebit, +); + +WalletChanges _changes({ + List? accounts, + List? inserted, + List? updated, + List? deleted, +}) => WalletChanges( + accounts: accounts ?? [_account()], + inserted: inserted ?? const [], + updated: updated ?? const [], + deleted: deleted ?? const [], + historyToken: 'tok-2', +); + +void main() { + // The sign is the server's decision, so nothing here may quietly make it for it. + test('amounts go up unsigned, with the direction beside them', () { + final changes = buildChanges( + _changes( + inserted: [ + _charge(), + _charge(creditDebit: WalletCreditDebit.credit), + ], + ), + historyTokenBefore: 'tok-1', + ); + + expect(changes.inserted!.map((charge) => charge.amount), ['20.00', '20.00']); + expect(changes.inserted!.map((charge) => charge.creditDebitIndicator.name), ['debit', 'credit']); + }); + + test('carries both tokens, so the server can refuse a batch from the wrong place', () { + final changes = buildChanges(_changes(), historyTokenBefore: 'tok-1'); + + expect(changes.historyTokenBefore, 'tok-1'); + expect(changes.historyTokenAfter, 'tok-2'); + }); + + test('a first read starts from nowhere', () { + expect(buildChanges(_changes(), historyTokenBefore: null).historyTokenBefore, isNull); + }); + + test('account kinds arrive in the words the rest of the API uses', () { + final kinds = [WalletAccountKind.creditCard, WalletAccountKind.cash, WalletAccountKind.savings]; + + final changes = buildChanges( + _changes(accounts: kinds.map((kind) => _account(kind: kind)).toList()), + historyTokenBefore: null, + ); + + expect(changes.accounts.map((account) => account.kind.name), [ + FinanceKitAccountKindEnum.creditCard.name, + FinanceKitAccountKindEnum.cash.name, + FinanceKitAccountKindEnum.savings.name, + ]); + }); + + test('dates cross as dates rather than as whatever the device formatted', () { + final changes = buildChanges(_changes(inserted: [_charge(date: '2026-02-09')]), historyTokenBefore: null); + + final date = changes.inserted!.single.date; + + expect([date.year, date.month, date.day], [2026, 2, 9]); + }); + + test('settled charges travel as updates, reversed ones as ids', () { + final changes = buildChanges( + _changes(updated: [_charge(pending: false)], deleted: ['txn-9']), + historyTokenBefore: null, + ); + + expect(changes.updated!.single.pending, isFalse); + expect(changes.deleted!.toList(), ['txn-9']); + }); +} diff --git a/mobile/tool/generate_pigeon.sh b/mobile/tool/generate_pigeon.sh new file mode 100755 index 00000000..cb04587d --- /dev/null +++ b/mobile/tool/generate_pigeon.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pigeon is run from a global activation rather than a dev dependency: it pins an older `analyzer` +# than build_runner does, and the two cannot share a lockfile. Nothing it emits needs the package +# at runtime, so the constraint only matters here. +cd "$(dirname "$0")/.." + +VERSION=26.3.3 + +dart pub global activate pigeon "$VERSION" >/dev/null + +dart pub global run pigeon --input pigeons/wallet.dart