
Financekit
- 2.1k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
financekit is an agent skill that Access eligible Wallet financial data using FinanceKit and FinanceKitUI. Use when querying transactions or balances, reading Apple Card, Apple Cash, Savings, or U.K. conn.
About
The financekit skill. Access eligible Wallet financial data using FinanceKit and FinanceKitUI. Use when querying transactions or balances, reading Apple Card, Apple Cash, Savings, or U.K. connected-account data, requesting financial-data authorization, using TransactionPicker, enabling iOS 26 background delivery, or saving and checking Wallet orders. Apple Card, Apple Cash, Savings, and U.K. FinanceKit provides on-device access to accounts, balances, and transactions with user-controlled authorization. Targets Swift 6.3 / current Apple platforms; query APIs are available from iOS/iPadOS 17.4, from iOS/iPadOS 18, and background delivery from iOS/iPadOS 26. Keep FinanceKit guidance focused on financial-data access, Wallet order storage/querying, TransactionPicker, and background delivery. Route Apple Pay checkout to PassKit, widget UI/timeline work to WidgetKit, and Wallet order-tracking email or Apple Business Connect optimization outside this skill. **Managed entitlement** -- request from Apple via the [FinanceKit entitlement request form](https://developer.apple.com/contact/request/financekit/).
- [Setup and Entitlements](#setup-and-entitlements)
- [Data Availability](#data-availability)
- [Authorization](#authorization)
- [Querying Accounts](#querying-accounts)
- [Account Balances](#account-balances)
Financekit by the numbers
- 2,053 all-time installs (skills.sh)
- +107 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #112 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
financekit capabilities & compatibility
- Capabilities
- [setup and entitlements](#setup and entitlements · [data availability](#data availability) · [authorization](#authorization) · [querying accounts](#querying accounts) · [account balances](#account balances)
- Use cases
- frontend · ui design · api development
What financekit says it does
Apple Card, Apple Cash, Savings, and U.K.
FinanceKit provides on-device access to accounts, balances, and transactions with user-controlled authorization.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill financekitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.1k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I apply financekit correctly using the SKILL.md workflows and reference files?
Access eligible Wallet financial data using FinanceKit and FinanceKitUI. Use when querying transactions or balances, reading Apple Card, Apple Cash, Savings, or U.K. connected-account data, requesting
Who is it for?
Developers and software engineers working with financekit patterns from the skill documentation.
Skip if: Skip when cached docs are empty, boilerplate-only, or outside the skill documented scope.
When should I use this skill?
Access eligible Wallet financial data using FinanceKit and FinanceKitUI. Use when querying transactions or balances, reading Apple Card, Apple Cash, Savings, or U.K. connected-account data, requesting financial-data auth
What you get
Grounded financekit guidance with highlights, triggers, and evidence quotes from SKILL.md.
- Entitlement config
- Authorization flow
- Transaction query snippet
Files
FinanceKit
Access eligible financial data from Apple Wallet, including U.S. Apple Card, Apple Cash, Savings, and U.K. connected-account data. FinanceKit provides on-device access to accounts, balances, and transactions with user-controlled authorization. Targets Swift 6.3 / current Apple platforms; query APIs are available from iOS/iPadOS 17.4, TransactionPicker from iOS/iPadOS 18, and background delivery from iOS/iPadOS 26.
Keep FinanceKit guidance focused on financial-data access, Wallet order storage/querying, TransactionPicker, and background delivery. Route Apple Pay checkout to PassKit, widget UI/timeline work to WidgetKit, and Wallet order-tracking email or Apple Business Connect optimization outside this skill.
Contents
- Setup and Entitlements
- Data Availability
- Authorization
- Querying Accounts
- Account Balances
- Querying Transactions
- Long-Running Queries and History
- Transaction Picker
- Wallet Orders
- Background Delivery
- Common Mistakes
- Review Checklist
- References
Setup and Entitlements
Requirements
1. Managed entitlement -- request com.apple.developer.financekit from Apple via the FinanceKit entitlement request form. This is a managed capability; Apple reviews each application. 2. Organization-level Apple Developer account (individual accounts are not eligible). 3. Account Holder role required to request the entitlement. 4. Eligible App Store app -- the app must be in the Finance category, distributed through the App Store for iPhone in the United States or United Kingdom, and provide financial-management tools such as net-worth, spending, or budgeting features. 5. Per-bundle-ID approval -- Apple assigns the entitlement to the approved bundle ID; do not assume it applies to sibling apps or extensions automatically. 6. If the app offers financial products directly or through a regulated institution, it must allow customers to connect those accounts to Apple Wallet and share the data with FinanceKit.
Project Configuration
1. Add the FinanceKit entitlement through Xcode managed capabilities after Apple approves the request. 2. Add NSFinancialDataUsageDescription to Info.plist -- this string is shown to the user during the authorization prompt. 3. For iOS 26 background delivery, add the FinanceKit entitlement to both the app and extension targets, then use App Groups for shared storage.
<key>NSFinancialDataUsageDescription</key>
<string>This app uses your financial data to track spending and provide budgeting insights.</string>Data Availability
U.S. FinanceKit financial data requires iOS/iPadOS 17.4+ and currently covers eligible Apple Card, Apple Cash, and Savings data; Apple Card Family participants and Apple Cash Family children are excluded. U.K. support requires iOS/iPadOS 18.4+ and uses open banking for supported institutions. Orders APIs are available separately from financial-data query APIs.
Check whether the device supports FinanceKit before making any API calls. This value is constant across launches and iOS versions.
import FinanceKit
guard FinanceStore.isDataAvailable(.financialData) else {
// FinanceKit not available -- do not call any other financial data APIs.
// The framework terminates the app if called when unavailable.
return
}For Wallet orders:
guard FinanceStore.isDataAvailable(.orders) else { return }Data availability returning true does not guarantee data exists on the device. Data access can also become temporarily restricted (e.g., Wallet unavailable, MDM restrictions). Restricted access throws FinanceError.dataRestricted rather than terminating.
Authorization
Request authorization to access user-selected financial accounts. The system presents an account picker where the user chooses which accounts to share and the earliest transaction date to expose.
let store = FinanceStore.shared
let status = try await store.requestAuthorization()
switch status {
case .authorized: break // Proceed with queries
case .denied: break // User declined
case .notDetermined: break // No meaningful choice made
@unknown default: break
}Checking Current Status
Query current authorization without prompting:
let currentStatus = try await store.authorizationStatus()Once the user grants or denies access, requestAuthorization() returns the cached decision without showing the prompt again. Users can change access in Settings > Privacy & Security > Financial Data.
Querying Accounts
Accounts are modeled as an enum with two cases: .asset (e.g., Apple Cash, Savings) and .liability (e.g., Apple Card credit). Both share common properties (id, displayName, institutionName, currencyCode) while liability accounts add credit-specific fields.
func fetchAccounts() async throws -> [Account] {
let query = AccountQuery(
sortDescriptors: [SortDescriptor(\Account.displayName)],
predicate: nil,
limit: nil,
offset: nil
)
return try await store.accounts(query: query)
}Working with Account Types
switch account {
case .asset(let asset):
print("Asset account, currency: \(asset.currencyCode)")
case .liability(let liability):
if let limit = liability.creditInformation.creditLimit {
print("Credit limit: \(limit.amount) \(limit.currencyCode)")
}
}Account Balances
Balances represent the amount in an account at a point in time. A CurrentBalance is one of three cases: .available (includes pending), .booked (posted only), or .availableAndBooked.
func fetchBalances(for accountID: UUID) async throws -> [AccountBalance] {
let predicate = #Predicate<AccountBalance> { balance in
balance.accountID == accountID
}
let query = AccountBalanceQuery(
sortDescriptors: [SortDescriptor(\AccountBalance.id)],
predicate: predicate,
limit: nil,
offset: nil
)
return try await store.accountBalances(query: query)
}Reading Balance Amounts
Amounts are always positive decimals. Use creditDebitIndicator to determine the sign:
func formatBalance(_ balance: Balance) -> String {
let sign = balance.creditDebitIndicator == .debit ? "-" : ""
return "\(sign)\(balance.amount.amount) \(balance.amount.currencyCode)"
}
// Extract from CurrentBalance enum:
switch balance.currentBalance {
case .available(let bal): formatBalance(bal)
case .booked(let bal): formatBalance(bal)
case .availableAndBooked(let available, _): formatBalance(available)
@unknown default: "Unknown"
}Querying Transactions
Use TransactionQuery with Swift predicates, sort descriptors, limit, and offset.
let predicate = #Predicate<Transaction> { $0.accountID == accountID }
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate,
limit: 50,
offset: nil
)
let transactions = try await store.transactions(query: query)Reading Transaction Data
let amount = transaction.transactionAmount
let direction = transaction.creditDebitIndicator == .debit ? "spent" : "received"
print("\(transaction.transactionDescription): \(direction) \(amount.amount) \(amount.currencyCode)")
// merchantName, merchantCategoryCode, foreignCurrencyAmount are optionalBuilt-In Predicate Helpers
FinanceKit provides factory methods for common filters:
// Filter by transaction status
let bookedOnly = TransactionQuery.predicate(forStatuses: [.booked])
// Filter by transaction type
let purchases = TransactionQuery.predicate(forTransactionTypes: [.pointOfSale, .directDebit])
// Filter by merchant category
let groceries = TransactionQuery.predicate(forMerchantCategoryCodes: [
MerchantCategoryCode(rawValue: 5411) // Grocery stores
])For a transaction field table and more query patterns, read references/financekit-patterns.md.
Long-Running Queries and History
Use AsyncSequence-based history APIs for catch-up sync, live updates, or resumable sync. These return inserted, updated, and deleted item IDs plus a HistoryToken.
func catchUpTransactions(for accountID: UUID) async throws {
let history = store.transactionHistory(
forAccountID: accountID,
since: loadSavedToken(),
isMonitoring: false // finish after saved-token catch-up
)
for try await changes in history {
removeLocalRecords(withIDs: changes.deleted)
upsert(changes.inserted + changes.updated)
saveToken(changes.newToken)
}
}History Token Persistence
HistoryToken conforms to Codable. Persist it to resume queries without reprocessing data:
func saveToken(_ token: FinanceStore.HistoryToken) {
if let data = try? JSONEncoder().encode(token) {
UserDefaults.standard.set(data, forKey: "financeHistoryToken")
}
}
func loadSavedToken() -> FinanceStore.HistoryToken? {
guard let data = UserDefaults.standard.data(forKey: "financeHistoryToken") else { return nil }
return try? JSONDecoder().decode(FinanceStore.HistoryToken.self, from: data)
}If a saved token points to compacted history, the framework throws FinanceError.historyTokenInvalid. Discard the token, then immediately run a fresh catch-up query for the affected account or balance stream so local state and the replacement token are rebuilt. Use isMonitoring: true only for a separate live monitor.
Account and Balance History
let accountChanges = store.accountHistory(since: nil, isMonitoring: true)
let balanceChanges = store.accountBalanceHistory(forAccountID: accountID, since: nil, isMonitoring: true)Ongoing budgeting sync should cover the data model the user authorized: account objects for account additions/removals, account balances for trend and widget state, and transactions for spending detail. Use separate history tokens per stream or account so a compacted token only forces resync of the affected stream.
Transaction Picker
For apps that need selective, ephemeral access without full authorization, use TransactionPicker from FinanceKitUI. Access is not persisted -- transactions are passed directly for immediate use.
import FinanceKitUI
struct ExpenseImportView: View {
@State private var selectedTransactions: [Transaction] = []
var body: some View {
if FinanceStore.isDataAvailable(.financialData) {
TransactionPicker(selection: $selectedTransactions) {
Label("Import Transactions", systemImage: "creditcard")
}
}
}
}Wallet Orders
FinanceKit supports saving and querying Wallet orders (e.g., purchase receipts, shipping tracking).
Saving an Order
let result = try await store.saveOrder(signedArchive: archiveData)
switch result {
case .added: break // Saved
case .cancelled: break // User cancelled
case .newerExisting: break // Newer version already in Wallet
@unknown default: break
}Checking for an Existing Order
let orderID = FullyQualifiedOrderIdentifier(
orderTypeIdentifier: "com.merchant.order",
orderIdentifier: "ORDER-123"
)
let result = try await store.containsOrder(matching: orderID, updatedDate: lastKnownDate)
// result: .exists, .newerExists, .olderExists, or .notFoundAdd Order to Wallet Button (FinanceKitUI)
import FinanceKitUI
AddOrderToWalletButton(signedArchive: orderData) { result in
// result: .success(SaveOrderResult) or .failure(Error)
}Background Delivery
iOS 26+ supports background delivery extensions that notify your app of financial data changes outside its lifecycle. User authorization made in the main app is inherited by the extension. Both targets need the FinanceKit entitlement; use App Groups to share data between the app, extension, and related widgets.
Enabling Background Delivery
These registration methods are synchronous and nonthrowing; do not write try or await.
store.enableBackgroundDelivery(
for: [.accounts, .accountBalances, .transactions],
frequency: .daily
)Available frequencies: .hourly, .daily, .weekly. These are expected minimum intervals between extension launches when data changes; longer frequencies give the extension a larger processing window.
Disable selectively or entirely:
store.disableBackgroundDelivery(for: [.transactions])
store.disableAllBackgroundDelivery()Background Delivery Extension
Create a background delivery extension target in Xcode (Background Delivery Extension template). Implement the two async entry points directly on the extension type and return from didReceiveData(for:) only after essential work is saved.
import FinanceKit
@main
struct MyFinanceExtension: BackgroundDeliveryExtension {
func didReceiveData(for types: [FinanceStore.BackgroundDataType]) async {
if types.contains(.transactions) {
await processNewTransactions()
}
if types.contains(.accountBalances) {
await updateBalanceCache()
}
if types.contains(.accounts) {
await refreshAccountList()
}
}
func willTerminate() async { await savePartialWork() }
}Common Mistakes
1. Calling APIs when data is unavailable
DON'T -- skip availability check:
let store = FinanceStore.shared
let status = try await store.requestAuthorization() // Terminates if unavailableDO -- guard availability first:
guard FinanceStore.isDataAvailable(.financialData) else {
showUnavailableMessage()
return
}
let status = try await FinanceStore.shared.requestAuthorization()2. Ignoring the credit/debit indicator
DON'T -- treat amounts as signed values:
let spent = transaction.transactionAmount.amount // Always positiveDO -- apply the indicator:
let amount = transaction.transactionAmount.amount
let signed = transaction.creditDebitIndicator == .debit ? -amount : amount3. Not handling data restriction errors
DON'T -- assume authorized access persists:
let transactions = try await store.transactions(query: query) // Fails if Wallet restrictedDO -- catch FinanceError:
do {
let transactions = try await store.transactions(query: query)
} catch let error as FinanceError {
if case .dataRestricted = error { showDataRestrictedMessage() }
}4. Requesting full snapshots instead of resumable queries
DON'T -- fetch everything on every launch:
let allTransactions = try await store.transactions(query: TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate)],
predicate: nil, limit: nil, offset: nil
))DO -- use history tokens for incremental sync:
let history = store.transactionHistory(
forAccountID: accountID,
since: loadSavedToken(),
isMonitoring: false
)
for try await changes in history {
removeLocalRecords(withIDs: changes.deleted)
upsert(changes.inserted + changes.updated)
saveToken(changes.newToken)
}5. Not persisting history tokens
DON'T -- discard the token:
for try await changes in history {
processChanges(changes)
// Token lost -- next launch reprocesses everything
}DO -- save every token:
for try await changes in history {
removeLocalRecords(withIDs: changes.deleted)
upsert(changes.inserted + changes.updated)
saveToken(changes.newToken)
}6. Misinterpreting credit/debit on liability accounts
Both asset and liability accounts use .debit for outgoing money. But .credit means different things: on an asset account it means money received; on a liability account it means a payment or refund that increases available credit. See references/financekit-patterns.md for a full interpretation table.
Review Checklist
- [ ]
FinanceStore.isDataAvailable(.financialData)checked before any API call - [ ] App eligibility checked: Finance category, App Store iPhone distribution in the U.S. or U.K., financial-management feature set, organization account, Account Holder request
- [ ]
com.apple.developer.financekitentitlement requested and approved for the app bundle ID - [ ]
NSFinancialDataUsageDescriptionset in Info.plist with a clear, specific message - [ ] Authorization status handled for all cases (
.authorized,.denied,.notDetermined) - [ ]
FinanceError.dataRestrictedcaught and handled gracefully - [ ]
CreditDebitIndicatorapplied correctly to amounts (not treated as signed) - [ ] History tokens persisted for resumable queries
- [ ]
FinanceError.historyTokenInvalidhandled by discarding token and immediately resyncing the affected stream - [ ] Ongoing sync plan covers authorized accounts, balances, and transactions, not transactions alone
- [ ] Long-running queries use
isMonitoring: falsewhen live updates are not needed - [ ] Transaction picker used when full authorization is unnecessary
- [ ] Only data the app genuinely needs is queried
- [ ] Deleted IDs from history changes are explicitly removed from local account, balance, or transaction storage
- [ ] Background delivery calls use the synchronous iOS 26 APIs and the extension is in the same App Group as the main app
- [ ] Background delivery registers every needed data type:
.accounts,.accountBalances, and/or.transactions - [ ] FinanceKit entitlement added to both app and background delivery extension targets
- [ ] Financial data deleted when user revokes access
References
- Extended patterns (predicates, sorting, pagination, currency formatting, background updates): references/financekit-patterns.md
- Get started with FinanceKit
- FinanceKit framework
- FinanceKitUI framework
- FinanceStore
- Transaction
- Account
- AccountBalance
- FinanceKit entitlement
- Implementing a background delivery extension
- Meet FinanceKit (WWDC24)
- What's new in Apple Pay (WWDC25)
{
"skill_name": "financekit",
"evals": [
{
"id": 1,
"prompt": "I'm adding FinanceKit to a budgeting app. Outline the entitlement and Info.plist setup, U.S./U.K. availability checks, authorization flow, and a Swift snippet that queries recent transactions for one selected account.",
"expected_output": "A source-grounded FinanceKit setup and query outline that covers managed entitlement eligibility, region/platform support, data availability, authorization, and transaction query details without treating amounts as signed values.",
"files": [],
"assertions": [
"States that `com.apple.developer.financekit` is a managed entitlement requested by the Account Holder for an organization-level Apple Developer account and approved per bundle ID.",
"Mentions App Store Finance category and iPhone distribution in the U.S. or U.K. as entitlement eligibility constraints.",
"Distinguishes U.S. Apple Card, Apple Cash, and Savings data from U.K. open-banking connected-account support and includes relevant iOS availability.",
"Adds `NSFinancialDataUsageDescription` and checks `FinanceStore.isDataAvailable(.financialData)` before authorization or financial-data API calls.",
"Uses `try await FinanceStore.shared.requestAuthorization()` or `authorizationStatus()` and handles `.authorized`, `.denied`, and `.notDetermined`.",
"Builds a `TransactionQuery` with predicate, sort descriptors, limit, and offset, and calls `try await store.transactions(query:)`.",
"Treats `CurrencyAmount.amount` as positive and applies `creditDebitIndicator` for direction or sign."
]
},
{
"id": 2,
"prompt": "Review this import plan: use TransactionPicker for expense reports, store the selected Transaction IDs forever, then use those IDs later to keep syncing updates. If users want full-budgeting sync, ask for all accounts and fetch complete snapshots every launch.",
"expected_output": "A correction-focused answer that keeps TransactionPicker as ephemeral selective access, uses FinanceKit authorization plus history tokens for persistent sync, handles deletions/token invalidation, and avoids over-querying.",
"files": [],
"assertions": [
"Explains that `TransactionPicker` selected transactions are passed directly for immediate use and access is ephemeral, not persistent sync authorization.",
"Recommends FinanceKit authorization and account selection for ongoing account, balance, and transaction access.",
"Uses `transactionHistory(forAccountID:since:isMonitoring:)` with persisted `FinanceStore.HistoryToken` for resumable incremental sync.",
"Saves every `changes.newToken` and applies inserted, updated, and deleted changes, including removing locally stored deleted data.",
"Handles `FinanceError.historyTokenInvalid` by discarding the token and immediately resynchronizing the affected stream.",
"Prefers `isMonitoring: false` for catch-up-only sync and avoids fetching complete snapshots every launch.",
"Keeps the answer in FinanceKit/FinanceKitUI rather than drifting into PassKit payment processing or generic data-store architecture."
]
},
{
"id": 3,
"prompt": "I want an iOS 26 widget to update spending totals when Wallet financial data changes in the background. Show the background delivery extension shape, registration calls, shared-storage requirements, and anything that should remain outside FinanceKit.",
"expected_output": "An iOS 26 background delivery plan that uses the current synchronous registration APIs, implements BackgroundDeliveryExtension entry points, shares data through App Groups, inherits app authorization, and routes unrelated widget UI/payment work to the right domains.",
"files": [],
"assertions": [
"States that FinanceKit background delivery is iOS/iPadOS 26+ and uses a Background Delivery Extension target.",
"Adds the FinanceKit entitlement to both app and extension targets and uses the same App Group for app, extension, and widget shared data.",
"Notes that authorization is requested in the main app and inherited by the background delivery extension.",
"Calls `store.enableBackgroundDelivery(for:frequency:)`, `disableBackgroundDelivery(for:)`, and `disableAllBackgroundDelivery()` without `try` or `await`.",
"Uses documented background data types such as `.transactions`, `.accountBalances`, and `.accounts` with `.hourly`, `.daily`, or `.weekly` update frequency.",
"Implements async `didReceiveData(for:)` and `willTerminate()` on `BackgroundDeliveryExtension` and explains that returning from `didReceiveData` ends the extension's work.",
"Keeps WidgetKit UI/timeline code, PassKit Apple Pay checkout, and order-tracking email optimization outside FinanceKit's core background delivery guidance."
]
}
]
}
FinanceKit Extended Patterns
Overflow reference for the financekit skill. Contains advanced query patterns, currency handling, and background delivery details that exceed the main skill file's scope.
Contents
- Predicate-Based Queries
- Transaction Field Reference
- Sorting and Pagination
- Merchant Category Codes
- Currency Formatting
- Transaction Status Handling
- Balance History and Trends
- Credit/Debit Interpretation by Account Type
- Resumable Sync Manager
- SwiftUI Integration
- Background Delivery Extension Lifecycle
- Error Handling
Predicate-Based Queries
Combining Predicates
FinanceKit queries accept Swift #Predicate macros. Combine conditions directly within the predicate.
import FinanceKit
func fetchRecentDebits(
for accountID: UUID,
since date: Date
) async throws -> [Transaction] {
let store = FinanceStore.shared
let predicate = #Predicate<Transaction> { transaction in
transaction.accountID == accountID &&
transaction.transactionDate > date &&
transaction.creditDebitIndicator == .debit
}
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate,
limit: nil,
offset: nil
)
return try await store.transactions(query: query)
}Using Built-In Predicate Factories
FinanceKit provides static factory methods on query types for common patterns:
// Transactions by status
let bookedPredicate = TransactionQuery.predicate(forStatuses: [.booked])
// Transactions by type
let purchasePredicate = TransactionQuery.predicate(
forTransactionTypes: [.pointOfSale, .directDebit, .billPayment]
)
// Transactions by merchant category code
let diningPredicate = TransactionQuery.predicate(
forMerchantCategoryCodes: [
MerchantCategoryCode(rawValue: 5812), // Restaurants
MerchantCategoryCode(rawValue: 5814), // Fast food
]
)
// Balances by date range (available balance)
let balancePredicate = AccountBalanceQuery.predicate(
availableSince: startDate,
until: endDate
)
// Balances by date range (booked balance)
let bookedBalancePredicate = AccountBalanceQuery.predicate(
bookedSince: startDate,
until: endDate
)Date Range Queries
func fetchTransactionsInRange(
accountID: UUID,
from startDate: Date,
to endDate: Date
) async throws -> [Transaction] {
let predicate = #Predicate<Transaction> { transaction in
transaction.accountID == accountID &&
transaction.transactionDate >= startDate &&
transaction.transactionDate <= endDate
}
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate,
limit: nil,
offset: nil
)
return try await FinanceStore.shared.transactions(query: query)
}Filtering by Posted Date
Some transactions have a postedDate (when booked by the institution) distinct from transactionDate:
let predicate = #Predicate<Transaction> { transaction in
transaction.postedDate != nil &&
transaction.status == .booked
}Transaction Field Reference
| Property | Type | Notes |
|---|---|---|
id | UUID | Unique internal ID; WWDC24 notes it is unique per device |
accountID | UUID | Links the transaction to its parent account |
transactionDate | Date | Time the transaction took place; may differ from posting time |
postedDate | Date? | Posting time; if absent, use transactionDate as the posted date |
transactionAmount | CurrencyAmount | Positive decimal amount plus ISO 4217 currency code |
creditDebitIndicator | CreditDebitIndicator | .debit or .credit; interpret by account type |
transactionDescription | String | Display-friendly description |
originalTransactionDescription | String | Unmodified institution description |
merchantName | String? | Merchant name if available |
merchantCategoryCode | MerchantCategoryCode? | ISO 18245 code wrapper with Int16 raw value |
transactionType | TransactionType | Includes .pointOfSale, .transfer, .refund, .unknown, and other documented cases |
status | TransactionStatus | .authorized, .pending, .booked, .memo, or .rejected |
foreignCurrencyAmount | CurrencyAmount? | Original foreign-currency amount if applicable |
foreignCurrencyExchangeRate | Decimal? | Exchange rate if applicable |
Sorting and Pagination
Multiple Sort Descriptors
let query = TransactionQuery(
sortDescriptors: [
SortDescriptor(\Transaction.transactionDate, order: .reverse),
SortDescriptor(\Transaction.transactionDescription)
],
predicate: nil,
limit: 20,
offset: nil
)Paginated Loading
Use limit and offset for paged access:
@Observable
@MainActor
final class TransactionPager {
private let store = FinanceStore.shared
private let pageSize = 25
private var currentOffset = 0
private(set) var transactions: [Transaction] = []
private(set) var hasMore = true
let accountID: UUID
init(accountID: UUID) {
self.accountID = accountID
}
func loadNextPage() async throws {
guard hasMore else { return }
let predicate = #Predicate<Transaction> { transaction in
transaction.accountID == self.accountID
}
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate,
limit: pageSize,
offset: currentOffset
)
let page = try await store.transactions(query: query)
transactions.append(contentsOf: page)
currentOffset += page.count
hasMore = page.count == pageSize
}
func reset() {
transactions = []
currentOffset = 0
hasMore = true
}
}Account Sorting
let accountQuery = AccountQuery(
sortDescriptors: [
SortDescriptor(\Account.institutionName),
SortDescriptor(\Account.displayName)
],
predicate: nil,
limit: nil,
offset: nil
)Merchant Category Codes
MerchantCategoryCode wraps an Int16 raw value conforming to ISO 18245. Common codes:
| Code | Category |
|---|---|
| 5411 | Grocery stores |
| 5541 | Gas stations |
| 5812 | Restaurants |
| 5814 | Fast food |
| 5912 | Pharmacies |
| 5999 | Miscellaneous retail |
| 7011 | Hotels and motels |
| 7832 | Movie theaters |
| 4121 | Rideshare / taxis |
| 5311 | Department stores |
Grouping Transactions by Category
func groupByCategory(_ transactions: [Transaction]) -> [Int16: [Transaction]] {
var groups: [Int16: [Transaction]] = [:]
for transaction in transactions {
let code = transaction.merchantCategoryCode?.rawValue ?? -1
groups[code, default: []].append(transaction)
}
return groups
}Category Display Name Mapping
MerchantCategoryCode conforms to CustomStringConvertible, providing a description property for display:
if let mcc = transaction.merchantCategoryCode {
print("Category: \(mcc.description)")
}Currency Formatting
FinanceKit stores amounts as CurrencyAmount with a Decimal amount and a currency code string. Use FormatStyle for localized display.
Basic Formatting
func formatCurrency(_ amount: CurrencyAmount) -> String {
amount.amount.formatted(
.currency(code: amount.currencyCode)
)
}Signed Amount Display
Amounts are always positive. Apply sign based on creditDebitIndicator:
func formatSignedAmount(
_ amount: CurrencyAmount,
indicator: CreditDebitIndicator,
accountType: Account
) -> String {
var value = amount.amount
switch accountType {
case .asset:
if indicator == .debit { value = -value }
case .liability:
if indicator == .debit { value = -value }
}
return value.formatted(.currency(code: amount.currencyCode))
}Foreign Currency Transactions
func displayForeignTransaction(_ transaction: Transaction) -> String {
var result = formatCurrency(transaction.transactionAmount)
if let foreign = transaction.foreignCurrencyAmount {
result += " (originally \(formatCurrency(foreign))"
if let rate = transaction.foreignCurrencyExchangeRate {
result += " at rate \(rate)"
}
result += ")"
}
return result
}Transaction Status Handling
Transactions progress through statuses as they are processed by the institution.
| Status | Meaning |
|---|---|
.authorized | Transaction approved but not yet processed |
.pending | Processing by the institution |
.memo | Informational entry, not yet settled |
.booked | Fully settled and posted |
.rejected | Declined by the institution |
Filtering by Status
func fetchPendingTransactions(for accountID: UUID) async throws -> [Transaction] {
let predicate = #Predicate<Transaction> { transaction in
transaction.accountID == accountID &&
(transaction.status == .pending || transaction.status == .authorized)
}
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate,
limit: nil,
offset: nil
)
return try await FinanceStore.shared.transactions(query: query)
}Status Display
func statusLabel(for status: TransactionStatus) -> String {
switch status {
case .authorized: "Authorized"
case .pending: "Pending"
case .memo: "Memo"
case .booked: "Posted"
case .rejected: "Declined"
@unknown default: "Unknown"
}
}Balance History and Trends
Use paginated balance queries to build historical balance charts.
func fetchBalanceHistory(
for accountID: UUID,
limit: Int = 30
) async throws -> [AccountBalance] {
let predicate = #Predicate<AccountBalance> { balance in
balance.accountID == accountID
}
let query = AccountBalanceQuery(
sortDescriptors: [SortDescriptor(\AccountBalance.id)],
predicate: predicate,
limit: limit,
offset: nil
)
return try await FinanceStore.shared.accountBalances(query: query)
}Date-Ranged Balance Queries
Use the built-in predicate factories:
let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
let query = AccountBalanceQuery(
sortDescriptors: [SortDescriptor(\AccountBalance.id)],
predicate: AccountBalanceQuery.predicate(
availableSince: thirtyDaysAgo,
until: nil
),
limit: nil,
offset: nil
)Extracting Chart Data
struct BalanceDataPoint: Identifiable {
let id: UUID
let date: Date
let amount: Decimal
let currencyCode: String
}
func balanceChartData(from balances: [AccountBalance]) -> [BalanceDataPoint] {
balances.compactMap { balance in
switch balance.currentBalance {
case .available(let bal), .booked(let bal):
let signed = bal.creditDebitIndicator == .credit ? bal.amount.amount : -bal.amount.amount
return BalanceDataPoint(
id: balance.id,
date: bal.asOfDate,
amount: signed,
currencyCode: bal.currencyCode
)
case .availableAndBooked(let available, _):
let signed = available.creditDebitIndicator == .credit
? available.amount.amount : -available.amount.amount
return BalanceDataPoint(
id: balance.id,
date: available.asOfDate,
amount: signed,
currencyCode: balance.currencyCode
)
@unknown default:
return nil
}
}
}Credit/Debit Interpretation by Account Type
The meaning of CreditDebitIndicator varies by account type. This is a common source of confusion.
Asset Accounts (Apple Cash, Savings)
| Indicator | Balance Effect | Example |
|---|---|---|
.debit | Decreases balance | Sending money via Apple Cash |
.credit | Increases balance | Receiving a payment |
Liability Accounts (Apple Card)
| Indicator | Balance Effect | Example |
|---|---|---|
.debit | Decreases available credit | Making a purchase |
.credit | Increases available credit | Payment or refund |
Unified Interpretation
enum MoneyDirection {
case incoming, outgoing
}
func direction(
of transaction: Transaction,
in account: Account
) -> MoneyDirection {
// For both asset and liability accounts, debit represents money going out
// (balance decrease for assets, credit decrease for liabilities)
transaction.creditDebitIndicator == .debit ? .outgoing : .incoming
}Resumable Sync Manager
A manager for catch-up sync (isMonitoring: false), live monitoring (true), token persistence, and explicit deletion removal.
import FinanceKit
@Observable
@MainActor
final class FinanceSyncManager {
private let store = FinanceStore.shared
private let tokenKey = "financekit.sync.token"
private(set) var accounts: [Account] = []
private(set) var balances: [UUID: [AccountBalance]] = [:]
private(set) var transactions: [UUID: [Transaction]] = [:]
private(set) var syncError: Error?
// MARK: - Initial Load
func performInitialLoad() async {
guard FinanceStore.isDataAvailable(.financialData) else { return }
do {
let status = try await store.authorizationStatus()
guard status == .authorized else { return }
accounts = try await fetchAllAccounts()
for account in accounts {
balances[account.id] = try await fetchBalances(for: account.id)
}
} catch {
syncError = error
}
}
// MARK: - Catch-Up Sync
func syncTransactions(for accountID: UUID) async {
let token = loadToken(for: accountID)
do {
let history = store.transactionHistory(
forAccountID: accountID,
since: token,
isMonitoring: false
)
for try await changes in history {
applyChanges(changes, for: accountID)
saveToken(changes.newToken, for: accountID)
}
} catch let error as FinanceError where error == .historyTokenInvalid {
// Token expired: discard it, then immediately rebuild local state
// and replacement token from a fresh catch-up sequence.
clearToken(for: accountID)
transactions[accountID] = []
await syncTransactions(for: accountID)
} catch {
syncError = error
}
}
// MARK: - Live Monitoring
func startMonitoring(for accountID: UUID) async {
let token = loadToken(for: accountID)
do {
let history = store.transactionHistory(
forAccountID: accountID,
since: token,
isMonitoring: true
)
for try await changes in history {
applyChanges(changes, for: accountID)
saveToken(changes.newToken, for: accountID)
}
} catch {
syncError = error
}
}
// MARK: - Private
private func fetchAllAccounts() async throws -> [Account] {
let query = AccountQuery(
sortDescriptors: [SortDescriptor(\Account.displayName)],
predicate: nil,
limit: nil,
offset: nil
)
return try await store.accounts(query: query)
}
private func fetchBalances(for accountID: UUID) async throws -> [AccountBalance] {
let predicate = #Predicate<AccountBalance> { $0.accountID == accountID }
let query = AccountBalanceQuery(
sortDescriptors: [SortDescriptor(\AccountBalance.id)],
predicate: predicate,
limit: nil,
offset: nil
)
return try await store.accountBalances(query: query)
}
private func applyChanges(
_ changes: FinanceStore.Changes<Transaction>,
for accountID: UUID
) {
var current = transactions[accountID] ?? []
// Remove deleted IDs first so local storage matches Wallet removals.
let deletedSet = Set(changes.deleted)
current.removeAll { deletedSet.contains($0.id) }
// Update existing
for updated in changes.updated {
if let index = current.firstIndex(where: { $0.id == updated.id }) {
current[index] = updated
}
}
// Insert new
current.append(contentsOf: changes.inserted)
// Sort by date descending
current.sort { $0.transactionDate > $1.transactionDate }
transactions[accountID] = current
}
private func applyBalanceChanges(
_ changes: FinanceStore.Changes<AccountBalance>,
for accountID: UUID
) {
var current = balances[accountID] ?? []
// Remove deleted IDs first so local storage matches Wallet removals.
let deletedSet = Set(changes.deleted)
current.removeAll { deletedSet.contains($0.id) }
for updated in changes.updated {
if let index = current.firstIndex(where: { $0.id == updated.id }) {
current[index] = updated
}
}
current.append(contentsOf: changes.inserted)
balances[accountID] = current
}
private func saveToken(_ token: FinanceStore.HistoryToken, for accountID: UUID) {
let key = "\(tokenKey).\(accountID.uuidString)"
if let data = try? JSONEncoder().encode(token) {
UserDefaults.standard.set(data, forKey: key)
}
}
private func loadToken(for accountID: UUID) -> FinanceStore.HistoryToken? {
let key = "\(tokenKey).\(accountID.uuidString)"
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(FinanceStore.HistoryToken.self, from: data)
}
private func clearToken(for accountID: UUID) {
let key = "\(tokenKey).\(accountID.uuidString)"
UserDefaults.standard.removeObject(forKey: key)
}
}SwiftUI Integration
Account List View
import SwiftUI
import FinanceKit
struct AccountListView: View {
@State private var accounts: [Account] = []
var body: some View {
NavigationStack {
List(accounts, id: \.id) { account in
NavigationLink(value: account.id) {
VStack(alignment: .leading) {
Text(account.displayName).font(.headline)
Text(account.institutionName).font(.subheadline).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Accounts")
.navigationDestination(for: UUID.self) { TransactionListView(accountID: $0) }
.task { await loadAccounts() }
}
}
private func loadAccounts() async {
guard FinanceStore.isDataAvailable(.financialData) else { return }
do {
let status = try await FinanceStore.shared.requestAuthorization()
guard status == .authorized else { return }
let query = AccountQuery(
sortDescriptors: [SortDescriptor(\Account.displayName)],
predicate: nil, limit: nil, offset: nil
)
accounts = try await FinanceStore.shared.accounts(query: query)
} catch { }
}
}Transaction List View
struct TransactionListView: View {
let accountID: UUID
@State private var transactions: [Transaction] = []
var body: some View {
List(transactions, id: \.id) { transaction in
HStack {
VStack(alignment: .leading) {
Text(transaction.transactionDescription)
if let merchant = transaction.merchantName {
Text(merchant).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
VStack(alignment: .trailing) {
let amount = transaction.transactionAmount
let sign = transaction.creditDebitIndicator == .debit ? "-" : "+"
Text("\(sign)\(amount.amount.formatted(.currency(code: amount.currencyCode)))")
.font(.body.monospacedDigit())
Text(transaction.transactionDate, style: .date)
.font(.caption).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Transactions")
.task {
let predicate = #Predicate<Transaction> { $0.accountID == accountID }
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate, limit: 100, offset: nil
)
transactions = (try? await FinanceStore.shared.transactions(query: query)) ?? []
}
}
}Background Delivery Extension Lifecycle
Extension Setup
The background delivery extension requires: 1. A new extension target using the Background Delivery Extension template. 2. Both app and extension in the same App Group for shared data access. 3. The FinanceKit entitlement on both targets. 4. Financial-data authorization requested in the main app before enabling delivery; the extension inherits the app's authorization.
Shared Data with App Groups
Use a shared container for data accessible to both the app and extension:
let sharedDefaults = UserDefaults(suiteName: "group.com.myapp.finance")
// In extension: sync latest data to shared container
func processNewTransactions() async {
let store = FinanceStore.shared
for account in try await fetchAccounts() {
let history = store.transactionHistory(
forAccountID: account.id, since: loadSharedToken(), isMonitoring: false
)
for try await changes in history {
persistToSharedStore(changes)
saveSharedToken(changes.newToken)
}
}
}Extension Lifecycle
didReceiveData(for:)is called when the system detects changes matching the registered data types.- Returning from
didReceiveData(for:)closes the extension, so save essential work before returning. willTerminate()provides a cleanup opportunity before the system terminates the extension.willTerminate()may not be called for every system termination path.- The extension has limited runtime. Perform only essential work (data sync, cache updates).
- Do not start long-running tasks or network requests that may not complete.
Error Handling
FinanceError Cases
do {
let transactions = try await store.transactions(query: query)
} catch let error as FinanceError {
switch error {
case .dataRestricted(let dataType):
handleRestriction(dataType) // Wallet unavailable or MDM restricted
case .historyTokenInvalid:
discardSavedToken() // Token points to compacted history
case .unknown:
logError(error)
@unknown default:
logError(error)
}
}Graceful Degradation
@Observable
@MainActor
final class FinanceDataProvider {
enum State {
case loading, available([Transaction]), unavailable(reason: String)
}
private(set) var state: State = .loading
func load(accountID: UUID) async {
guard FinanceStore.isDataAvailable(.financialData) else {
state = .unavailable(reason: "Financial data is not available on this device.")
return
}
do {
let status = try await FinanceStore.shared.authorizationStatus()
guard status == .authorized else {
state = .unavailable(reason: "Access to financial data has not been granted.")
return
}
let predicate = #Predicate<Transaction> { $0.accountID == accountID }
let query = TransactionQuery(
sortDescriptors: [SortDescriptor(\Transaction.transactionDate, order: .reverse)],
predicate: predicate, limit: 50, offset: nil
)
state = .available(try await FinanceStore.shared.transactions(query: query))
} catch let error as FinanceError {
state = .unavailable(reason: error == .dataRestricted(.financialData)
? "Financial data is temporarily restricted."
: "Unable to load financial data.")
} catch {
state = .unavailable(reason: "An unexpected error occurred.")
}
}
}Related skills
How it compares
Use over generic iOS finance tutorials when the task specifically requires Apple FinanceKit managed entitlements and transaction query APIs.
FAQ
Who is financekit for?
Developers and software engineers working with financekit patterns from the skill documentation.
When should I use financekit?
Access eligible Wallet financial data using FinanceKit and FinanceKitUI. Use when querying transactions or balances, reading Apple Card, Apple Cash, Savings, or U.K. connected-account data, requesting financial-data authorization, using TransactionPicker, enabling iOS 26 backgrou
Is financekit safe to install?
Review the Security Audits panel on this page before installing in production.