mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)
* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts * [BUGFIX] Fix CoinStats PR regressions * [BUGFIX] Fix CoinStats PR review findings * [BUGFIX] Address follow-up CoinStats PR feedback * [REFACTO] Extract CoinStats exchange account helpers * [BUGFIX] Batch linked CoinStats chart normalization * [BUGFIX] Fix CoinStats processor lint --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -9,6 +9,9 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
name: "Test CoinStats Connection",
|
||||
api_key: "test_api_key_123"
|
||||
)
|
||||
tailwind_build = Rails.root.join("app/assets/builds/tailwind.css")
|
||||
FileUtils.mkdir_p(tailwind_build.dirname)
|
||||
File.write(tailwind_build, "/* test */") unless tailwind_build.exist?
|
||||
end
|
||||
|
||||
# Helper to wrap data in Provider::Response
|
||||
@@ -175,4 +178,38 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :unprocessable_entity
|
||||
assert_match(/No tokens found/, response.body)
|
||||
end
|
||||
|
||||
test "link_exchange filters unexpected connection fields" do
|
||||
Provider::Coinstats.any_instance.expects(:get_exchanges).returns(success_response([
|
||||
{
|
||||
connectionId: "bitvavo",
|
||||
name: "Bitvavo",
|
||||
connectionFields: [
|
||||
{ key: "apiKey", name: "API Key" },
|
||||
{ key: "apiSecret", name: "API Secret" }
|
||||
]
|
||||
}
|
||||
])).once
|
||||
|
||||
linker_result = CoinstatsItem::ExchangeLinker::Result.new(success?: true, created_count: 0, errors: [])
|
||||
CoinstatsItem::ExchangeLinker.expects(:new).with(
|
||||
@coinstats_item,
|
||||
connection_id: "bitvavo",
|
||||
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" },
|
||||
name: "Bitvavo"
|
||||
).returns(stub(link: linker_result))
|
||||
|
||||
post link_exchange_coinstats_items_url, params: {
|
||||
coinstats_item_id: @coinstats_item.id,
|
||||
exchange_connection_id: "bitvavo",
|
||||
exchange_connection_name: "Bitvavo",
|
||||
connection_fields: {
|
||||
apiKey: " key ",
|
||||
apiSecret: " secret ",
|
||||
unexpected: "should_not_be_forwarded"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,4 +43,106 @@ class Account::ChartableTest < ActiveSupport::TestCase
|
||||
memoized_series2_cash_view = account.balance_series(period: Period.last_90_days, view: :cash_balance)
|
||||
memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance)
|
||||
end
|
||||
|
||||
test "trims placeholder history for linked investment accounts without trades" do
|
||||
account = accounts(:investment)
|
||||
account.entries.destroy_all
|
||||
account.holdings.destroy_all
|
||||
|
||||
coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
|
||||
account.account_providers.create!(provider: coinstats_account)
|
||||
|
||||
account.holdings.create!(
|
||||
security: securities(:aapl),
|
||||
date: 5.days.ago.to_date,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
account_provider: account.account_providers.last
|
||||
)
|
||||
|
||||
raw_series = Series.new(
|
||||
start_date: 10.days.ago.to_date,
|
||||
end_date: Date.current,
|
||||
interval: "1 day",
|
||||
values: [
|
||||
Series::Value.new(date: 10.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
|
||||
Series::Value.new(date: 9.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
|
||||
Series::Value.new(date: 8.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
|
||||
Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")),
|
||||
Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(110, "USD"))
|
||||
],
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
|
||||
builder = mock
|
||||
Balance::ChartSeriesBuilder.expects(:new).returns(builder)
|
||||
builder.expects(:balance_series).returns(raw_series)
|
||||
|
||||
series = account.balance_series
|
||||
|
||||
assert_equal 5.days.ago.to_date, series.start_date
|
||||
assert_equal [ 5.days.ago.to_date, Date.current ], series.values.map(&:date)
|
||||
end
|
||||
|
||||
test "trims unstable provider snapshot history for linked investment accounts without trades" do
|
||||
account = accounts(:investment)
|
||||
account.entries.destroy_all
|
||||
account.holdings.destroy_all
|
||||
|
||||
coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
|
||||
account.account_providers.create!(provider: coinstats_account)
|
||||
|
||||
account.holdings.create!(
|
||||
security: securities(:aapl),
|
||||
date: 5.days.ago.to_date,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
account_provider: account.account_providers.last
|
||||
)
|
||||
account.holdings.create!(
|
||||
security: securities(:aapl),
|
||||
date: 4.days.ago.to_date,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
account_provider: account.account_providers.last
|
||||
)
|
||||
account.holdings.create!(
|
||||
security: securities(:msft),
|
||||
date: Date.current,
|
||||
qty: 1,
|
||||
price: 120,
|
||||
amount: 120,
|
||||
currency: "USD",
|
||||
account_provider: account.account_providers.last
|
||||
)
|
||||
|
||||
raw_series = Series.new(
|
||||
start_date: 5.days.ago.to_date,
|
||||
end_date: Date.current,
|
||||
interval: "1 day",
|
||||
values: [
|
||||
Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")),
|
||||
Series::Value.new(date: 4.days.ago.to_date, date_formatted: "", value: Money.new(101, "USD")),
|
||||
Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(120, "USD"))
|
||||
],
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
|
||||
builder = mock
|
||||
Balance::ChartSeriesBuilder.expects(:new).returns(builder)
|
||||
builder.expects(:balance_series).returns(raw_series)
|
||||
|
||||
series = account.balance_series
|
||||
|
||||
assert_equal Date.current, series.start_date
|
||||
assert_equal [ Date.current ], series.values.map(&:date)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -167,6 +167,58 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
|
||||
assert_equal 0, Security::Price.where(security: security, date: trade_date).count
|
||||
end
|
||||
|
||||
test "syncs security prices for provider-held securities without trades" do
|
||||
family = Family.create!(name: "Smith", currency: "USD")
|
||||
|
||||
account = family.accounts.create!(
|
||||
name: "Brokerage",
|
||||
currency: "USD",
|
||||
balance: 0,
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
security = Security.create!(ticker: "PE500", exchange_operating_mic: "XPAR")
|
||||
|
||||
coinstats_item = family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
|
||||
account_provider = AccountProvider.create!(account: account, provider: coinstats_account)
|
||||
|
||||
account.holdings.create!(
|
||||
security: security,
|
||||
date: Date.current,
|
||||
qty: 1,
|
||||
price: 100,
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
account_provider: account_provider
|
||||
)
|
||||
|
||||
expected_start_date = account.start_date - SECURITY_PRICE_BUFFER
|
||||
end_date = Date.current.in_time_zone("America/New_York").to_date
|
||||
|
||||
@provider.expects(:fetch_security_prices)
|
||||
.with(symbol: security.ticker,
|
||||
exchange_operating_mic: security.exchange_operating_mic,
|
||||
start_date: expected_start_date,
|
||||
end_date: end_date)
|
||||
.returns(provider_success_response([
|
||||
OpenStruct.new(security: security,
|
||||
date: account.start_date,
|
||||
price: 100,
|
||||
currency: "USD")
|
||||
]))
|
||||
|
||||
@provider.stubs(:fetch_security_info)
|
||||
.with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)
|
||||
.returns(provider_success_response(OpenStruct.new(name: "PE500", logo_url: "logo")))
|
||||
|
||||
@provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))
|
||||
|
||||
Account::MarketDataImporter.new(account).import_all
|
||||
|
||||
assert_equal 1, Security::Price.where(security: security, date: account.start_date).count
|
||||
end
|
||||
|
||||
test "handles provider error response gracefully for exchange rates" do
|
||||
family = Family.create!(name: "Smith", currency: "USD")
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
@account.reload
|
||||
assert_equal BigDecimal("5000.50"), @account.balance
|
||||
assert_equal BigDecimal("5000.50"), @account.cash_balance
|
||||
assert_equal BigDecimal("0"), @account.cash_balance
|
||||
end
|
||||
|
||||
test "updates account currency from coinstats account" do
|
||||
|
||||
@@ -290,4 +290,46 @@ class CoinstatsAccountTest < ActiveSupport::TestCase
|
||||
# Verify wallet A no longer exists
|
||||
assert_nil CoinstatsAccount.find_by(id: wallet_a.id)
|
||||
end
|
||||
|
||||
test "portfolio exchange account derives total and cash balances from embedded coins" do
|
||||
@family.update!(currency: "EUR")
|
||||
|
||||
portfolio_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "EUR",
|
||||
account_id: "exchange_portfolio:test",
|
||||
wallet_address: "portfolio-test",
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio-test",
|
||||
exchange_name: "Bitvavo",
|
||||
coins: [
|
||||
{
|
||||
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
||||
count: "0.00335845",
|
||||
price: { EUR: "57950.0491" }
|
||||
},
|
||||
{
|
||||
coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" },
|
||||
count: "0.05580825",
|
||||
price: { EUR: "1728.952252246" }
|
||||
},
|
||||
{
|
||||
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
|
||||
count: "2.58",
|
||||
price: { EUR: "1" }
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert portfolio_account.exchange_portfolio_account?
|
||||
refute portfolio_account.fiat_asset?
|
||||
assert_equal "EUR", portfolio_account.inferred_currency
|
||||
assert_in_delta 293.69214193130284, portfolio_account.inferred_current_balance.to_f, 0.0001
|
||||
assert_in_delta 2.58, portfolio_account.inferred_cash_balance.to_f, 0.0001
|
||||
assert_equal 2, portfolio_account.portfolio_non_fiat_coins.size
|
||||
assert_equal 1, portfolio_account.portfolio_fiat_coins.size
|
||||
end
|
||||
end
|
||||
|
||||
@@ -264,4 +264,202 @@ class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
processor.process
|
||||
end
|
||||
end
|
||||
|
||||
test "restores legacy transaction entry if trade import fails" do
|
||||
exchange_crypto = Crypto.create!
|
||||
exchange_account_record = @family.accounts.create!(
|
||||
accountable: exchange_crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
exchange_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "USD",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
coins: []
|
||||
}
|
||||
)
|
||||
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
|
||||
|
||||
legacy_entry = exchange_account_record.entries.create!(
|
||||
entryable: Transaction.new,
|
||||
external_id: "coinstats_trade_legacy",
|
||||
source: "coinstats",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
date: Date.new(2025, 1, 15),
|
||||
name: "Trade BTC"
|
||||
)
|
||||
|
||||
transaction_data = {
|
||||
type: "Trade",
|
||||
date: "2025-01-15T10:00:00.000Z",
|
||||
hash: { id: "trade_legacy" },
|
||||
transactions: [
|
||||
{
|
||||
items: [
|
||||
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.1", totalWorth: "100" },
|
||||
{ coin: { id: "ethereum", symbol: "ETH" }, count: "1.5", totalWorth: "100" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
||||
Account::ProviderImportAdapter.any_instance.expects(:import_trade).raises(StandardError, "boom")
|
||||
|
||||
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
|
||||
|
||||
assert_raises(StandardError) { processor.process }
|
||||
assert exchange_account_record.entries.exists?(id: legacy_entry.id)
|
||||
end
|
||||
|
||||
test "exchange trades prefer the disposed asset leg" do
|
||||
exchange_crypto = Crypto.create!
|
||||
exchange_account_record = @family.accounts.create!(
|
||||
accountable: exchange_crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
exchange_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "USD",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
coins: []
|
||||
}
|
||||
)
|
||||
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
|
||||
|
||||
transaction_data = {
|
||||
type: "Trade",
|
||||
date: "2025-01-15T10:00:00.000Z",
|
||||
hash: { id: "trade_disposed_asset" },
|
||||
transactions: [
|
||||
{
|
||||
items: [
|
||||
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
|
||||
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
||||
|
||||
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
|
||||
processor.process
|
||||
|
||||
entry = exchange_account_record.entries.order(created_at: :desc).first
|
||||
assert_equal "Trade BTC", entry.name
|
||||
assert_equal "Sell", entry.trade.investment_activity_label
|
||||
end
|
||||
|
||||
test "portfolio exchange fallback keeps disposed asset sign when trade import is skipped" do
|
||||
exchange_crypto = Crypto.create!
|
||||
exchange_account_record = @family.accounts.create!(
|
||||
accountable: exchange_crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
exchange_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "USD",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
coins: []
|
||||
}
|
||||
)
|
||||
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
|
||||
|
||||
transaction_data = {
|
||||
type: "Trade",
|
||||
date: "2025-01-15T10:00:00.000Z",
|
||||
hash: { id: "trade_fallback_sign" },
|
||||
transactions: [
|
||||
{
|
||||
items: [
|
||||
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
|
||||
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Security::Resolver.any_instance.stubs(:resolve).returns(nil)
|
||||
|
||||
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
|
||||
processor.process
|
||||
|
||||
entry = exchange_account_record.entries.order(created_at: :desc).first
|
||||
assert_equal BigDecimal("100"), entry.amount
|
||||
assert_equal "Trade BTC", entry.name
|
||||
end
|
||||
|
||||
test "preserves protected legacy transaction when migrating exchange trade" do
|
||||
exchange_crypto = Crypto.create!
|
||||
exchange_account_record = @family.accounts.create!(
|
||||
accountable: exchange_crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
exchange_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "USD",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
coins: []
|
||||
}
|
||||
)
|
||||
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
|
||||
|
||||
legacy_entry = exchange_account_record.entries.create!(
|
||||
entryable: Transaction.new,
|
||||
external_id: "coinstats_trade_protected",
|
||||
source: "coinstats",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
date: Date.new(2025, 1, 15),
|
||||
name: "Trade BTC"
|
||||
)
|
||||
legacy_entry.mark_user_modified!
|
||||
|
||||
transaction_data = {
|
||||
type: "Trade",
|
||||
date: "2025-01-15T10:00:00.000Z",
|
||||
hash: { id: "trade_protected" },
|
||||
transactions: [
|
||||
{
|
||||
items: [
|
||||
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
|
||||
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
|
||||
|
||||
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
|
||||
|
||||
assert_no_difference("Trade.count") { assert_equal legacy_entry, processor.process }
|
||||
assert_equal "Transaction", legacy_entry.reload.entryable_type
|
||||
end
|
||||
end
|
||||
|
||||
125
test/models/coinstats_item/exchange_linker_test.rb
Normal file
125
test/models/coinstats_item/exchange_linker_test.rb
Normal file
@@ -0,0 +1,125 @@
|
||||
require "test_helper"
|
||||
|
||||
class CoinstatsItem::ExchangeLinkerTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@family.update!(currency: "EUR")
|
||||
@coinstats_item = CoinstatsItem.create!(
|
||||
family: @family,
|
||||
name: "Test CoinStats Connection",
|
||||
api_key: "test_api_key_123"
|
||||
)
|
||||
end
|
||||
|
||||
def success_response(data)
|
||||
Provider::Response.new(success?: true, data: data, error: nil)
|
||||
end
|
||||
|
||||
test "link creates one exchange portfolio account with embedded coins" do
|
||||
Provider::Coinstats.any_instance.expects(:exchange_options).returns([
|
||||
{
|
||||
connection_id: "bitvavo",
|
||||
name: "Bitvavo",
|
||||
icon: "https://example.com/bitvavo.png",
|
||||
connection_fields: [
|
||||
{ key: "apiKey", name: "API Key" },
|
||||
{ key: "apiSecret", name: "API Secret" }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange)
|
||||
.with(
|
||||
connection_id: "bitvavo",
|
||||
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" },
|
||||
name: "Bitvavo Portfolio"
|
||||
)
|
||||
.returns(success_response({ portfolioId: "portfolio_123" }))
|
||||
|
||||
Provider::Coinstats.any_instance.expects(:list_portfolio_coins)
|
||||
.with(portfolio_id: "portfolio_123")
|
||||
.returns([
|
||||
{
|
||||
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
||||
count: "0.00335845",
|
||||
price: { EUR: "57950.0491" }
|
||||
},
|
||||
{
|
||||
coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" },
|
||||
count: "0.05580825",
|
||||
price: { EUR: "1728.952252246" }
|
||||
},
|
||||
{
|
||||
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
|
||||
count: "2.58",
|
||||
price: { EUR: "1" }
|
||||
}
|
||||
])
|
||||
|
||||
@coinstats_item.expects(:sync_later).once
|
||||
|
||||
assert_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ], 1 do
|
||||
result = CoinstatsItem::ExchangeLinker.new(
|
||||
@coinstats_item,
|
||||
connection_id: "bitvavo",
|
||||
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }
|
||||
).link
|
||||
|
||||
assert result.success?
|
||||
assert_equal 1, result.created_count
|
||||
end
|
||||
|
||||
@coinstats_item.reload
|
||||
assert_equal "portfolio_123", @coinstats_item.exchange_portfolio_id
|
||||
|
||||
coinstats_account = @coinstats_item.coinstats_accounts.last
|
||||
assert coinstats_account.exchange_portfolio_account?
|
||||
assert_equal "Bitvavo", coinstats_account.name
|
||||
assert_equal "exchange_portfolio:portfolio_123", coinstats_account.account_id
|
||||
assert_equal 3, coinstats_account.raw_payload["coins"].size
|
||||
|
||||
account = coinstats_account.account
|
||||
assert_equal "Bitvavo", account.name
|
||||
assert_equal "EUR", account.currency
|
||||
assert_in_delta 293.69214193130284, account.balance.to_f, 0.0001
|
||||
assert_in_delta 2.58, account.cash_balance.to_f, 0.0001
|
||||
end
|
||||
|
||||
test "link defers local account creation when initial portfolio coin fetch is missing" do
|
||||
Provider::Coinstats.any_instance.expects(:exchange_options).returns([
|
||||
{
|
||||
connection_id: "bitvavo",
|
||||
name: "Bitvavo",
|
||||
icon: "https://example.com/bitvavo.png",
|
||||
connection_fields: [
|
||||
{ key: "apiKey", name: "API Key" }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange)
|
||||
.returns(success_response({ portfolioId: "portfolio_456" }))
|
||||
|
||||
Provider::Coinstats.any_instance.expects(:list_portfolio_coins)
|
||||
.with(portfolio_id: "portfolio_456")
|
||||
.returns(nil)
|
||||
|
||||
@coinstats_item.expects(:sync_later).once
|
||||
|
||||
assert_no_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ] do
|
||||
result = CoinstatsItem::ExchangeLinker.new(
|
||||
@coinstats_item,
|
||||
connection_id: "bitvavo",
|
||||
connection_fields: { "apiKey" => "key" }
|
||||
).link
|
||||
|
||||
assert result.success?
|
||||
assert_equal 0, result.created_count
|
||||
end
|
||||
|
||||
@coinstats_item.reload
|
||||
assert_equal "portfolio_456", @coinstats_item.exchange_portfolio_id
|
||||
assert_equal "bitvavo", @coinstats_item.exchange_connection_id
|
||||
assert_empty @coinstats_item.coinstats_accounts
|
||||
end
|
||||
end
|
||||
@@ -220,6 +220,121 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
|
||||
assert_equal 0, result[:transactions_imported]
|
||||
end
|
||||
|
||||
test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do
|
||||
crypto = Crypto.create!
|
||||
account = @family.accounts.create!(
|
||||
accountable: crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 250,
|
||||
cash_balance: 10,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "EUR",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
wallet_address: "portfolio_123",
|
||||
current_balance: 250,
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
connection_id: "bitvavo",
|
||||
exchange_name: "Bitvavo",
|
||||
coins: [
|
||||
{
|
||||
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
||||
count: "0.003",
|
||||
price: { EUR: "80000" }
|
||||
},
|
||||
{
|
||||
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
|
||||
count: "10",
|
||||
price: { EUR: "1" }
|
||||
}
|
||||
]
|
||||
},
|
||||
raw_transactions_payload: []
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: coinstats_account)
|
||||
|
||||
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
|
||||
@mock_provider.expects(:list_exchange_transactions)
|
||||
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
|
||||
.returns([])
|
||||
@mock_provider.expects(:list_portfolio_coins)
|
||||
.with(portfolio_id: "portfolio_123")
|
||||
.returns(nil)
|
||||
|
||||
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
||||
|
||||
assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do
|
||||
result = importer.import
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:accounts_updated]
|
||||
assert_equal 0, result[:transactions_imported]
|
||||
end
|
||||
|
||||
reloaded = coinstats_account.reload
|
||||
assert_equal "portfolio_123", reloaded.raw_payload["portfolio_id"]
|
||||
assert_equal 2, reloaded.raw_payload["coins"].size
|
||||
assert_equal 250.0, reloaded.current_balance.to_f
|
||||
end
|
||||
|
||||
test "writes an empty exchange portfolio snapshot when CoinStats returns an empty portfolio" do
|
||||
crypto = Crypto.create!
|
||||
account = @family.accounts.create!(
|
||||
accountable: crypto,
|
||||
name: "Bitvavo",
|
||||
balance: 250,
|
||||
cash_balance: 10,
|
||||
currency: "EUR"
|
||||
)
|
||||
|
||||
coinstats_account = @coinstats_item.coinstats_accounts.create!(
|
||||
name: "Bitvavo",
|
||||
currency: "EUR",
|
||||
account_id: "exchange_portfolio:portfolio_123",
|
||||
wallet_address: "portfolio_123",
|
||||
current_balance: 250,
|
||||
raw_payload: {
|
||||
source: "exchange",
|
||||
portfolio_account: true,
|
||||
portfolio_id: "portfolio_123",
|
||||
connection_id: "bitvavo",
|
||||
exchange_name: "Bitvavo",
|
||||
coins: [
|
||||
{
|
||||
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
|
||||
count: "0.003",
|
||||
price: { EUR: "80000" }
|
||||
}
|
||||
]
|
||||
},
|
||||
raw_transactions_payload: []
|
||||
)
|
||||
AccountProvider.create!(account: account, provider: coinstats_account)
|
||||
|
||||
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
|
||||
@mock_provider.expects(:list_exchange_transactions)
|
||||
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
|
||||
.returns([])
|
||||
@mock_provider.expects(:list_portfolio_coins)
|
||||
.with(portfolio_id: "portfolio_123")
|
||||
.returns([])
|
||||
|
||||
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
|
||||
result = importer.import
|
||||
|
||||
assert result[:success]
|
||||
assert_equal 1, result[:accounts_updated]
|
||||
|
||||
reloaded = coinstats_account.reload
|
||||
assert_equal 0.0, reloaded.current_balance.to_f
|
||||
assert_equal [], reloaded.raw_payload["coins"]
|
||||
end
|
||||
|
||||
test "calculates balance from matching token only, not all tokens" do
|
||||
# Create two accounts for different tokens in the same wallet
|
||||
crypto1 = Crypto.create!
|
||||
|
||||
@@ -93,4 +93,34 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
|
||||
assert_equal "calculated", holding.cost_basis_source
|
||||
assert_equal BigDecimal("2750.0"), holding.cost_basis
|
||||
end
|
||||
|
||||
test "preserves calculated history for provider-sourced holdings on reverse materialization" do
|
||||
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(
|
||||
name: "Brokerage",
|
||||
currency: "USD"
|
||||
)
|
||||
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
|
||||
|
||||
Holding.create!(
|
||||
account: @account,
|
||||
security: @aapl,
|
||||
qty: 10,
|
||||
price: 200,
|
||||
amount: 2000,
|
||||
currency: "USD",
|
||||
date: Date.current,
|
||||
account_provider: account_provider
|
||||
)
|
||||
|
||||
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
|
||||
|
||||
today_holding = @account.holdings.find_by!(security: @aapl, date: Date.current, currency: "USD")
|
||||
yesterday_holding = @account.holdings.find_by!(security: @aapl, date: Date.yesterday, currency: "USD")
|
||||
|
||||
assert_equal account_provider.id, today_holding.account_provider_id
|
||||
assert_nil yesterday_holding.account_provider_id
|
||||
assert_equal BigDecimal("10"), yesterday_holding.qty
|
||||
assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,4 +47,41 @@ class Holding::PortfolioSnapshotTest < ActiveSupport::TestCase
|
||||
assert_equal 1, portfolio.size
|
||||
assert_equal 0, portfolio[@aapl.id]
|
||||
end
|
||||
|
||||
test "prefers the latest provider snapshot over newer calculated holdings" do
|
||||
@account.holdings.destroy_all
|
||||
@account.entries.destroy_all
|
||||
|
||||
create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago)
|
||||
create_trade(@msft, account: @account, qty: 5, price: 200, date: 5.days.ago)
|
||||
|
||||
coinstats_item = @account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
|
||||
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
|
||||
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
|
||||
|
||||
@account.holdings.create!(
|
||||
security: @aapl,
|
||||
date: 1.day.ago,
|
||||
qty: 10,
|
||||
price: 100,
|
||||
amount: 1000,
|
||||
currency: "USD",
|
||||
account_provider: account_provider
|
||||
)
|
||||
|
||||
@account.holdings.create!(
|
||||
security: @msft,
|
||||
date: Date.current,
|
||||
qty: 5,
|
||||
price: 200,
|
||||
amount: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
portfolio = Holding::PortfolioSnapshot.new(@account).to_h
|
||||
|
||||
assert_equal 2, portfolio.size
|
||||
assert_equal 10, portfolio[@aapl.id]
|
||||
assert_equal 0, portfolio[@msft.id]
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user