mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 15:34:58 +00:00
Merge branch 'main' into feat/goals-v2-architecture
This commit is contained in:
6
.github/workflows/preview-deploy.yml
vendored
6
.github/workflows/preview-deploy.yml
vendored
@@ -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
21
SECURITY.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">·</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">·</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? %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 金鑰嗎?此操作無法還原,且會立即停用所有使用此金鑰的應用程式。"
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user