Files
sure/test/models/up_entry/processor_test.rb
threatsurfer ab32388326 feat(up): flag internal transfers and round-ups as funds_movement (#2460)
* feat(up): flag internal transfers and round-ups as funds_movement

Up populates relationships.transferAccount on transactions that move money
between the user's own accounts (including round-ups swept into a Saver), but
flatten_transaction dropped it, so these imported as ordinary income/expense
and distorted budgets and cashflow.

- Provider::Up#flatten_transaction: lift transfer_account_id from
  relationships.transferAccount.data.id.
- UpEntry::Processor: import transfers as funds_movement and persist
  transfer_account_id in extra["up"].
- Account::ProviderImportAdapter#import_transaction: optional kind: param; an
  explicit provider kind takes precedence over account-type auto-detection and
  is applied after the sync-protection check, so user re-categorisations
  survive re-sync.

Complementary to Family#auto_match_transfers!: two-sided transfers between
linked accounts are still paired into a Transfer (the matcher does not filter
on kind); one-sided movements and round-ups, which the matcher cannot pair,
are the cases this fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(up): account-type kind wins over provider transfer hint

Codex review caught that Up HOME_LOAN accounts map to a Loan account, so a
repayment carrying transferAccount would be reclassified from loan_payment to
funds_movement (budget-excluded). Make the provider kind: a fallback:
activity-label and account-type classification now take precedence, so
loan_payment and cc_payment survive. Adds a regression test (loan repayment
stays loan_payment), a depository-applies test, and a note that the up_test
stub ignores query: intentionally.

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>
2026-06-30 08:19:25 +02:00

155 lines
5.0 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
end