Merge branch 'main' into feat/goals-v2-architecture

This commit is contained in:
Guillem Arias Fauste
2026-05-27 09:48:35 +02:00
committed by GitHub
15 changed files with 342 additions and 33 deletions

View File

@@ -2,7 +2,7 @@ name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- 'charts/**'
- 'docs/**'
@@ -10,7 +10,9 @@ on:
jobs:
deploy-preview:
if: contains(github.event.pull_request.labels.*.name, 'preview-cf')
if: |
contains(github.event.pull_request.labels.*.name, 'preview-cf') &&
(github.event.action != 'labeled' || github.event.label.name == 'preview-cf')
name: Deploy to Cloudflare Containers
runs-on: ubuntu-latest
timeout-minutes: 15

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

View File

@@ -5,8 +5,8 @@ class FamilyMerchantsController < ApplicationController
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ]
# Show all merchants for this family
@family_merchants = Current.family.merchants.alphabetically
@provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
@all_family_merchants = Current.family.merchants.alphabetically
@all_provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
# Show recently unlinked ProviderMerchants (within last 30 days)
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)
@@ -14,12 +14,15 @@ class FamilyMerchantsController < ApplicationController
.where(family: Current.family)
.recently_unlinked
.pluck(:merchant_id)
assigned_ids = @provider_merchants.pluck(:id)
assigned_ids = @all_provider_merchants.pluck(:id)
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
@enhanceable_count = @provider_merchants.where(website_url: [ nil, "" ]).count
@enhanceable_count = @all_provider_merchants.where(website_url: [ nil, "" ]).count
@llm_available = Provider::Registry.get_provider(:openai).present?
@pagy_family_merchants, @family_merchants = pagy(@all_family_merchants, page_param: :family_page, limit: safe_per_page)
@pagy_provider_merchants, @provider_merchants = pagy(@all_provider_merchants, page_param: :provider_page, limit: safe_per_page)
render layout: "settings"
end

View File

@@ -200,6 +200,12 @@ class Settings::HostingsController < ApplicationController
redirect_to settings_hosting_path, notice: t(".success")
rescue Setting::ValidationError => error
# Preserve user-submitted OpenAI config so the form re-renders with their
# input intact (issue #1824). The form auto-submits on blur, so a partial
# entry (e.g. URI base before model) hits validation and would otherwise
# be wiped because the view reads from the unchanged Setting.* values.
@openai_uri_base_input = hosting_params[:openai_uri_base] if hosting_params.key?(:openai_uri_base)
@openai_model_input = hosting_params[:openai_model] if hosting_params.key?(:openai_model)
flash.now[:alert] = error.message
render :show, status: :unprocessable_entity
end

View File

@@ -11,8 +11,11 @@ class IbkrAccount::HistoricalBalancesSync
return unless account.present?
return if normalized_rows.empty?
rows = balance_rows
return if rows.empty?
account.balances.upsert_all(
balance_rows,
rows,
unique_by: %i[account_id date currency]
)
end
@@ -109,12 +112,35 @@ class IbkrAccount::HistoricalBalancesSync
def balance_rows
current_time = Time.current
trade_flows_by_date # ensure @failed_fx_dates is populated before iterating
normalized_rows.each_with_index.map do |row, index|
normalized_rows.each_with_index.filter_map do |row, index|
next if @failed_fx_dates.include?(row[:date])
previous_row = index.zero? ? nil : normalized_rows[index - 1]
start_cash_balance = previous_row ? previous_row[:cash] : row[:cash]
start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash]
# Derive market return directly from IBKR's equity data so Period Return
# matches IBKR without requiring third-party security price providers.
#
# nmf = Δnon_cash - net_buy_sell
# Δnon_cash : change in holdings value per IBKR equity summary (exact)
# net_buy_sell: sum of trade entry amounts converted to base currency
# (positive = buy, negative = sell; IBKR fx_rate_to_base applied)
#
# non_cash_adjustments absorbs net_buy_sell so the virtual column
# end_non_cash_balance = start + nmf + adjustments stays equal to row[:non_cash].
if previous_row
net_buy_sell = trade_flows_by_date[row[:date]] || 0
nmf = row[:non_cash] - start_non_cash_balance - net_buy_sell
non_cash_adj = net_buy_sell
else
# First-day row has no prior period to diff against, so both values are
# intentionally zero — not a bug, just an unavoidable bootstrap constraint.
nmf = 0
non_cash_adj = 0
end
{
account_id: account.id,
date: row[:date],
@@ -127,13 +153,44 @@ class IbkrAccount::HistoricalBalancesSync
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
net_market_flows: nmf,
cash_adjustments: row[:cash] - start_cash_balance,
non_cash_adjustments: row[:non_cash] - start_non_cash_balance,
non_cash_adjustments: non_cash_adj,
flows_factor: 1,
created_at: current_time,
updated_at: current_time
}
end
end
# Net value of all trades on each date, in account base currency.
# Uses the IBKR-provided fx_rate_to_base stored on each Trade entry so the
# conversion is exact and consistent with IBKR's own calculations.
# Positive = net buy (cash out), negative = net sell (cash in).
def trade_flows_by_date
@trade_flows_by_date ||= begin
@failed_fx_dates = []
if account
account.entries
.joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
.where.not(trades: { qty: 0 })
.includes(:entryable)
.each_with_object(Hash.new(0)) do |entry, flows|
custom_rate = entry.entryable.exchange_rate
base_amount = Money.new(entry.amount, entry.currency)
.exchange_to(account_currency, custom_rate: custom_rate, date: entry.date)
.amount
flows[entry.date] += base_amount
rescue Money::ConversionError
Rails.logger.warn(
"IbkrAccount::HistoricalBalancesSync - No FX rate for #{entry.currency}#{account_currency} " \
"on #{entry.date}; balance row for this date will not be persisted"
)
@failed_fx_dates << entry.date
end
else
{}
end
end
end
end

