Native Product Integration
Connecting Finely Transactions to App Intents
App Intents transaction path
Siri, Shortcuts, Spotlight, and payment automation reuse Finely's draft, review, and SwiftData-backed store.
System input must not bypass the rules used by Finely's native transaction form.
Accept conversational or typed input without inventing missing values, bypassing review, or creating a second write path.
The decision
Keep App Intents as adapters to the existing transaction path.
The intent collects input. Finely decides what can be saved. Conversational requests build an editable TransactionDraft; payment automation uses a separate positive-amount path. Both save through TransactionStore.
Technical deep dive · Transaction boundary
The intent collects input. Finely decides what can be saved.
App Intents collect a phrase or typed parameters. Finely refreshes entitlement state when needed, builds a TransactionDraft, requests confirmation for conversational input, and saves through TransactionStore.shared.
Boundary 01 · Collect without deciding
Treat system parameters as untrusted input.
Shortcuts can pass amount, label, date, category, and account. Siri can provide a short command. TransactionDraft reconciles those explicit fields with parsed values, rejects missing amounts, and discards a category that contradicts the transaction kind.
Input comes from Siri, Shortcuts, Spotlight, or the payment automation intent.
Convert money to Decimal and pass values through the intent boundary.
Explicit fields win, contradictions are discarded, and inference stays bounded.
Conversational entry requests confirmation and can open field review. Payment automation uses a separate positive-amount path.
TransactionStore.shared inserts the model, saves its SwiftData main context, sorts the ledger after success, and posts a change notification.
@MainActor
func perform() async throws -> some IntentResult {
if !Monetization.canAddTransaction() {
await PurchaseService.shared.refreshEntitlement()
}
guard var draft = TransactionDraft(
message: message,
amount: amount.map(IntentMoney.decimal),
category: category,
account: account,
history: TransactionStore.shared.transactions
) else {
throw $amount.needsValueError()
}
do {
try await requestConfirmation(dialog: confirmation(for: draft))
} catch {
try await review(&draft)
}
guard TransactionStore.shared.add(draft.makeTransaction()) else {
return .result(dialog: notSaved)
}
return .result(dialog: added(draft))
}Both transaction intents create a Transaction from a draft and save it through TransactionStore.shared.
guard let amount = explicitAmount ?? parsed.amount,
amount > 0 else {
return nil
}
category =
explicit.category?.side == parsed.kind.side
? explicit.category
: inferCategory()
account = explicit.account
?? inferAccount()One action from system surface to SwiftData
Follow the draft before it is saved.
Siri, Shortcuts, and Spotlight reach AddTransactionIntent. Payment automation reaches LogPaymentIntent. Both paths create a draft and save accepted data through TransactionStore.shared.
for groceries
A user action enters from outside the app.
Siri, Shortcuts, or Spotlight supplies conversational or typed input. LogPaymentIntent receives a positive payment amount and an optional merchant. None of it is persisted yet.
SiriShortcutsWalletThe App Intent forwards input to the transaction domain.
The intent refreshes cold-launch state, converts boundary values such as money to Decimal, and forwards input to the domain builder.
perform()DecimalTransactionDraft reconciles the proposed values.
Explicit values are reconciled with parsed values, contradictions are rejected, and inference remains bounded by product policy.
TransactionDraftambiguity visibleConversational entry requests confirmation or field review.
AddTransactionIntent requests confirmation; if it does not complete, the user can edit fields in a review loop. LogPaymentIntent follows its separate positive-amount path without that loop.
confirmationbounded automationTransactionStore.shared receives every accepted action.
A successful save appends and sorts the Transaction, then posts the store change notification. A failed save never becomes a success message.
The phone surface is reconstructed. The draft policy, confirmation and review behavior, Decimal conversion, payment path, and TransactionStore write come from the current Finely implementation.
Verification · Draft and store behavior
Test the draft rules and save result.
AppIntentsTests covers parsing, explicit-value precedence, category contradictions, missing amounts, Decimal conversion, and Apple Pay drafts. TransactionStore reports whether the SwiftData save succeeds.
func testDraftDiscardsContradictoryCategory() {
let draft = TransactionDraft(
message: "24 at the bakery",
amount: nil,
category: incomeCategory,
history: []
)
XCTAssertEqual(draft?.kind, .expense)
XCTAssertNotEqual(draft?.category, incomeCategory)
}A missing amount stops the action; the intent asks for one instead of inventing a value.
Every accepted action creates the same Transaction model and saves through TransactionStore.shared.
LogPaymentIntent requires a positive payment amount and keeps merchant handling inside the payment path.
What this decision adds.
The integration rejects missing amounts and may require field review. It supports fewer shortcuts than a generic parser, but accepted actions follow the same persistence rules as the app.
System actions, one transaction store
Accepted actions write through TransactionStore to the same SwiftData stack, and a failed save is never reported as successful.