mirror of
https://github.com/we-promise/sure.git
synced 2026-07-18 07:45:24 +00:00
* 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>
202 lines
7.1 KiB
Ruby
202 lines
7.1 KiB
Ruby
require "test_helper"
|
|
|
|
class Provider::UpTest < ActiveSupport::TestCase
|
|
FakeResponse = Struct.new(:code, :body, :message, keyword_init: true)
|
|
|
|
test "fetches paginated account transactions following JSON:API links.next with bearer auth" do
|
|
next_url = "https://api.up.com.au/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=cursor2"
|
|
responses = [
|
|
FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ],
|
|
links: { prev: nil, next: next_url }
|
|
}.to_json
|
|
),
|
|
FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [ { type: "transactions", id: "tx_2", attributes: { status: "HELD" }, relationships: { account: { data: { id: "acc_123" } } } } ],
|
|
links: { prev: nil, next: nil }
|
|
}.to_json
|
|
)
|
|
]
|
|
requests = []
|
|
|
|
Provider::Up.stub(:get, ->(url, headers:, query: nil) {
|
|
requests << { url: url, headers: headers, query: query }
|
|
responses.shift
|
|
}) do
|
|
client = Provider::Up.new("up-access-token")
|
|
|
|
transactions = client.get_account_transactions(
|
|
account_id: "acc_123",
|
|
since: Date.new(2026, 1, 1)
|
|
)
|
|
|
|
assert_equal [ "tx_1", "tx_2" ], transactions.map { |tx| tx[:id] }
|
|
assert_equal [ "acc_123", "acc_123" ], transactions.map { |tx| tx[:account_id] }
|
|
assert_equal "SETTLED", transactions.first[:status]
|
|
end
|
|
|
|
assert_equal 2, requests.size
|
|
assert_match "/accounts/acc_123/transactions", requests.first[:url]
|
|
assert_equal "Bearer up-access-token", requests.first[:headers]["Authorization"]
|
|
assert_equal "2026-01-01T00:00:00Z", requests.first[:query]["filter[since]"]
|
|
assert_equal 100, requests.first[:query]["page[size]"]
|
|
# Pagination follows the absolute next URL with no extra query params.
|
|
assert_equal next_url, requests.second[:url]
|
|
assert_nil requests.second[:query]
|
|
end
|
|
|
|
test "stops paginating when the API repeats the same next cursor" do
|
|
repeating_url = "https://api.up.com.au/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=loop"
|
|
page = FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ],
|
|
links: { prev: nil, next: repeating_url }
|
|
}.to_json
|
|
)
|
|
request_count = 0
|
|
|
|
Provider::Up.stub(:get, ->(url, headers:, query: nil) {
|
|
request_count += 1
|
|
raise "infinite pagination loop" if request_count > 5
|
|
|
|
page
|
|
}) do
|
|
client = Provider::Up.new("up-access-token")
|
|
transactions = client.get_account_transactions(account_id: "acc_123")
|
|
|
|
# First request + one follow of the repeated cursor, then the guard stops.
|
|
assert_equal 2, request_count
|
|
assert_equal [ "tx_1", "tx_1" ], transactions.map { |tx| tx[:id] }
|
|
end
|
|
end
|
|
|
|
test "refuses to follow a pagination link pointing at a non-Up host" do
|
|
evil_url = "https://evil.example.com/api/v1/accounts/acc_123/transactions?page%5Bafter%5D=x"
|
|
first_page = FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [ { type: "transactions", id: "tx_1", attributes: { status: "SETTLED" }, relationships: { account: { data: { id: "acc_123" } } } } ],
|
|
links: { prev: nil, next: evil_url }
|
|
}.to_json
|
|
)
|
|
requested_urls = []
|
|
|
|
Provider::Up.stub(:get, ->(url, headers:, query: nil) {
|
|
requested_urls << url
|
|
first_page
|
|
}) do
|
|
client = Provider::Up.new("up-access-token")
|
|
|
|
error = assert_raises(Provider::Up::UpError) do
|
|
client.get_account_transactions(account_id: "acc_123")
|
|
end
|
|
assert_equal :invalid_url, error.error_type
|
|
end
|
|
|
|
# The bearer token must never be sent to the foreign host.
|
|
assert_not_includes requested_urls, evil_url
|
|
end
|
|
|
|
test "flattens JSON:API account resources" do
|
|
response = FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [ {
|
|
type: "accounts",
|
|
id: "acc_123",
|
|
attributes: {
|
|
displayName: "Spending",
|
|
accountType: "TRANSACTIONAL",
|
|
ownershipType: "INDIVIDUAL",
|
|
balance: { currencyCode: "AUD", value: "123.45", valueInBaseUnits: 12345 }
|
|
}
|
|
} ],
|
|
links: { prev: nil, next: nil }
|
|
}.to_json
|
|
)
|
|
|
|
Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do
|
|
accounts = Provider::Up.new("up-access-token").get_accounts
|
|
|
|
assert_equal 1, accounts.size
|
|
account = accounts.first
|
|
assert_equal "acc_123", account[:id]
|
|
assert_equal "Spending", account[:displayName]
|
|
assert_equal "TRANSACTIONAL", account[:accountType]
|
|
assert_equal "AUD", account.dig(:balance, :currencyCode)
|
|
assert_equal "123.45", account.dig(:balance, :value)
|
|
end
|
|
end
|
|
|
|
test "flattens transaction relationships including transferAccount" do
|
|
response = FakeResponse.new(
|
|
code: 200,
|
|
message: "OK",
|
|
body: {
|
|
data: [
|
|
{
|
|
type: "transactions", id: "tx_xfer",
|
|
attributes: { status: "SETTLED", description: "Transfer to Savings" },
|
|
relationships: {
|
|
account: { data: { id: "acc_123" } },
|
|
category: { data: nil },
|
|
transferAccount: { data: { id: "acc_saver" } }
|
|
}
|
|
},
|
|
{
|
|
type: "transactions", id: "tx_plain",
|
|
attributes: { status: "SETTLED", description: "Coffee" },
|
|
relationships: {
|
|
account: { data: { id: "acc_123" } },
|
|
category: { data: { id: "restaurants-and-cafes" } },
|
|
transferAccount: { data: nil }
|
|
}
|
|
}
|
|
],
|
|
links: { prev: nil, next: nil }
|
|
}.to_json
|
|
)
|
|
|
|
# The stub deliberately ignores the query: keyword: this test exercises only
|
|
# response flattening, not the request params (pagination/date filters), which
|
|
# are covered by the pagination tests above.
|
|
Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do
|
|
transactions = Provider::Up.new("up-access-token").get_account_transactions(account_id: "acc_123")
|
|
|
|
assert_equal "acc_saver", transactions.first[:transfer_account_id]
|
|
assert_equal "restaurants-and-cafes", transactions.second[:category_id]
|
|
assert_nil transactions.second[:transfer_account_id]
|
|
end
|
|
end
|
|
|
|
test "raises typed errors for unauthorized responses" do
|
|
response = FakeResponse.new(code: 401, message: "Unauthorized", body: "{}")
|
|
|
|
Provider::Up.stub(:get, ->(_url, headers:, query: nil) { response }) do
|
|
error = assert_raises Provider::Up::UpError do
|
|
Provider::Up.new("invalid-token").get_accounts
|
|
end
|
|
|
|
assert_equal :unauthorized, error.error_type
|
|
end
|
|
end
|
|
|
|
test "raises configuration error when token blank" do
|
|
error = assert_raises Provider::Up::UpError do
|
|
Provider::Up.new("")
|
|
end
|
|
|
|
assert_equal :configuration_error, error.error_type
|
|
end
|
|
end
|