mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 08:15:21 +00:00
Merge branch 'we-promise:main' into Transfer-charges
This commit is contained in:
@@ -458,7 +458,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_balances)
|
||||
.with("ethereum:0xworking,ethereum:0xfailing")
|
||||
.with("ethereum:0xfailing,ethereum:0xworking")
|
||||
.returns(success_response(bulk_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_balance)
|
||||
@@ -475,7 +475,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_transactions)
|
||||
.with("ethereum:0xworking,ethereum:0xfailing")
|
||||
.with("ethereum:0xfailing,ethereum:0xworking")
|
||||
.returns(success_response(bulk_transactions_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_transactions)
|
||||
@@ -551,7 +551,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_balances)
|
||||
.with("ethereum:0xworking,dogecoin:Ddoge123")
|
||||
.with("dogecoin:Ddoge123,ethereum:0xworking")
|
||||
.returns(success_response(bulk_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_balance)
|
||||
@@ -568,7 +568,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_transactions)
|
||||
.with("ethereum:0xworking,dogecoin:Ddoge123")
|
||||
.with("dogecoin:Ddoge123,ethereum:0xworking")
|
||||
.returns(success_response(bulk_transactions_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_transactions)
|
||||
@@ -650,12 +650,12 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
AccountProvider.create!(account: account2, provider: coinstats_account2)
|
||||
|
||||
@mock_provider.expects(:get_wallet_balances)
|
||||
.with("ethereum:0xeth123,dogecoin:Ddoge456")
|
||||
.with("dogecoin:Ddoge456,ethereum:0xeth123")
|
||||
.raises(Provider::Coinstats::Error.new("CoinStats timeout"))
|
||||
|
||||
bulk_transactions_response = []
|
||||
@mock_provider.expects(:get_wallet_transactions)
|
||||
.with("ethereum:0xeth123,dogecoin:Ddoge456")
|
||||
.with("dogecoin:Ddoge456,ethereum:0xeth123")
|
||||
.returns(success_response(bulk_transactions_response))
|
||||
|
||||
assert_difference "DebugLogEntry.count", 3 do
|
||||
@@ -729,7 +729,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_balances)
|
||||
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
|
||||
.with("bitcoin:bc1qbtc456,ethereum:0xeth123")
|
||||
.returns(success_response(bulk_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_balance)
|
||||
@@ -756,7 +756,7 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
]
|
||||
|
||||
@mock_provider.expects(:get_wallet_transactions)
|
||||
.with("ethereum:0xeth123,bitcoin:bc1qbtc456")
|
||||
.with("bitcoin:bc1qbtc456,ethereum:0xeth123")
|
||||
.returns(success_response(bulk_transactions_response))
|
||||
|
||||
@mock_provider.expects(:extract_wallet_transactions)
|
||||
|
||||
@@ -88,7 +88,7 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase
|
||||
test "fetch_security_prices converts bond percent-of-par to money via nominal" do
|
||||
travel_to Date.new(2026, 6, 18) do
|
||||
stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", isin: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ])
|
||||
stub_instrument_by("uid-bond", instrument_full(name: "Bond", nominal: { "units" => "1000", "nano" => 0 }))
|
||||
stub_bond_by("uid-bond", { "nominal" => { "units" => "1000", "nano" => 0 } })
|
||||
@provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns(
|
||||
"candles" => [ candle("2026-06-17", units: 103, nano: 700_000_000) ] # 103.7% of par
|
||||
)
|
||||
@@ -104,7 +104,7 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase
|
||||
test "fetch_security_prices fails (no zero price) when a bond nominal is missing" do
|
||||
travel_to Date.new(2026, 6, 18) do
|
||||
stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ])
|
||||
stub_instrument_by("uid-bond", instrument_full(name: "Bond")) # no nominal
|
||||
stub_bond_by("uid-bond", {}) # no nominal
|
||||
@provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns("candles" => [ candle("2026-06-17", units: 103, nano: 0) ])
|
||||
@provider.stubs(:post).with("MarketDataService", "GetLastPrices", anything).returns("lastPrices" => [])
|
||||
|
||||
@@ -115,6 +115,32 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "fetch_security_prices returns only the live price for an amortizing bond" do
|
||||
travel_to Date.new(2026, 6, 18) do
|
||||
stub_find("RU000A10AAQ4", [ instrument_short(ticker: "RU000A10AAQ4", type: "bond", class_code: "TQCB", uid: "uid-bond", currency: "rub") ])
|
||||
stub_bond_by("uid-bond", { "nominal" => { "units" => "417", "nano" => 710_000_000 }, "amortizationFlag" => true })
|
||||
@provider.stubs(:post).with("MarketDataService", "GetCandles", anything).returns("candles" => [ candle("2026-06-10", units: 105, nano: 0) ])
|
||||
@provider.stubs(:post).with("MarketDataService", "GetLastPrices", anything).returns("lastPrices" => [ { "price" => { "units" => "103", "nano" => 0 } } ])
|
||||
|
||||
response = @provider.fetch_security_prices(symbol: "RU000A10AAQ4", exchange_operating_mic: "MISX", start_date: Date.new(2026, 6, 10), end_date: Date.new(2026, 6, 18))
|
||||
|
||||
# The historical candle is skipped (today's nominal must not reprice old par);
|
||||
# only the live price remains, converted with the current nominal.
|
||||
assert_equal [ Date.new(2026, 6, 18) ], response.data.map(&:date)
|
||||
assert_equal (BigDecimal("103") / 100 * BigDecimal("417.71")), response.data.first.price
|
||||
end
|
||||
end
|
||||
|
||||
test "resolve strips an exchange suffix before querying T-Invest" do
|
||||
stub_find("T", [ instrument_short(ticker: "T", type: "share", class_code: "TQBR", uid: "uid-t") ])
|
||||
stub_instrument_by("uid-t", instrument_full(name: "T-Tech", logo_name: "tcs2.png"))
|
||||
|
||||
response = @provider.fetch_security_info(symbol: "T.MOEX", exchange_operating_mic: "MISX")
|
||||
|
||||
assert response.success?
|
||||
assert_equal "https://invest-brands.cdn-tinkoff.ru/tcs2x160.png", response.data.logo_url
|
||||
end
|
||||
|
||||
test "fetch_security_prices skips incomplete candles" do
|
||||
travel_to Date.new(2026, 6, 18) do
|
||||
stub_find("SBER", [ instrument_short(ticker: "SBER", type: "share", class_code: "TQBR", uid: "uid-sber", currency: "rub") ])
|
||||
@@ -143,10 +169,16 @@ class Provider::TinkoffInvestTest < ActiveSupport::TestCase
|
||||
.returns("instrument" => instrument)
|
||||
end
|
||||
|
||||
def instrument_short(ticker:, type:, class_code:, name: nil, uid: "uid", isin: "", currency: "rub", country: "RU", exchange: "moex")
|
||||
def stub_bond_by(uid, instrument)
|
||||
@provider.stubs(:post)
|
||||
.with("InstrumentsService", "BondBy", has_entry(id: uid))
|
||||
.returns("instrument" => instrument)
|
||||
end
|
||||
|
||||
def instrument_short(ticker:, type:, class_code:, name: nil, uid: "uid", isin: "", currency: "rub", country: "RU", exchange: "moex", tradeable: true)
|
||||
{
|
||||
"ticker" => ticker, "name" => name || ticker, "instrumentType" => type,
|
||||
"classCode" => class_code, "uid" => uid, "isin" => isin,
|
||||
"classCode" => class_code, "uid" => uid, "isin" => isin, "apiTradeAvailableFlag" => tradeable,
|
||||
"currency" => currency, "countryOfRisk" => country, "exchange" => exchange
|
||||
}
|
||||
end
|
||||
|
||||
160
test/models/provider/up_test.rb
Normal file
160
test/models/provider/up_test.rb
Normal file
@@ -0,0 +1,160 @@
|
||||
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 "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
|
||||
24
test/models/up_account_test.rb
Normal file
24
test/models/up_account_test.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
require "test_helper"
|
||||
|
||||
class UpAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@up_item = UpItem.create!(family: @family, name: "Test Up", access_token: "up-access-token")
|
||||
end
|
||||
|
||||
test "needs_setup excludes linked and ignored accounts" do
|
||||
unlinked = UpAccount.create!(up_item: @up_item, name: "Unlinked", account_id: "acc_unlinked", currency: "AUD")
|
||||
ignored = UpAccount.create!(up_item: @up_item, name: "Skipped", account_id: "acc_ignored", currency: "AUD", ignored: true)
|
||||
|
||||
linked = UpAccount.create!(up_item: @up_item, name: "Linked", account_id: "acc_linked", currency: "AUD")
|
||||
account = Account.create!(family: @family, name: "Linked", accountable: Depository.new(subtype: "checking"), balance: 0, currency: "AUD")
|
||||
AccountProvider.create!(account: account, provider: linked)
|
||||
|
||||
needs_setup = @up_item.up_accounts.needs_setup
|
||||
|
||||
assert_includes needs_setup, unlinked
|
||||
assert_not_includes needs_setup, ignored
|
||||
assert_not_includes needs_setup, linked
|
||||
assert_equal 1, @up_item.unlinked_accounts_count
|
||||
end
|
||||
end
|
||||
116
test/models/up_entry/processor_test.rb
Normal file
116
test/models/up_entry/processor_test.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
end
|
||||
156
test/models/up_item/importer_test.rb
Normal file
156
test/models/up_item/importer_test.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
require "test_helper"
|
||||
|
||||
class UpItem::ImporterTest < ActiveSupport::TestCase
|
||||
class FakeUpProvider
|
||||
attr_reader :transaction_calls
|
||||
|
||||
def initialize(accounts: nil, transactions: nil)
|
||||
@transaction_calls = []
|
||||
@accounts = accounts
|
||||
@transactions = transactions
|
||||
end
|
||||
|
||||
def get_accounts
|
||||
@accounts || [
|
||||
{
|
||||
id: "acc_123",
|
||||
displayName: "Up Spending",
|
||||
accountType: "TRANSACTIONAL",
|
||||
ownershipType: "INDIVIDUAL",
|
||||
balance: { currencyCode: "AUD", value: "123.45", valueInBaseUnits: 12345 }
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def get_account_transactions(account_id:, since: nil)
|
||||
@transaction_calls << { account_id: account_id, since: since }
|
||||
@transactions || [ settled_transaction ]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def settled_transaction
|
||||
{
|
||||
id: "tx_1",
|
||||
account_id: "acc_123",
|
||||
status: "SETTLED",
|
||||
description: "Posted transaction",
|
||||
amount: { currencyCode: "AUD", value: "-20.00", valueInBaseUnits: -2000 },
|
||||
settledAt: "2026-01-19T00:00:00+11:00",
|
||||
createdAt: "2026-01-19T00:00:00+11:00"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
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: "Old name",
|
||||
account_id: "acc_123",
|
||||
currency: "AUD"
|
||||
)
|
||||
@account = Account.create!(
|
||||
family: @family,
|
||||
name: "Spending",
|
||||
accountable: Depository.new(subtype: "checking"),
|
||||
balance: 0,
|
||||
currency: "AUD"
|
||||
)
|
||||
AccountProvider.create!(account: @account, provider: @up_account)
|
||||
end
|
||||
|
||||
test "imports account snapshot and stores transactions" do
|
||||
provider = FakeUpProvider.new
|
||||
|
||||
result = UpItem::Importer.new(@up_item, up_provider: provider).import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:accounts_updated]
|
||||
assert_equal 1, result[:transactions_imported]
|
||||
|
||||
@up_account.reload
|
||||
assert_equal "Up Spending", @up_account.name
|
||||
assert_equal BigDecimal("123.45"), @up_account.current_balance
|
||||
assert_equal "TRANSACTIONAL", @up_account.account_type
|
||||
assert_equal "acc_123", provider.transaction_calls.first[:account_id]
|
||||
|
||||
assert_equal [ "tx_1" ], @up_account.raw_transactions_payload.map { |tx| tx["id"] }
|
||||
end
|
||||
|
||||
test "held transaction settling under the same id clears pending without duplicates" do
|
||||
import_with(transactions: [ held_transaction(id: "tx_h1", amount: "-8.00") ])
|
||||
process_transactions
|
||||
|
||||
assert_equal 1, pending_entries.count
|
||||
|
||||
import_with(transactions: [ settled(id: "tx_h1", amount: "-8.00") ])
|
||||
process_transactions
|
||||
|
||||
assert_empty pending_entries
|
||||
assert_equal 1, @account.entries.where(source: "up").count
|
||||
entry = @account.entries.find_by(external_id: "up_tx_h1", source: "up")
|
||||
assert_equal false, entry.entryable.pending?
|
||||
end
|
||||
|
||||
test "removes held transactions that disappear from the latest fetch" do
|
||||
import_with(transactions: [ held_transaction(id: "tx_h2", amount: "-8.00") ])
|
||||
process_transactions
|
||||
|
||||
assert_equal 1, pending_entries.count
|
||||
|
||||
import_with(transactions: [])
|
||||
process_transactions
|
||||
|
||||
@up_account.reload
|
||||
assert_empty pending_entries
|
||||
assert_empty @up_account.raw_transactions_payload.select { |tx| UpEntry::Processor.pending?(tx) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def import_with(transactions:)
|
||||
provider = FakeUpProvider.new(transactions: transactions)
|
||||
UpItem::Importer.new(@up_item, up_provider: provider).import
|
||||
end
|
||||
|
||||
def process_transactions
|
||||
UpAccount::Transactions::Processor.new(@up_account.reload).process
|
||||
end
|
||||
|
||||
def pending_entries
|
||||
@account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(source: "up")
|
||||
.where("(transactions.extra -> 'up' ->> 'pending')::boolean = true")
|
||||
end
|
||||
|
||||
def held_transaction(id:, amount:, date: "2026-01-15T00:00:00+11:00")
|
||||
{
|
||||
id: id,
|
||||
account_id: "acc_123",
|
||||
status: "HELD",
|
||||
description: "Pending card auth",
|
||||
amount: { currencyCode: "AUD", value: amount, valueInBaseUnits: (amount.to_f * 100).to_i },
|
||||
settledAt: nil,
|
||||
createdAt: date
|
||||
}
|
||||
end
|
||||
|
||||
def settled(id:, amount:, date: "2026-01-16T00:00:00+11:00")
|
||||
{
|
||||
id: id,
|
||||
account_id: "acc_123",
|
||||
status: "SETTLED",
|
||||
description: "Posted card auth",
|
||||
amount: { currencyCode: "AUD", value: amount, valueInBaseUnits: (amount.to_f * 100).to_i },
|
||||
settledAt: date,
|
||||
createdAt: date
|
||||
}
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user