CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

financekit-patterns.mdskills/financekit/references/

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

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
}

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:

CodeCategory
5411Grocery stores
5541Gas stations
5812Restaurants
5814Fast food
5912Pharmacies
5999Miscellaneous retail
7011Hotels and motels
7832Movie theaters
4121Rideshare / taxis
5311Department 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.

StatusMeaning
.authorizedTransaction approved but not yet processed
.pendingProcessing by the institution
.memoInformational entry, not yet settled
.bookedFully settled and posted
.rejectedDeclined 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)

IndicatorBalance EffectExample
.debitDecreases balanceSending money via Apple Cash
.creditIncreases balanceReceiving a payment

Liability Accounts (Apple Card)

IndicatorBalance EffectExample
.debitDecreases available creditMaking a purchase
.creditIncreases available creditPayment 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 complete manager for incremental sync with token persistence and error recovery.

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 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()
        } catch {
            syncError = error
        }
    }

    // MARK: - Incremental 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 -- full resync
            clearToken(for: 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 applyChanges(
        _ changes: FinanceStore.Changes<Transaction>,
        for accountID: UUID
    ) {
        var current = transactions[accountID] ?? []

        // Remove deleted
        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 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.

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.
  • willTerminate() provides a cleanup opportunity before the system terminates the extension.
  • 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.")
        }
    }
}

skills

financekit

CHANGELOG.md

README.md

tile.json