financekit

financekit

热门

使用 FinanceKit 和 FinanceKitUI 获取符合条件的 Wallet 财务数据。适用场景包括:查询交易明细或余额,读取 Apple Card、Apple Cash、Savings 存款账户及英国关联账户数据,请求财务数据授权,使用 TransactionPicker,开启 iOS 26 后台推送,以及保存和检查 Wallet 订单。

967Star
49Fork
更新于 2026/7/31
SKILL.md
只读
名称
financekit
描述

使用 FinanceKit 和 FinanceKitUI 获取符合条件的 Wallet 财务数据。适用场景包括:查询交易明细或余额,读取 Apple Card、Apple Cash、Savings 存款账户及英国关联账户数据,请求财务数据授权,使用 TransactionPicker,开启 iOS 26 后台推送,以及保存和检查 Wallet 订单。

FinanceKit

获取来自 Apple Wallet 的符合条件的财务数据,包括美国地区的 Apple Card、Apple Cash、Savings 存款账户以及英国地区的关联账户数据。FinanceKit 提供由用户完全控制授权的设备端账户、余额与交易记录访问能力。目标平台为 Swift 6.3 及当前 Apple 平台;查询 API 需 iOS/iPadOS 17.4+,TransactionPicker 需 iOS/iPadOS 18+,后台推送(Background Delivery)需 iOS/iPadOS 26+。

请保持 FinanceKit 相关指导聚焦于财务数据访问、Wallet 订单存储/查询、TransactionPicker 和后台推送。若涉及 Apple Pay 结账请引导至 PassKit,小组件 UI/时间线开发请引导至 WidgetKit,而 Wallet 订单跟踪邮件或 Apple Business Connect 优化则不属于本 Skill 的范畴。

目录

配置与 Entitlement 权限

申请前提

  1. 托管 Entitlement (Managed entitlement) —— 通过 FinanceKit Entitlement 申请表单 向 Apple 申请 com.apple.developer.financekit。这是一项托管权限,Apple 会对每个应用进行严格审核。
  2. 组织级 Apple Developer 账号(个人开发者账号不具备申请资格)。
  3. 需具备 Account Holder (账号持有人) 角色方可提交申请。
  4. 符合条件的 App Store 应用 —— 应用必须归属于财务(Finance)分类,通过美国或英国的 iPhone App Store 分发,并提供资产净值、消费追踪或预算管理等财务管理工具。
  5. 按 Bundle ID 独立审核 —— Apple 会将权限绑定到已批准的 Bundle ID,切勿假设其会自动适用于同系列兄弟应用或扩展。
  6. 如果应用本身或通过受监管机构直接提供金融产品,则必须允许客户将这些账户连接到 Apple Wallet 并与 FinanceKit 共享数据。

项目配置

  1. Apple 批准申请后,通过 Xcode 的 Managed Capabilities 添加 FinanceKit Entitlement。
  2. 在 Info.plist 中添加 NSFinancialDataUsageDescription 键 —— 该字符串将在弹出授权提示框时展示给用户。
  3. 对于 iOS 26 的后台推送(Background Delivery),需同时为 App Target 和 Extension Target 添加 FinanceKit Entitlement,并使用 App Groups 进行共享存储。
<key>NSFinancialDataUsageDescription</key>
<string>此应用需要使用您的财务数据来追踪支出并提供预算分析。</string>

数据可用性

美国地区的 FinanceKit 财务数据要求 iOS/iPadOS 17.4+,目前涵盖符合条件的 Apple Card、Apple Cash 和 Savings 存款账户数据;Apple Card Family 参与者和 Apple Cash Family 儿童账户除外。英国地区支持要求 iOS/iPadOS 18.4+,并通过 Open Banking 为受支持的金融机构提供服务。订单 (Orders) API 与财务数据查询 API 是独立的。

在调用任何 API 之前,请务必检查设备是否支持 FinanceKit。该返回值在应用启动和 iOS 版本之间保持恒定。

import FinanceKit

guard FinanceStore.isDataAvailable(.financialData) else {
    // FinanceKit 不可用 -- 切勿调用任何其他财务数据 API。
    // 如果在不可用状态下调用,框架将直接终止应用。
    return
}

对于 Wallet 订单:

guard FinanceStore.isDataAvailable(.orders) else { return }

数据可用性返回 true 并不保证设备上一定存在数据。数据访问也可能临时受限(例如 Wallet 不可用、MDM 限制)。受限访问会抛出 FinanceError.dataRestricted 而不是终止应用。

用户授权

请求访问用户选定的财务账户授权。系统会展示一个账户选择器,由用户选择要共享哪些账户以及暴露的最早交易日期。

let store = FinanceStore.shared

let status = try await store.requestAuthorization()
switch status {
case .authorized:    break  // 继续执行查询
case .denied:        break  // 用户拒绝
case .notDetermined: break  // 未做出明确选择
@unknown default:    break
}

检查当前授权状态

在不弹窗提示的情况下查询当前授权状态:

let currentStatus = try await store.authorizationStatus()

一旦用户授予或拒绝访问,requestAuthorization() 将直接返回缓存的决定,不再重复展示弹窗。用户随时可以在“设置 > 隐私与安全性 > 财务数据”中修改访问权限。

查询账户

