From f016d8c21647e283687da5687a118b5da657df1e Mon Sep 17 00:00:00 2001 From: Michael St Clair Date: Sun, 16 Aug 2026 11:27:00 -0600 Subject: [PATCH] Read Wallet on the device Pigeon rather than a MethodChannel: the payload is decimal money, dates and enums, and untyped map marshalling is where money bugs breed. The Swift is transport. Amounts go up unsigned with a credit/debit indicator beside them and the server puts the sign on, so there is one place that decides which way money went. Two things the SDK settled that the plan had guessed at: there is no `assetType`, so nothing can tell Apple Cash from Apple Savings without reading the name, which is not worth doing; and history is per account, so the per-account tokens travel together under one opaque string, which is all the server treats it as. Also removes the sync history button. It only ever meant anything for Plaid, and the server now refuses it for anything else. Co-Authored-By: Claude Opus 5 --- .github/workflows/mobile.yml | 6 +- mobile/README.md | 22 + mobile/analysis_options.yaml | 2 + mobile/ios/Runner.xcodeproj/project.pbxproj | 16 + mobile/ios/Runner/AppDelegate.swift | 3 + .../Runner/FinanceKit/FinanceKitPlugin.swift | 233 ++++++++ mobile/ios/Runner/FinanceKit/Wallet.g.swift | 526 ++++++++++++++++++ mobile/ios/Runner/Info.plist | 2 + mobile/ios/Runner/Runner.entitlements | 6 + mobile/lib/banks/banks_controller.dart | 8 +- mobile/lib/banks/banks_controller.g.dart | 2 +- mobile/lib/banks/banks_screen.dart | 40 +- mobile/lib/finance_kit/wallet.g.dart | 465 ++++++++++++++++ mobile/lib/finance_kit/wallet_mapper.dart | 62 +++ mobile/lib/finance_kit/wallet_sync.dart | 131 +++++ mobile/lib/finance_kit/wallet_sync.g.dart | 184 ++++++ mobile/lib/shell.dart | 12 +- mobile/pigeons/wallet.dart | 100 ++++ mobile/test/banks/banks_screen_test.dart | 99 +++- .../test/finance_kit/wallet_mapper_test.dart | 106 ++++ mobile/tool/generate_pigeon.sh | 13 + 21 files changed, 1997 insertions(+), 41 deletions(-) create mode 100644 mobile/ios/Runner/FinanceKit/FinanceKitPlugin.swift create mode 100644 mobile/ios/Runner/FinanceKit/Wallet.g.swift create mode 100644 mobile/lib/finance_kit/wallet.g.dart create mode 100644 mobile/lib/finance_kit/wallet_mapper.dart create mode 100644 mobile/lib/finance_kit/wallet_sync.dart create mode 100644 mobile/lib/finance_kit/wallet_sync.g.dart create mode 100644 mobile/pigeons/wallet.dart create mode 100644 mobile/test/finance_kit/wallet_mapper_test.dart create mode 100755 mobile/tool/generate_pigeon.sh 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