Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
2 changes: 2 additions & 0 deletions mobile/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
16 changes: 16 additions & 0 deletions mobile/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
FK00000000000000000002 /* FinanceKitPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FinanceKitPlugin.swift; sourceTree = "<group>"; };
FK00000000000000000003 /* Wallet.g.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Wallet.g.swift; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
Expand Down Expand Up @@ -124,10 +128,20 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FK00000000000000000001 /* FinanceKit */,
);
path = Runner;
sourceTree = "<group>";
};
FK00000000000000000001 /* FinanceKit */ = {
isa = PBXGroup;
children = (
FK00000000000000000002 /* FinanceKitPlugin.swift */,
FK00000000000000000003 /* Wallet.g.swift */,
);
path = FinanceKit;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand Down Expand Up @@ -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;
};
Expand Down
3 changes: 3 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
233 changes: 233 additions & 0 deletions mobile/ios/Runner/FinanceKit/FinanceKitPlugin.swift
Original file line number Diff line number Diff line change
@@ -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<WalletAuthorization, Error>) -> 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<WalletAuthorization, Error>) -> 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<WalletChanges, Error>) -> 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
Loading
Loading