账户在 Swift 中被建模为一个枚举,包含两个 case:.asset(资产账户,如 Apple Cash、Savings)和 .liability(负债账户,如 Apple Card 信用卡)。两者共享通用属性(iddisplayNameinstitutionNamecurrencyCode),而负债账户额外包含信用卡特有的字段。

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)
}

处理不同账户类型

switch account {
case .asset(let asset):
    print("资产账户,币种:\(asset.currencyCode)")
case .liability(let liability):
    if let limit = liability.creditInformation.creditLimit {
        print("信用额度:\(limit.amount) \(limit.currencyCode)")
    }
}

账户余额

余额代表账户在特定时间点的金额。CurrentBalance 分为三种情况:.available(包含未结算/待处理金额)、.booked(仅已记账金额)或 .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)
}

读取余额数值

金额始终为正的 Decimal。使用 creditDebitIndicator 来判断正负号:

func formatBalance(_ balance: Balance) -> String {
    let sign = balance.creditDebitIndicator == .debit ? "-" : ""
    return "\(sign)\(balance.amount.amount) \(balance.amount.currencyCode)"
}

// 从 CurrentBalance 枚举中提取:
switch balance.currentBalance {
case .available(let bal):       formatBalance(bal)
case .booked(let bal):          formatBalance(bal)
case .availableAndBooked(let available, _): formatBalance(available)
@unknown default: "未知"
}

查询交易记录

结合 Swift 谓词(predicates)、排序描述符(sort descriptors)、limit 和 offset 使用 TransactionQuery

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)

读取交易数据

let amount = transaction.transactionAmount
let direction = transaction.creditDebitIndicator == .debit ? "支出" : "收入"
print("\(transaction.transactionDescription): \(direction) \(amount.amount) \(amount.currencyCode)")
// merchantName、merchantCategoryCode、foreignCurrencyAmount 为可选属性

内置谓词辅助函数

FinanceKit 为常用筛选条件提供了工厂方法:

// 按交易状态筛选
let bookedOnly = TransactionQuery.predicate(forStatuses: [.booked])

// 按交易类型筛选
let purchases = TransactionQuery.predicate(forTransactionTypes: [.pointOfSale, .directDebit])

// 按商户类别筛选
let groceries = TransactionQuery.predicate(forMerchantCategoryCodes: [
    MerchantCategoryCode(rawValue: 5411)  // 杂货店/超市
])

有关交易字段对照表和更多查询模式,请参阅 references/financekit-patterns.md

长轮询查询与历史记录

使用基于 AsyncSequence 的历史记录 API 来进行追赶同步(catch-up sync)、实时更新或可断点续传的同步。这些 API 会返回新增、更新和删除的项目 ID 以及一个 HistoryToken

func catchUpTransactions(for accountID: UUID) async throws {
    let history = store.transactionHistory(
        forAccountID: accountID,
        since: loadSavedToken(),
        isMonitoring: false  // 追赶完已保存 token 的历史变更后即结束
    )

    for try await changes in history {
        removeLocalRecords(withIDs: changes.deleted)
        upsert(changes.inserted + changes.updated)
        saveToken(changes.newToken)
    }
}

History Token 的持久化存储

HistoryToken 遵循 Codable 协议。将其持久化保存,以便在恢复查询时无需重复处理已有的数据:

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)
}

如果保存的 token 指向已压缩(compacted)的历史记录,框架将抛出 FinanceError.historyTokenInvalid。此时应丢弃该 token,并立即为受影响的账户或余额流重新发起一次全量追赶查询,以重建本地状态和新的 token。仅在单独的实时监听任务中使用 isMonitoring: true

账户与余额历史记录

let accountChanges = store.accountHistory(since: nil, isMonitoring: true)
let balanceChanges = store.accountBalanceHistory(forAccountID: accountID, since: nil, isMonitoring: true)

持续的预算同步应覆盖用户授权的数据模型:账户对象(处理账户添加/移除)、账户余额(处理趋势和 Widget 状态)以及交易记录(处理消费明细)。建议为每个数据流或账户使用独立的 history token,这样当某个 token 失效压缩时,仅需重新同步受影响的单个数据流。

交易选择器 (Transaction Picker)

对于只需要选择性、临时访问而不需要完整授权的应用,可以使用 FinanceKitUI 中的 TransactionPicker。访问权限不会被持久化 —— 选中的交易记录将直接传递给应用供即时使用。

import FinanceKitUI

struct ExpenseImportView: View {
    @State private var selectedTransactions: [Transaction] = []

    var body: some View {
        if FinanceStore.isDataAvailable(.financialData) {
            TransactionPicker(selection: $selectedTransactions) {
                Label("导入交易记录", systemImage: "creditcard")
            }
        }
    }
}

Wallet 订单

FinanceKit 支持保存和查询 Wallet 订单(如购买收据、物流跟踪信息)。

保存订单

let result = try await store.saveOrder(signedArchive: archiveData)
switch result {
case .added:        break  // 保存成功
case .cancelled:    break  // 用户取消
case .newerExisting: break // Wallet 中已存在更新的版本
@unknown default:   break
}

检查是否存在已有订单

let orderID = FullyQualifiedOrderIdentifier(
    orderTy

<!-- truncated for translation batch; full body continues in source -->