Merge branch 'we-promise:main' into Transfer-charges

This commit is contained in:
Shibu M
2026-06-21 01:29:31 +05:30
committed by GitHub
65 changed files with 5016 additions and 278 deletions

View File

@@ -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)

View File

@@ -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

View 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

View 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

View 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

View 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