mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 04:02:15 +00:00
* Add support for trading212 integration in investments sync * Respect Trading 212 history rate limits * Replace hex colors with design tokens * Changed withdrawal to withdraw * Address PR review feedback for Trading 212 integration - Replace all Rails.logger calls with DebugLogEntry.capture across syncer, importer, processor, provider, controller, and unlinking - Fix destroy action to surface unlink errors via DebugLogEntry - Replace hardcoded 'GBP' currency fallback with Current.family.currency - Replace hardcoded English status strings in Syncer with i18n - Add config/initializers/trading212.rb with DEBUG_RAW ENV toggle - Add comprehensive test coverage: item, account, data_helpers, holdings_processor, activities_processor, importer, syncer, and controller tests - Rewrite syncer and controller tests to match actual interfaces * Fix tests and bugs found during local test execution - Fix standard_ticker: empty string caused nil.upcase via [].first - Fix parse_date: DateTime < Date, so when Date caught DateTime first - Fix ActivitiesProcessor/HoldingsProcessor: missing instruments_map in DataHelpers caused NameError when processing dividends - Fix test: security fixture ticker collision with test data - Fix test: sync_status_summary i18n matching in assertions - Fix test: trading212_provider ConfigurationError test path - Fix test: controller invalid params needs Turbo-Frame header - Fix test: syncer test uses stubs instead of strict expects 116 tests, 257 assertions, 0 failures, 0 errors * Keep raw provider response bodies out of exception messages. * Fix Secrets leak into page HTML, prevents the API key/secret from appearing in the HTML source while keeping the "leave blank to keep existing" UX. * Address review findings: env gate, sync test, uniqueness test, destroy flow - Gate TRADING212_DEBUG_RAW behind Rails.env.local? so staging/production cannot accidentally enable raw payload logging - Assert SyncJob enqueue in sync controller test, not just redirect - Fix cross-item uniqueness test to actually use two different items - Remove rescue in destroy so unlink failures stop the flow (matching Brex/Akahu pattern) instead of proceeding to destroy_later silently * Add Trading212 tables to db/schema.rb for CI test database CI runs db:test:prepare which loads db/schema.rb, not migrations. Without these table definitions, fixture loading fails with PG::UndefinedTable: relation "trading212_accounts" does not exist. * Removed lint complaint * Register Trading212 in ProviderConnectionStatus::PROVIDERS Fixes CI failure: test_provider_registry_covers_syncable_family_provider_item_associations * Fixed bad merge --------- Signed-off-by: jdcdp <47483528+jdcdp@users.noreply.github.com> Co-authored-by: jdcdp <jdcdp@cdm4.net>
420 lines
13 KiB
Ruby
420 lines
13 KiB
Ruby
require "test_helper"
|
|
|
|
class Trading212ActivitiesProcessorTest < ActiveSupport::TestCase
|
|
fixtures :families, :trading212_items, :trading212_accounts, :accounts, :securities
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@trading212_item = trading212_items(:configured_item)
|
|
@trading212_account = trading212_accounts(:main_account)
|
|
|
|
# Link to an investment account
|
|
@account = @family.accounts.create!(
|
|
name: "Test T212 Investment",
|
|
balance: 0,
|
|
cash_balance: 0,
|
|
currency: "USD",
|
|
accountable: Investment.new
|
|
)
|
|
@trading212_account.ensure_account_provider!(@account)
|
|
@trading212_account.reload
|
|
end
|
|
|
|
# === process orders (BUY) ===
|
|
|
|
test "processes a filled buy order as a Buy trade" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => {
|
|
"id" => "order_001",
|
|
"status" => "FILLED",
|
|
"side" => "BUY",
|
|
"instrument" => {
|
|
"ticker" => "AAPL_US_EQ",
|
|
"isin" => "US0378331005",
|
|
"name" => "Apple Inc."
|
|
},
|
|
"filledValue" => "1750.00",
|
|
"createdAt" => "2024-06-15T10:30:00Z"
|
|
},
|
|
"fill" => {
|
|
"id" => "fill_001",
|
|
"quantity" => "10",
|
|
"price" => "175.00",
|
|
"filledAt" => "2024-06-15T10:30:00Z"
|
|
}
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:trades]
|
|
entry = @account.entries.find_by(external_id: "trading212_order_fill_001")
|
|
assert_not_nil entry
|
|
assert_equal "Trade", entry.entryable_type
|
|
assert_equal "Buy", entry.entryable.investment_activity_label
|
|
assert entry.entryable.qty.positive?
|
|
end
|
|
|
|
test "processes a filled sell order as a Sell trade" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => {
|
|
"id" => "order_002",
|
|
"status" => "FILLED",
|
|
"side" => "SELL",
|
|
"instrument" => {
|
|
"ticker" => "TSLA_US_EQ",
|
|
"name" => "Tesla Inc."
|
|
},
|
|
"filledValue" => "2500.00",
|
|
"createdAt" => "2024-06-15T10:30:00Z"
|
|
},
|
|
"fill" => {
|
|
"id" => "fill_002",
|
|
"quantity" => "10",
|
|
"price" => "250.00",
|
|
"filledAt" => "2024-06-15T10:30:00Z"
|
|
}
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:trades]
|
|
entry = @account.entries.find_by(external_id: "trading212_order_fill_002")
|
|
assert_equal "Sell", entry.entryable.investment_activity_label
|
|
assert entry.entryable.qty.negative?
|
|
end
|
|
|
|
test "skips non-filled orders" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => {
|
|
"id" => "order_003",
|
|
"status" => "PENDING",
|
|
"side" => "BUY",
|
|
"instrument" => { "ticker" => "MSFT_US_EQ", "name" => "Microsoft" },
|
|
"filledValue" => "400.00",
|
|
"createdAt" => "2024-06-15T10:30:00Z"
|
|
},
|
|
"fill" => {
|
|
"id" => "fill_003",
|
|
"quantity" => "1",
|
|
"price" => "400.00"
|
|
}
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 0, result[:trades]
|
|
end
|
|
|
|
test "uses order id as external_id when fill id is absent" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => {
|
|
"id" => "order_005",
|
|
"status" => "FILLED",
|
|
"side" => "BUY",
|
|
"instrument" => { "ticker" => "MSFT_US_EQ", "name" => "Microsoft" },
|
|
"filledValue" => "400.00",
|
|
"createdAt" => "2024-06-15T10:30:00Z"
|
|
},
|
|
"fill" => {
|
|
"quantity" => "1",
|
|
"price" => "400.00",
|
|
"filledAt" => "2024-06-15T10:30:00Z"
|
|
}
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:trades]
|
|
entry = @account.entries.find_by(external_id: "trading212_order_order_005")
|
|
assert_not_nil entry
|
|
end
|
|
|
|
# === process dividends ===
|
|
|
|
test "processes dividend as a cash transaction" do
|
|
@trading212_account.update!(
|
|
raw_dividends_payload: [
|
|
{
|
|
"reference" => "div_001",
|
|
"ticker" => "AAPL_US_EQ",
|
|
"amount" => "25.00",
|
|
"paidOn" => "2024-06-15",
|
|
"quantity" => "50",
|
|
"grossAmountPerShare" => "0.50",
|
|
"type" => "ORDINARY"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:dividends]
|
|
entry = @account.entries.find_by(external_id: "trading212_dividend_div_001")
|
|
assert_not_nil entry
|
|
assert_equal "Transaction", entry.entryable_type
|
|
assert_equal "Dividend", entry.entryable.investment_activity_label
|
|
# Dividends are negative in Sure (inflow)
|
|
assert entry.amount.negative?
|
|
end
|
|
|
|
test "skips dividend with blank reference" do
|
|
@trading212_account.update!(
|
|
raw_dividends_payload: [
|
|
{
|
|
"ticker" => "AAPL_US_EQ",
|
|
"amount" => "25.00",
|
|
"paidOn" => "2024-06-15"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 0, result[:dividends]
|
|
end
|
|
|
|
test "skips dividend with zero or nil amount" do
|
|
@trading212_account.update!(
|
|
raw_dividends_payload: [
|
|
{ "reference" => "div_zero", "amount" => "0", "paidOn" => "2024-06-15" },
|
|
{ "reference" => "div_nil", "amount" => nil, "paidOn" => "2024-06-15" }
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 0, result[:dividends]
|
|
end
|
|
|
|
# === process cash transactions ===
|
|
|
|
test "processes DEPOSIT as Contribution" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{
|
|
"reference" => "txn_001",
|
|
"type" => "DEPOSIT",
|
|
"amount" => "1000.00",
|
|
"dateTime" => "2024-06-15T10:00:00Z"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:transactions]
|
|
entry = @account.entries.find_by(external_id: "trading212_transaction_txn_001")
|
|
assert_not_nil entry
|
|
assert_equal "Contribution", entry.entryable.investment_activity_label
|
|
# Deposits are negative in Sure (inflow)
|
|
assert entry.amount.negative?
|
|
end
|
|
|
|
test "processes WITHDRAW as Withdrawal" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{
|
|
"reference" => "txn_002",
|
|
"type" => "WITHDRAW",
|
|
"amount" => "500.00",
|
|
"dateTime" => "2024-06-15T10:00:00Z"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:transactions]
|
|
entry = @account.entries.find_by(external_id: "trading212_transaction_txn_002")
|
|
assert_equal "Withdrawal", entry.entryable.investment_activity_label
|
|
assert entry.amount.positive?
|
|
end
|
|
|
|
test "processes INTEREST as Interest" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{
|
|
"reference" => "txn_003",
|
|
"type" => "INTEREST",
|
|
"amount" => "5.00",
|
|
"dateTime" => "2024-06-15T10:00:00Z"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:transactions]
|
|
entry = @account.entries.find_by(external_id: "trading212_transaction_txn_003")
|
|
assert_equal "Interest", entry.entryable.investment_activity_label
|
|
end
|
|
|
|
test "processes FEE as Fee" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{
|
|
"reference" => "txn_004",
|
|
"type" => "FEE",
|
|
"amount" => "2.50",
|
|
"dateTime" => "2024-06-15T10:00:00Z"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 1, result[:transactions]
|
|
entry = @account.entries.find_by(external_id: "trading212_transaction_txn_004")
|
|
assert_equal "Fee", entry.entryable.investment_activity_label
|
|
end
|
|
|
|
test "skips transaction with blank reference" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{
|
|
"type" => "DEPOSIT",
|
|
"amount" => "100.00",
|
|
"dateTime" => "2024-06-15T10:00:00Z"
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 0, result[:transactions]
|
|
end
|
|
|
|
test "skips transaction with zero amount" do
|
|
@trading212_account.update!(
|
|
raw_transactions_payload: [
|
|
{ "reference" => "txn_zero", "type" => "DEPOSIT", "amount" => "0", "dateTime" => "2024-06-15T10:00:00Z" }
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 0, result[:transactions]
|
|
end
|
|
|
|
# === idempotency ===
|
|
|
|
test "processor is idempotent - does not duplicate entries" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => {
|
|
"id" => "order_idempotent",
|
|
"status" => "FILLED",
|
|
"side" => "BUY",
|
|
"instrument" => { "ticker" => "AAPL_US_EQ", "name" => "Apple" },
|
|
"filledValue" => "1750.00",
|
|
"createdAt" => "2024-06-15T10:30:00Z"
|
|
},
|
|
"fill" => {
|
|
"id" => "fill_idempotent",
|
|
"quantity" => "10",
|
|
"price" => "175.00"
|
|
}
|
|
}
|
|
],
|
|
raw_dividends_payload: [
|
|
{ "reference" => "div_idempotent", "amount" => "10.00", "paidOn" => "2024-06-15" }
|
|
],
|
|
raw_transactions_payload: [
|
|
{ "reference" => "txn_idempotent", "type" => "DEPOSIT", "amount" => "100.00", "dateTime" => "2024-06-15T10:00:00Z" }
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
first_result = processor.process
|
|
second_result = processor.process
|
|
|
|
assert_equal first_result, second_result
|
|
assert_equal 1, @account.entries.where(external_id: "trading212_order_fill_idempotent").count
|
|
assert_equal 1, @account.entries.where(external_id: "trading212_dividend_div_idempotent").count
|
|
assert_equal 1, @account.entries.where(external_id: "trading212_transaction_txn_idempotent").count
|
|
end
|
|
|
|
# === process returns early without account ===
|
|
|
|
test "returns zero counts when no account linked" do
|
|
@trading212_account.account_provider.destroy!
|
|
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => { "id" => "o1", "status" => "FILLED", "side" => "BUY",
|
|
"instrument" => { "ticker" => "AAPL_US_EQ", "name" => "Apple" } },
|
|
"fill" => { "quantity" => "10", "price" => "175.00" }
|
|
}
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal({ trades: 0, dividends: 0, transactions: 0 }, result)
|
|
end
|
|
|
|
# === combined results ===
|
|
|
|
test "returns combined stats for all activity types" do
|
|
@trading212_account.update!(
|
|
raw_orders_payload: [
|
|
{
|
|
"order" => { "id" => "o1", "status" => "FILLED", "side" => "BUY",
|
|
"instrument" => { "ticker" => "AAPL_US_EQ", "name" => "Apple" } },
|
|
"fill" => { "id" => "f1", "quantity" => "10", "price" => "175.00", "filledAt" => "2024-06-15T10:30:00Z" }
|
|
},
|
|
{
|
|
"order" => { "id" => "o2", "status" => "FILLED", "side" => "SELL",
|
|
"instrument" => { "ticker" => "TSLA_US_EQ", "name" => "Tesla" } },
|
|
"fill" => { "id" => "f2", "quantity" => "5", "price" => "250.00", "filledAt" => "2024-06-15T11:00:00Z" }
|
|
}
|
|
],
|
|
raw_dividends_payload: [
|
|
{ "reference" => "d1", "amount" => "10.00", "paidOn" => "2024-06-15" }
|
|
],
|
|
raw_transactions_payload: [
|
|
{ "reference" => "t1", "type" => "DEPOSIT", "amount" => "1000.00", "dateTime" => "2024-06-15T10:00:00Z" },
|
|
{ "reference" => "t2", "type" => "FEE", "amount" => "2.00", "dateTime" => "2024-06-15T10:00:00Z" }
|
|
]
|
|
)
|
|
|
|
processor = Trading212Account::ActivitiesProcessor.new(@trading212_account)
|
|
result = processor.process
|
|
|
|
assert_equal 2, result[:trades]
|
|
assert_equal 1, result[:dividends]
|
|
assert_equal 2, result[:transactions]
|
|
end
|
|
end
|