mirror of
https://github.com/we-promise/sure.git
synced 2026-08-06 00:52:16 +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>
222 lines
7.2 KiB
Ruby
222 lines
7.2 KiB
Ruby
class Trading212Account::ActivitiesProcessor
|
|
include Trading212Account::DataHelpers
|
|
|
|
# T212 transaction types that map to cash flows
|
|
DEPOSIT_TYPE = "DEPOSIT".freeze
|
|
WITHDRAW_TYPE = "WITHDRAW".freeze
|
|
INTEREST_TYPE = "INTEREST".freeze
|
|
FEE_TYPE = "FEE".freeze
|
|
|
|
def initialize(trading212_account)
|
|
@trading212_account = trading212_account
|
|
end
|
|
|
|
def process
|
|
return { trades: 0, dividends: 0, transactions: 0 } unless account.present?
|
|
|
|
trade_count = Array(@trading212_account.raw_orders_payload).sum do |order|
|
|
process_order(order.with_indifferent_access) ? 1 : 0
|
|
end
|
|
|
|
dividend_count = Array(@trading212_account.raw_dividends_payload).sum do |dividend|
|
|
process_dividend(dividend.with_indifferent_access) ? 1 : 0
|
|
end
|
|
|
|
transaction_count = Array(@trading212_account.raw_transactions_payload).sum do |transaction|
|
|
process_cash_transaction(transaction.with_indifferent_access) ? 1 : 0
|
|
end
|
|
|
|
{ trades: trade_count, dividends: dividend_count, transactions: transaction_count }
|
|
end
|
|
|
|
private
|
|
|
|
def account
|
|
@trading212_account.current_account
|
|
end
|
|
|
|
def import_adapter
|
|
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
|
end
|
|
|
|
def currency
|
|
@trading212_account.currency
|
|
end
|
|
|
|
def process_order(raw)
|
|
# T212 orders response is {order: {...}, fill: {...}}
|
|
order = (raw[:order] || {}).with_indifferent_access
|
|
fill = (raw[:fill] || {}).with_indifferent_access
|
|
|
|
return false unless order[:status].to_s.upcase == "FILLED"
|
|
|
|
instrument = (order[:instrument] || {}).with_indifferent_access
|
|
t212_ticker = instrument[:ticker].presence || order[:ticker].to_s
|
|
return false if t212_ticker.blank?
|
|
|
|
filled_quantity = parse_decimal(fill[:quantity])
|
|
fill_price = parse_decimal(fill[:price])
|
|
net_value = parse_decimal(fill.dig(:walletImpact, :netValue)) ||
|
|
parse_decimal(order[:filledValue])
|
|
|
|
return false unless filled_quantity && fill_price
|
|
return false if filled_quantity.zero?
|
|
|
|
isin = instrument[:isin].presence
|
|
ticker = standard_ticker(t212_ticker)
|
|
name = instrument[:name].presence || ticker
|
|
security = resolve_security_direct(isin, ticker, name)
|
|
return false unless security
|
|
|
|
is_buy = order[:side].to_s.upcase == "BUY"
|
|
activity_label = is_buy ? "Buy" : "Sell"
|
|
signed_quantity = is_buy ? filled_quantity : -filled_quantity
|
|
amount = net_value ? net_value.abs : (fill_price * filled_quantity.abs)
|
|
amount = is_buy ? amount : -amount
|
|
|
|
instrument_ccy = instrument[:currency].presence || currency
|
|
date = parse_date(fill[:filledAt] || order[:createdAt]) || Date.current
|
|
order_id = (fill[:id] || order[:id]).to_s
|
|
external_id = "trading212_order_#{order_id}"
|
|
|
|
import_adapter.import_trade(
|
|
external_id: external_id,
|
|
security: security,
|
|
quantity: signed_quantity,
|
|
price: fill_price,
|
|
amount: amount,
|
|
currency: instrument_ccy,
|
|
date: date,
|
|
name: build_order_name(security.ticker, signed_quantity),
|
|
source: "trading212",
|
|
activity_label: activity_label
|
|
)
|
|
|
|
true
|
|
rescue => e
|
|
DebugLogEntry.capture(
|
|
category: "sync",
|
|
level: "error",
|
|
message: "Trading212Account::ActivitiesProcessor - Failed to process order #{raw.dig(:order, :id)}: #{e.message}",
|
|
source: "trading212",
|
|
family: @trading212_account.trading212_item.family,
|
|
provider_key: "trading212",
|
|
metadata: { order_id: raw.dig(:order, :id), trading212_account_id: @trading212_account.id }
|
|
)
|
|
false
|
|
end
|
|
|
|
def process_dividend(dividend)
|
|
reference = dividend[:reference].to_s
|
|
return false if reference.blank?
|
|
|
|
amount = parse_decimal(dividend[:amount])
|
|
return false unless amount && amount > 0
|
|
|
|
t212_ticker = dividend[:ticker].to_s
|
|
security = t212_ticker.present? ? resolve_security_for_ticker(t212_ticker) : nil
|
|
|
|
date = parse_date(dividend[:paidOn]) || Date.current
|
|
|
|
|
|
import_adapter.import_transaction(
|
|
external_id: "trading212_dividend_#{reference}",
|
|
amount: -amount.abs,
|
|
currency: currency,
|
|
date: date,
|
|
name: build_dividend_name(security),
|
|
source: "trading212",
|
|
investment_activity_label: "Dividend",
|
|
extra: {
|
|
security_id: security&.id,
|
|
trading212: {
|
|
reference: reference,
|
|
ticker: t212_ticker,
|
|
quantity: dividend[:quantity],
|
|
gross_amount_per_share: dividend[:grossAmountPerShare],
|
|
type: dividend[:type]
|
|
}.compact
|
|
}
|
|
)
|
|
|
|
true
|
|
rescue => e
|
|
DebugLogEntry.capture(
|
|
category: "sync",
|
|
level: "error",
|
|
message: "Trading212Account::ActivitiesProcessor - Failed to process dividend #{dividend[:reference]}: #{e.message}",
|
|
source: "trading212",
|
|
family: @trading212_account.trading212_item.family,
|
|
provider_key: "trading212",
|
|
metadata: { reference: dividend[:reference], trading212_account_id: @trading212_account.id }
|
|
)
|
|
false
|
|
end
|
|
|
|
def process_cash_transaction(transaction)
|
|
reference = transaction[:reference].to_s
|
|
return false if reference.blank?
|
|
|
|
type = transaction[:type].to_s.upcase
|
|
amount = parse_decimal(transaction[:amount])
|
|
return false unless amount && !amount.zero?
|
|
|
|
label, signed_amount = classify_transaction(type, amount)
|
|
return false unless label
|
|
|
|
date = parse_date(transaction[:dateTime]) || Date.current
|
|
|
|
import_adapter.import_transaction(
|
|
external_id: "trading212_transaction_#{reference}",
|
|
amount: signed_amount,
|
|
currency: currency,
|
|
date: date,
|
|
name: label,
|
|
source: "trading212",
|
|
investment_activity_label: label,
|
|
extra: {
|
|
trading212: {
|
|
reference: reference,
|
|
type: type,
|
|
amount: transaction[:amount]
|
|
}.compact
|
|
}
|
|
)
|
|
|
|
true
|
|
rescue => e
|
|
DebugLogEntry.capture(
|
|
category: "sync",
|
|
level: "error",
|
|
message: "Trading212Account::ActivitiesProcessor - Failed to process transaction #{transaction[:reference]}: #{e.message}",
|
|
source: "trading212",
|
|
family: @trading212_account.trading212_item.family,
|
|
provider_key: "trading212",
|
|
metadata: { reference: transaction[:reference], trading212_account_id: @trading212_account.id }
|
|
)
|
|
false
|
|
end
|
|
|
|
def classify_transaction(type, amount)
|
|
case type
|
|
when DEPOSIT_TYPE
|
|
[ "Contribution", -amount.abs ]
|
|
when WITHDRAW_TYPE
|
|
[ "Withdrawal", amount.abs ]
|
|
when INTEREST_TYPE
|
|
[ "Interest", -amount.abs ]
|
|
when FEE_TYPE
|
|
[ "Fee", amount.abs ]
|
|
end
|
|
end
|
|
|
|
def build_order_name(ticker, signed_quantity)
|
|
action = signed_quantity.negative? ? "Sell" : "Buy"
|
|
"#{action} #{signed_quantity.abs} shares of #{ticker}"
|
|
end
|
|
|
|
def build_dividend_name(security)
|
|
security ? "Dividend from #{security.ticker}" : "Dividend"
|
|
end
|
|
end
|