mirror of
https://github.com/we-promise/sure.git
synced 2026-09-03 13:51:29 +00:00
* feat(up): map Up category slugs to Sure categories on import UpEntry::Processor captured Up's category slug into extra but never applied it, so Up transactions imported uncategorised even though the user had already tagged them in the Up app. Add UpAccount::Transactions::CategoryTaxonomy + CategoryMatcher, mirroring PlaidAccount::Transactions::CategoryMatcher: map Up's child category slugs onto the family's existing/default Sure categories by alias, and wire the matcher through UpAccount::Transactions::Processor into UpEntry::Processor. The category is applied via the adapter's enrich_attribute, so a category the user has set or locked is preserved on re-sync. High-confidence mappings only. Up-specific categories with no honest Sure default (Booze, Pets, Apps & Games, Life Admin, Technology, ...) intentionally stay uncategorised for the user's own rules / AI, since a wrong auto-category is worse than none. Adds a matcher unit test and processor wiring tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(up): match category slugs as strings in CategoryMatcher Up category ids are string slugs; compare them against the taxonomy keys as strings so the lookup does not depend on the keys being symbols. No behaviour change (the "slug": hash syntax already produces symbol keys that matched the symbolized input, covered by the matcher unit test), but it removes a subtle footgun and reads clearer. Flagged by the Codex review on the PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(up): make category import non-destructive; word-boundary the alias match Per review feedback: do not bootstrap Sure's default categories during a sync. family_categories now returns the family's existing categories without creating defaults, so a family that has none (deliberately cleared, or pre-onboarding) gets uncategorised transactions rather than having the full default set silently created. Matching resumes once the user sets up categories through the normal UI flow. Also word-boundary the "and" stripping in the matcher normalization so it strips only the standalone conjunction, not "and" inside a word (e.g. errand). Adds a processor test for the non-destructive guarantee. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Gavin Matthews <matthews.gav@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
6.5 KiB
Ruby
201 lines
6.5 KiB
Ruby
require "test_helper"
|
|
|
|
class UpEntry::ProcessorTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:empty)
|
|
@up_item = UpItem.create!(
|
|
family: @family,
|
|
name: "Test Up",
|
|
access_token: "up-access-token"
|
|
)
|
|
@up_account = UpAccount.create!(
|
|
up_item: @up_item,
|
|
name: "Up Spending",
|
|
account_id: "acc_123",
|
|
currency: "AUD"
|
|
)
|
|
@account = Account.create!(
|
|
family: @family,
|
|
name: "Spending",
|
|
accountable: Depository.new(subtype: "checking"),
|
|
balance: 1000,
|
|
currency: "AUD"
|
|
)
|
|
|
|
AccountProvider.create!(account: @account, provider: @up_account)
|
|
end
|
|
|
|
test "imports settled Up transaction with sign conversion and provider metadata" do
|
|
transaction_data = {
|
|
id: "tx_123",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "Coffee Shop",
|
|
message: "Morning coffee",
|
|
rawText: "COFFEE SHOP SYDNEY",
|
|
amount: { currencyCode: "AUD", value: "-12.50", valueInBaseUnits: -1250 },
|
|
foreignAmount: nil,
|
|
settledAt: "2026-01-15T08:30:00+11:00",
|
|
createdAt: "2026-01-15T08:00:00+11:00",
|
|
category_id: "restaurants-and-cafes"
|
|
}
|
|
|
|
entry = UpEntry::Processor.new(transaction_data, up_account: @up_account).process
|
|
|
|
assert_equal "up_tx_123", entry.external_id
|
|
assert_equal "up", entry.source
|
|
assert_equal BigDecimal("12.5"), entry.amount
|
|
assert_equal "AUD", entry.currency
|
|
assert_equal Date.new(2026, 1, 15), entry.date
|
|
assert_equal "Coffee Shop", entry.name
|
|
assert_equal "Morning coffee", entry.notes
|
|
|
|
transaction = entry.entryable
|
|
assert_equal false, transaction.pending?
|
|
assert_equal false, transaction.extra.dig("up", "pending")
|
|
assert_equal "SETTLED", transaction.extra.dig("up", "status")
|
|
assert_equal "restaurants-and-cafes", transaction.extra.dig("up", "category_id")
|
|
assert_equal "Coffee Shop", transaction.merchant.name
|
|
end
|
|
|
|
test "marks HELD transactions as pending and falls back to createdAt for the date" do
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_held_1",
|
|
account_id: "acc_123",
|
|
status: "HELD",
|
|
description: "Pending card auth",
|
|
amount: { currencyCode: "AUD", value: "-8.00", valueInBaseUnits: -800 },
|
|
settledAt: nil,
|
|
createdAt: "2026-01-20T12:00:00+11:00"
|
|
},
|
|
up_account: @up_account
|
|
).process
|
|
|
|
assert entry.entryable.pending?
|
|
assert_equal true, entry.entryable.extra.dig("up", "pending")
|
|
assert_equal Date.new(2026, 1, 20), entry.date
|
|
end
|
|
|
|
test "stores foreign amount FX metadata" do
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_fx_1",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "US Merchant",
|
|
amount: { currencyCode: "AUD", value: "-15.00", valueInBaseUnits: -1500 },
|
|
foreignAmount: { currencyCode: "USD", value: "-10.00", valueInBaseUnits: -1000 },
|
|
settledAt: "2026-01-18T00:00:00+11:00",
|
|
createdAt: "2026-01-18T00:00:00+11:00"
|
|
},
|
|
up_account: @up_account
|
|
).process
|
|
|
|
transaction = entry.entryable
|
|
assert_equal "USD", transaction.extra.dig("up", "fx_from")
|
|
assert_equal "-10.00", transaction.extra.dig("up", "fx_amount")
|
|
end
|
|
|
|
test "income (positive Up amount) is stored as negative in Sure" do
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_income_1",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "Salary",
|
|
amount: { currencyCode: "AUD", value: "2500.00", valueInBaseUnits: 250000 },
|
|
settledAt: "2026-01-15T00:00:00+11:00",
|
|
createdAt: "2026-01-15T00:00:00+11:00"
|
|
},
|
|
up_account: @up_account
|
|
).process
|
|
|
|
assert_equal BigDecimal("-2500.0"), entry.amount
|
|
end
|
|
|
|
test "marks internal transfers (transferAccount present) as funds_movement" do
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_transfer_1",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "Transfer to 2Up Spending",
|
|
amount: { currencyCode: "AUD", value: "-500.00", valueInBaseUnits: -50000 },
|
|
settledAt: "2026-01-22T00:00:00+11:00",
|
|
createdAt: "2026-01-22T00:00:00+11:00",
|
|
transfer_account_id: "acc_other"
|
|
},
|
|
up_account: @up_account
|
|
).process
|
|
|
|
transaction = entry.entryable
|
|
assert_equal "funds_movement", transaction.kind
|
|
assert_equal "acc_other", transaction.extra.dig("up", "transfer_account_id")
|
|
end
|
|
|
|
test "ordinary transactions (no transferAccount) keep the standard kind" do
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_standard_1",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "Coffee Shop",
|
|
amount: { currencyCode: "AUD", value: "-4.50", valueInBaseUnits: -450 },
|
|
settledAt: "2026-01-22T00:00:00+11:00",
|
|
createdAt: "2026-01-22T00:00:00+11:00"
|
|
},
|
|
up_account: @up_account
|
|
).process
|
|
|
|
assert_equal "standard", entry.entryable.kind
|
|
assert_nil entry.entryable.extra.dig("up", "transfer_account_id")
|
|
end
|
|
|
|
test "applies the matched Sure category when a category_matcher is provided" do
|
|
@family.categories.bootstrap!
|
|
matcher = UpAccount::Transactions::CategoryMatcher.new(@family.categories.to_a)
|
|
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_cat_1",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "Woolworths",
|
|
amount: { currencyCode: "AUD", value: "-40.00", valueInBaseUnits: -4000 },
|
|
settledAt: "2026-01-15T00:00:00+11:00",
|
|
createdAt: "2026-01-15T00:00:00+11:00",
|
|
category_id: "groceries"
|
|
},
|
|
up_account: @up_account,
|
|
category_matcher: matcher
|
|
).process
|
|
|
|
assert_equal "Groceries", entry.transaction.category&.name
|
|
# The raw Up slug is still preserved in extra for reference.
|
|
assert_equal "groceries", entry.transaction.extra.dig("up", "category_id")
|
|
end
|
|
|
|
test "leaves the transaction uncategorised when the Up category has no confident match" do
|
|
@family.categories.bootstrap!
|
|
matcher = UpAccount::Transactions::CategoryMatcher.new(@family.categories.to_a)
|
|
|
|
entry = UpEntry::Processor.new(
|
|
{
|
|
id: "tx_cat_2",
|
|
account_id: "acc_123",
|
|
status: "SETTLED",
|
|
description: "BWS",
|
|
amount: { currencyCode: "AUD", value: "-25.00", valueInBaseUnits: -2500 },
|
|
settledAt: "2026-01-15T00:00:00+11:00",
|
|
createdAt: "2026-01-15T00:00:00+11:00",
|
|
category_id: "booze"
|
|
},
|
|
up_account: @up_account,
|
|
category_matcher: matcher
|
|
).process
|
|
|
|
assert_nil entry.transaction.category_id
|
|
end
|
|
end
|