View File

@@ -19,10 +19,10 @@
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".family_title", moniker: family_moniker) %></p>
<span class="text-subdued">&middot;</span>
<p><%= @family_merchants.count %></p>
<p><%= @all_family_merchants.count %></p>
</div>
<% if @family_merchants.any? %>
<% if @all_family_merchants.any? %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
@@ -52,13 +52,17 @@
</div>
</div>
<% end %>
<div class="pt-4">
<%= render "shared/pagination", pagy: @pagy_family_merchants %>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".provider_title") %></p>
<span class="text-subdued">&middot;</span>
<p><%= @provider_merchants.count %></p>
<p><%= @all_provider_merchants.count %></p>
</div>
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
@@ -84,7 +88,7 @@
</div>
<% end %>
<% if @provider_merchants.any? %>
<% if @all_provider_merchants.any? %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
@@ -106,6 +110,10 @@
<p class="text-secondary text-sm text-center"><%= t(".provider_empty", moniker: family_moniker_downcase) %></p>
</div>
<% end %>
<div class="pt-4">
<%= render "shared/pagination", pagy: @pagy_provider_merchants %>
</div>
</section>
<% if @unlinked_merchants.any? %>

View File

@@ -30,7 +30,7 @@
<%= form.text_field :openai_uri_base,
label: t(".uri_base_label"),
placeholder: t(".uri_base_placeholder"),
value: Setting.openai_uri_base,
value: @openai_uri_base_input || Setting.openai_uri_base,
autocomplete: "off",
autocapitalize: "none",
spellcheck: "false",
@@ -41,7 +41,7 @@
<%= form.text_field :openai_model,
label: t(".model_label"),
placeholder: t(".model_placeholder"),
value: Setting.openai_model,
value: @openai_model_input || Setting.openai_model,
autocomplete: "off",
autocapitalize: "none",
spellcheck: "false",

View File

@@ -17,18 +17,24 @@
<%= tag.details open: open,
class: "group bg-container shadow-border-xs rounded-xl #{border_class}",
data: details_data do %>
<summary class="flex items-center gap-3 min-h-15 px-4 py-3.5 cursor-pointer rounded-xl list-none [&::-webkit-details-marker]:hidden">
<%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %>
<div class="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<h3 class="text-sm font-medium text-primary"><%= entry[:title] %></h3>
<%= render "settings/providers/maturity_badge", label: maturity_lbl %>
</div>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status_pill %>
<%= sync_action if sync_action %>
<summary class="min-h-15 px-4 py-3.5 cursor-pointer rounded-xl list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-3">
<%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %>
<div class="flex items-center flex-wrap gap-2 min-w-0 flex-1">
<div class="flex items-center gap-2 shrink-0 flex-1">
<h3 class="text-sm font-medium text-primary"><%= entry[:title] %></h3>
<%= render "settings/providers/maturity_badge", label: maturity_lbl %>
</div>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status_pill %>
</div>
</div>
<div class="group-open:hidden">
<%= sync_action if sync_action %>
</div>
</div>
</summary>
<div class="space-y-4 mt-4 px-4 pb-4">

View File

@@ -37,7 +37,7 @@ es:
never_expires: "Nunca expira"
permissions: "Permisos"
usage_instructions_title: "Cómo usar tu clave API"
usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de Maybe:"
usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de %{product_name}:"
regenerate_key: "Crear Nueva Clave"
revoke_key: "Revocar Clave"
revoke_confirmation: "¿Estás seguro de que deseas revocar esta clave API? Esta acción no se puede deshacer y deshabilitará inmediatamente todas las aplicaciones que usen esta clave."

View File

@@ -36,7 +36,7 @@ nb:
never_expires: "Utløper aldri"
permissions: "Tillatelser"
usage_instructions_title: "Hvordan bruke din API-nøkkel"
usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til Maybe API-et:"
usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til %{product_name} API-et:"
regenerate_key: "Opprett ny nøkkel"
revoke_key: "Tilbakekall nøkkel"
revoke_confirmation: "Er du sikker på at du vil tilbakekalle denne API-nøkkelen? Denne handlingen kan ikke angres og vil umiddelbart deaktivere alle applikasjoner som bruker denne nøkkelen."

View File

@@ -37,7 +37,7 @@ pl:
never_expires: Nigdy nie wygasa
permissions: Uprawnienia
usage_instructions_title: Jak używać klucza API
usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API Maybe:'
usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API %{product_name}:'
regenerate_key: Utwórz nowy klucz
revoke_key: Unieważnij klucz
revoke_confirmation: Czy na pewno chcesz unieważnić ten klucz API? Tej akcji nie można cofnąć, a wszystkie aplikacje używające tego klucza zostaną natychmiast wyłączone.

View File

@@ -36,7 +36,7 @@ tr:
never_expires: "Süresiz"
permissions: "Yetkiler"
usage_instructions_title: "API anahtarınızı nasıl kullanırsınız"
usage_instructions: "Maybe API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:"
usage_instructions: "%{product_name} API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:"
regenerate_key: "Yeni Anahtar Oluştur"
revoke_key: "Anahtarı İptal Et"
revoke_confirmation: "Bu API anahtarını iptal etmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamalar hemen devre dışı kalır."

View File

@@ -37,7 +37,7 @@ zh-TW:
never_expires: "永不過期"
permissions: "權限範圍"
usage_instructions_title: "如何使用您的 API 金鑰"
usage_instructions: "在向 Maybe API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:"
usage_instructions: "在向 %{product_name} API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:"
regenerate_key: "建立新金鑰"
revoke_key: "撤銷金鑰"
revoke_confirmation: "您確定要撤銷此 API 金鑰嗎?此操作無法還原,且會立即停用所有使用此金鑰的應用程式。"

View File

@@ -95,6 +95,55 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
end
# Regression: issue #1824. The OpenAI form auto-submits on blur, so entering
# the URI base before the model fires a partial submit that fails validation.
# The re-rendered form must show the user's submitted URI base — not the
# still-blank saved value — so they can finish typing the model.
test "preserves submitted openai uri base in form when validation fails" do
with_self_hosting do
Setting.openai_uri_base = nil
Setting.openai_model = ""
patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1" } }
assert_response :unprocessable_entity
assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs|
assert_equal "https://api.example.com/v1", inputs.first["value"]
end
end
ensure
Setting.openai_uri_base = nil
Setting.openai_model = nil
end
# PR #1862 review (jjmata): symmetric coverage for the model field. When the
# user changes the URI base and clears the model in the same auto-submit, the
# cross-field validation fails — the re-rendered model input must reflect the
# user's submitted (cleared) value, not silently revert to the saved model.
test "preserves submitted openai model in form when validation fails" do
with_self_hosting do
Setting.openai_uri_base = "https://saved.example.com/v1"
Setting.openai_model = "saved-model"
patch settings_hosting_url, params: { setting: {
openai_uri_base: "https://new.example.com/v1",
openai_model: ""
} }
assert_response :unprocessable_entity
assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs|
assert_equal "https://new.example.com/v1", inputs.first["value"]
end
assert_select "input[name=?]", "setting[openai_model]" do |inputs|
assert_not_equal "saved-model", inputs.first["value"].to_s,
"model field must reflect the submitted (cleared) value, not the saved model"
end
end
ensure
Setting.openai_uri_base = nil
Setting.openai_model = nil
end
test "can update openai model alone when self hosting is enabled" do
with_self_hosting do
patch settings_hosting_url, params: { setting: { openai_model: "gpt-4" } }

View File

@@ -213,6 +213,163 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF")
end
test "computes net_market_flows from equity delta minus trade flows" do
# Day 1: total=3000, cash=500, non_cash=2500
# Day 2: total=3200, cash=500, non_cash=2700 (Δnon_cash=200)
# Buy trade on Day 2: CHF 150 (same currency as account, no FX)
# nmf = 200 - 150 = 50
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
security = Security.create!(ticker: "TEST", name: "Test Stock")
@account.entries.create!(
name: "Buy 100 TEST",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "CHF",
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "CHF")
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("0"), day1.net_market_flows
assert_equal BigDecimal("50"), day2.net_market_flows
# Virtual column must still resolve to IBKR's equity total minus cash
assert_equal BigDecimal("2500.00"), day1.end_non_cash_balance
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
end
test "applies fx_rate_to_base when trade currency differs from account currency" do
# Trade in EUR with fx_rate_to_base=1.1 → CHF 165, not CHF 150
# Day 1: non_cash=2500, Day 2: non_cash=2700 (Δ=200)
# nmf = 200 - 165 = 35
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
security = Security.create!(ticker: "TEST2", name: "Test Stock EUR")
trade = Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
trade.exchange_rate = 1.1
@account.entries.create!(
name: "Buy 100 TEST2",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "EUR",
entryable: trade
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_in_delta 35.0, day2.net_market_flows.to_f, 0.01
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
end
test "excludes balance row from upsert when Money::ConversionError prevents FX conversion" do
# EUR trade with no exchange_rate stored → custom_rate=nil → ConversionError raised.
# The affected date is excluded from the upsert entirely so net_market_flows is not
# silently wrong (the trade's value would otherwise flow into market appreciation).
# The seeded day2 balance is intentionally different from IBKR's total (3150 vs 3200)
# so we can assert the row was not overwritten by sync.
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3150.00, cash_balance: 500.00)
security = Security.create!(ticker: "NORATE", name: "No Rate EUR Stock")
@account.entries.create!(
name: "Buy 100 NORATE",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "EUR",
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
)
Money.any_instance.stubs(:exchange_to).raises(
Money::ConversionError.new(from_currency: "EUR", to_currency: "CHF", date: Date.new(2026, 5, 8))
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
# Day 1 is unaffected — still synced normally
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3000.00"), day1.balance
# Day 2 was excluded from the upsert — seeded values are preserved, not overwritten
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("3150.00"), day2.balance # seeded, not IBKR's 3200
assert_equal BigDecimal("0"), day2.net_market_flows # seeded, not recomputed
end
test "net_market_flows equals full non_cash delta when account has no trades" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3300.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3300.00, cash_balance: 500.00)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("0"), day1.net_market_flows
assert_equal BigDecimal("300"), day2.net_market_flows
assert_equal BigDecimal("2800.00"), day2.end_non_cash_balance
end
test "sell trades reduce net_buy_sell so market loss is isolated in net_market_flows" do
# Day 1: total=3000, cash=500, non_cash=2500
# Day 2: total=2700, cash=700, non_cash=2000 (Δnon_cash=-500)
# Sell 100 at CHF 1.50: entry.amount=-150 (negative = proceeds received)
# net_buy_sell=-150; nmf = -500 - (-150) = -350 (market caused -350 loss)
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "2700.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 2700.00, cash_balance: 700.00)
security = Security.create!(ticker: "SELL_TEST", name: "Sell Test Stock")
@account.entries.create!(
name: "Sell 100 SELL_TEST",
date: Date.new(2026, 5, 8),
amount: -150.00,
currency: "CHF",
entryable: Trade.new(security: security, qty: -100, price: 1.5, currency: "CHF")
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("-350"), day2.net_market_flows
assert_equal BigDecimal("2000.00"), day2.end_non_cash_balance
end
test "writes balance row with zero total for fully liquidated dates" do
@ibkr_account.update!(
raw_equity_summary_payload: [