<%= icon("search") %>
- <%= hidden_field_tag :account_id, @account.id %>
+ <%= hidden_field_tag :account_id, account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
value: @q[:search],
@@ -61,7 +68,7 @@
<% if @entries.empty? %>
<%= t(".no_entries") %>
<% else %>
- <%= tag.div id: dom_id(@account, "entries_bulk_select"),
+ <%= tag.div id: dom_id(account, "entries_bulk_select"),
data: {
controller: "bulk-select checkbox-toggle",
bulk_select_singular_label_value: t(".entry"),
diff --git a/app/views/bond_lots/_form.html.erb b/app/views/bond_lots/_form.html.erb
new file mode 100644
index 000000000..e1858994b
--- /dev/null
+++ b/app/views/bond_lots/_form.html.erb
@@ -0,0 +1,173 @@
+<%# locals: (account:, bond_lot:, url:) %>
+
+<%= styled_form_with model: bond_lot,
+ url: url,
+ html: {
+ data: {
+ controller: "bond-lot-form bond-lot-inflation",
+ bond_lot_form_product_subtype_map_value: Bond::PRODUCT_DEFAULTS.transform_values { |defaults| defaults[:subtype] }.to_json,
+ bond_lot_form_product_term_map_value: Bond::PRODUCT_DEFAULTS.transform_values { |defaults| defaults[:term_months] }.to_json,
+ bond_lot_inflation_inflation_subtypes_value: Bond::INFLATION_LINKED_SUBTYPES.to_json,
+ bond_lot_inflation_lot_auto_fetch_value: false,
+ bond_lot_inflation_global_import_enabled_value: false
+ }
+ } do |form| %>
+
+ <% if bond_lot.errors.any? %>
+ <%= render "shared/form_errors", model: bond_lot %>
+ <% end %>
+
+
+ <%= form.date_field :purchased_on,
+ label: t("bond_lots.form.purchased_on"),
+ required: true,
+ data: { action: "change->bond-lot-form#syncIssueDateWithPurchase" } %>
+
+
+ <%= form.date_field :issue_date,
+ label: t("bond_lots.form.issue_date"),
+ data: { bond_lot_inflation_target: "inflationInput" } %>
+
+
+ <%= form.money_field :amount,
+ label: t("bond_lots.form.amount"),
+ default_currency: account.currency,
+ required: true %>
+
+ <%= form.select :product_code,
+ Bond.product_options_for_select,
+ {
+ label: t("bond_lots.form.product_code"),
+ include_blank: t("bond_lots.form.product_code_blank")
+ },
+ {
+ data: {
+ action: "change->bond-lot-form#syncSubtypeWithProduct",
+ bond_lot_form_target: "productCodeSelect"
+ }
+ } %>
+
+
+ <%= form.number_field :units,
+ label: t("bond_lots.form.units"),
+ min: 0.01,
+ step: 0.01,
+ data: { bond_lot_inflation_target: "inflationInput" } %>
+
+ <%= form.money_field :nominal_per_unit,
+ label: t("bond_lots.form.nominal_per_unit"),
+ default_currency: account.currency,
+ data: { bond_lot_inflation_target: "inflationInput" } %>
+
+
+ <%= form.number_field :term_months,
+ label: t("bond_lots.form.term_months"),
+ min: 1,
+ required: true %>
+
+
+ <%= form.number_field :interest_rate,
+ label: t("bond_lots.form.interest_rate"),
+ placeholder: t("bond_lots.form.interest_rate_placeholder"),
+ min: 0,
+ step: 0.005,
+ required: true,
+ data: { bond_lot_inflation_target: "otherRequiredInput" } %>
+
+
+ <% selected_subtype = Bond::LEGACY_SUBTYPE_ALIASES.fetch(bond_lot.subtype.to_s, bond_lot.subtype.presence || "other") %>
+ <%= form.select :subtype,
+ Bond::SUBTYPES.map { |key, labels| [ Bond.long_subtype_label_for(key) || labels[:long], key ] },
+ {
+ label: t("bond_lots.form.subtype"),
+ required: false,
+ selected: selected_subtype
+ },
+ {
+ data: {
+ action: "change->bond-lot-inflation#toggleSubtypeFields",
+ bond_lot_form_target: "subtypeSelect"
+ }
+ } %>
+
+ <%= t("bond_lots.form.subtype_derived_hint") %>
+
+
+
+ <%= form.select :rate_type,
+ Bond::RATE_TYPES.map { |value| [ t("bond_lots.form.rate_types.#{value}", default: value.titleize), value ] },
+ { label: t("bond_lots.form.rate_type"), required: true },
+ { data: { bond_lot_inflation_target: "otherRequiredInput" } } %>
+
+ <%= form.select :coupon_frequency,
+ Bond::COUPON_FREQUENCIES.map { |value| [ t("bond_lots.form.coupon_frequencies.#{value}", default: value.humanize), value ] },
+ { label: t("bond_lots.form.coupon_frequency"), required: true },
+ { data: { bond_lot_inflation_target: "otherRequiredInput" } } %>
+
+
+
+ <%= form.number_field :first_period_rate,
+ label: t("bond_lots.form.first_period_rate"),
+ min: 0,
+ step: 0.005,
+ data: { bond_lot_inflation_target: "inflationInput", optional: true, requires_first_period_check: true } %>
+
+ <%= form.number_field :inflation_margin,
+ label: t("bond_lots.form.inflation_margin"),
+ min: 0,
+ step: 0.005,
+ data: { bond_lot_inflation_target: "inflationInput" } %>
+
+
+
+ <%= form.number_field :cpi_lag_months,
+ label: t("bond_lots.form.cpi_lag_months"),
+ min: 0,
+ step: 1,
+ data: { bond_lot_inflation_target: "inflationInput" } %>
+
+
+ <%= form.hidden_field :inflation_provider, value: "" %>
+ <%= form.hidden_field :auto_fetch_inflation, value: 0 %>
+
+
+ <%= form.number_field :inflation_rate_assumption,
+ label: t("bond_lots.form.inflation_rate_assumption"),
+ min: 0,
+ step: 0.005,
+ data: { bond_lot_inflation_target: "manualInflationInput" } %>
+
+
+
+ <%= form.money_field :early_redemption_fee,
+ label: t("bond_lots.form.early_redemption_fee"),
+ default_currency: account.currency,
+ data: { bond_lot_inflation_target: "inflationInput", optional: true } %>
+
+
+
+
+
+
<%= t("bond_lots.form.auto_close_on_maturity") %>
+
<%= t("bond_lots.form.auto_close_on_maturity_hint") %>
+
+ <%= form.toggle :auto_close_on_maturity, checked: bond_lot.auto_close_on_maturity? %>
+
+
+ <% unless account.bond.tax_exempt_wrapper? %>
+ <%= form.select :tax_strategy,
+ BondLot::TAX_STRATEGIES.map { |value| [ t("bond_lots.form.tax_strategies.#{value}"), value ] },
+ { label: t("bond_lots.form.tax_strategy") } %>
+
+ <%= form.number_field :tax_rate,
+ label: t("bond_lots.form.tax_rate"),
+ min: 0,
+ max: 100,
+ step: 0.001 %>
+ <% end %>
+
+
+
+ <%= form.submit bond_lot.new_record? ? t("bond_lots.form.submit") : t("bond_lots.form.update") %>
+
+<% end %>
diff --git a/app/views/bond_lots/edit.html.erb b/app/views/bond_lots/edit.html.erb
new file mode 100644
index 000000000..418ea2c69
--- /dev/null
+++ b/app/views/bond_lots/edit.html.erb
@@ -0,0 +1,6 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t(".title", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, bond_lot: @bond_lot, url: bond_lot_path(@bond_lot) %>
+ <% end %>
+<% end %>
diff --git a/app/views/bond_lots/new.html.erb b/app/views/bond_lots/new.html.erb
new file mode 100644
index 000000000..fd711b573
--- /dev/null
+++ b/app/views/bond_lots/new.html.erb
@@ -0,0 +1,6 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t(".title", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, bond_lot: @bond_lot, url: bond_lots_path(account_id: @account.id) %>
+ <% end %>
+<% end %>
diff --git a/app/views/bond_lots/show.html.erb b/app/views/bond_lots/show.html.erb
new file mode 100644
index 000000000..174742de2
--- /dev/null
+++ b/app/views/bond_lots/show.html.erb
@@ -0,0 +1,95 @@
+<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %>
+ <% dialog.with_header(custom_header: true) do %>
+
+
+ <%= tag.h3(Bond.long_subtype_label_for(@bond_lot.subtype) || t(".unknown"), class: "text-2xl font-medium text-primary") %>
+ <%= tag.p t(".purchased", date: l(@bond_lot.purchased_on)), class: "text-sm text-secondary" %>
+
+ <%= dialog.close_button %>
+
+ <% end %>
+
+ <% dialog.with_body do %>
+ <% if @bond_lot.open? %>
+
+ <%= render "form", account: @account, bond_lot: @bond_lot, url: bond_lot_path(@bond_lot) %>
+
+ <% else %>
+
+
+
<%= t(".overview_principal") %>: <%= format_money(Money.new(@bond_lot.amount, @account.currency)) %>
+
<%= t(".overview_settlement") %>: <%= format_money(Money.new(@bond_lot.settlement_amount.to_d, @account.currency)) %>
+
<%= t(".overview_maturity") %>: <%= @bond_lot.maturity_date ? l(@bond_lot.maturity_date) : t(".unknown") %>
+
<%= t(".overview_closed_on") %>: <%= @bond_lot.closed_on ? l(@bond_lot.closed_on) : t(".unknown") %>
+
+
+ <% end %>
+
+ <% dialog.with_section(title: t(".history"), open: true) do %>
+
+ <% history = @bond_lot.capitalization_history %>
+
+ <% if history.any? %>
+
+ <% history.each do |event| %>
+ -
+
+
+ <%= t(".history_period", period: event[:period_number], start: l(event[:start_on]), end: l(event[:end_on])) %>
+
+
+ <%= number_to_percentage(event[:annual_rate_percent], precision: 3) %>
+
+
+
+
+
<%= t(".history_balance", opening: Money.new(event[:opening_balance], @account.currency).format, closing: Money.new(event[:closing_balance], @account.currency).format) %>
+
<%= t(".history_interest", interest: Money.new(event[:interest_earned], @account.currency).format, rate: number_to_percentage(event[:annual_rate_percent], precision: 3)) %>
+
+ <% if event[:full_year_capitalization] %>
+ <%= t(".history_capitalized") %>
+ <% else %>
+ <%= t(".history_partial") %>
+ <% end %>
+
+
+
+ <% if event[:inflation_source].present? %>
+
+ <% inflation_text = number_to_percentage(event[:inflation_component_percent].to_d, precision: 3) %>
+ <% margin_text = number_to_percentage(event[:margin_component_percent].to_d, precision: 3) %>
+ <% if event[:inflation_source] == "first_period" %>
+ <%= t(".history_inflation_first_period") %>
+ <% else %>
+ <%= t(".history_inflation_manual", inflation: inflation_text, margin: margin_text) %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
<%= t(".no_history") %>
+ <% end %>
+
+ <% end %>
+
+ <% if @bond_lot.open? %>
+ <% dialog.with_section(title: t(".settings"), open: true) do %>
+
+
+
+
<%= t(".delete_title") %>
+
<%= t(".delete_subtitle") %>
+
+ <%= button_to t(".delete"),
+ bond_lot_path(@bond_lot),
+ method: :delete,
+ class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
+ data: { turbo_confirm: t(".delete_confirm") } %>
+
+
+ <% end %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/bonds/_cash_holding.html.erb b/app/views/bonds/_cash_holding.html.erb
new file mode 100644
index 000000000..5fd277279
--- /dev/null
+++ b/app/views/bonds/_cash_holding.html.erb
@@ -0,0 +1,41 @@
+<%# locals: (account:) %>
+
+<% currency = Money::Currency.new(account.currency) %>
+
+
+
+ <%= render DS::FilledIcon.new(
+ variant: :text,
+ text: currency.symbol,
+ rounded: true,
+ size: "lg"
+ ) %>
+
+
+ <%= tag.p t(".cash_position"), class: "text-primary" %>
+ <%= tag.p account.currency, class: "text-secondary text-xs uppercase" %>
+
+
+
+
+ <% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
+ <%= render "shared/progress_circle", progress: cash_weight %>
+ <%= tag.p number_to_percentage(cash_weight, precision: 1) %>
+
+
+
+ <%= tag.p "--", class: "text-secondary" %>
+
+
+
+ <%= tag.p format_money(account.cash_balance_money), class: "privacy-sensitive" %>
+
+
+
+ <%= tag.p "--", class: "text-secondary" %>
+
+
+
+ <%= tag.p "--", class: "text-secondary" %>
+
+
diff --git a/app/views/bonds/_closed_purchase_holding.html.erb b/app/views/bonds/_closed_purchase_holding.html.erb
new file mode 100644
index 000000000..5cadd05b2
--- /dev/null
+++ b/app/views/bonds/_closed_purchase_holding.html.erb
@@ -0,0 +1,57 @@
+<%# locals: (lot:, account:) %>
+
+<% label = Bond.long_subtype_label_for(lot.subtype) || t("bonds.purchase_holding.unknown") %>
+<% total_balance = account.balance.to_d %>
+<% weight = total_balance.zero? ? 0 : lot.amount.to_d / total_balance * 100 %>
+<% settlement_amount = lot.settlement_amount.to_d %>
+<% total_return_amount = settlement_amount - lot.amount.to_d %>
+<% total_return_percent = lot.amount.to_d.zero? ? 0 : (total_return_amount / lot.amount.to_d) * 100 %>
+<% total_return_class = total_return_amount.negative? ? "text-destructive" : "text-success" %>
+<% effective_on = lot.closed_on || lot.maturity_date || Date.current %>
+<% history = lot.capitalization_history(on: effective_on) %>
+<% total_interest = history.sum { |event| event[:interest_earned].to_d } %>
+<% tax_withheld = lot.tax_withheld.to_d %>
+
+
+
+ <%= render DS::FilledIcon.new(variant: :text, text: label, size: "md", rounded: true) %>
+
+
+ <%= link_to label, bond_lot_path(lot), data: { turbo_frame: :drawer }, class: "hover:underline" %>
+ <% closed_label = lot.closed_on ? l(lot.closed_on) : t("bonds.purchase_holding.unknown") %>
+ <%= tag.p t(".closed_meta", purchased: l(lot.purchased_on), closed: closed_label), class: "text-secondary text-xs uppercase" %>
+
+
+
+
+ <%= render "shared/progress_circle", progress: weight %>
+ <%= tag.p number_to_percentage(weight, precision: 1) %>
+
+
+
+ <% if lot.inflation_linked? %>
+ <% rate_on_close = lot.current_rate_percent(on: effective_on) %>
+ <%= tag.p(rate_on_close.present? ? number_to_percentage(rate_on_close, precision: 3) : t("bonds.purchase_holding.unknown"), class: "privacy-sensitive") %>
+ <%= tag.p t(".closed_rate_meta", periods: history.size), class: "font-normal text-secondary" %>
+ <% else %>
+ <%= tag.p(lot.interest_rate.present? ? number_to_percentage(lot.interest_rate, precision: 3) : t("bonds.purchase_holding.unknown"), class: "privacy-sensitive") %>
+ <%= tag.p t(".closed_rate_meta", periods: history.size), class: "font-normal text-secondary" %>
+ <% end %>
+
+
+
+ <%= tag.p format_money(Money.new(lot.amount, account.currency)), class: "privacy-sensitive" %>
+ <%= tag.p t("bonds.purchase_holding.principal_term", term: t("bonds.purchase_holding.term_months", count: lot.term_months)), class: "font-normal text-secondary" %>
+
+
+
+ <%= tag.p lot.closed_on ? l(lot.closed_on) : t("bonds.purchase_holding.unknown"), class: "privacy-sensitive" %>
+ <%= tag.p t(".settled_net", net: format_money(Money.new(settlement_amount, account.currency))), class: "font-normal text-secondary" %>
+
+
+
+ <%= tag.p format_money(Money.new(total_return_amount, account.currency)), class: ["privacy-sensitive", total_return_class] %>
+ <%= tag.p "(#{number_to_percentage(total_return_percent, precision: 1)})", class: ["privacy-sensitive", total_return_class] %>
+ <%= tag.p t(".history_meta", interest: format_money(Money.new(total_interest, account.currency)), tax: format_money(Money.new(tax_withheld, account.currency))), class: "font-normal text-secondary" %>
+
+
diff --git a/app/views/bonds/_form.html.erb b/app/views/bonds/_form.html.erb
new file mode 100644
index 000000000..ecfd72b02
--- /dev/null
+++ b/app/views/bonds/_form.html.erb
@@ -0,0 +1,33 @@
+<%# locals: (account:, url:) %>
+
+<%= render "accounts/form", account: account, url: url do |form| %>
+ <%= render "shared/ruler", classes: "my-4" %>
+
+
+ <%= form.fields_for :accountable do |bond_form| %>
+
+ <%= bond_form.money_field :initial_balance,
+ label: t("bonds.form.initial_balance"),
+ default_currency: Current.family.currency,
+ required: true %>
+
+
+
+ <%= bond_form.select :tax_wrapper,
+ Bond::TAX_WRAPPERS.map { |key, value| [value[:long], key] },
+ { label: t("bonds.form.tax_wrapper") },
+ { data: { action: "change->bond-account-form#toggleTaxWrapperFields", bond_account_form_target: "wrapperSelect" } } %>
+
+
+
+
+
+
<%= t("bonds.form.auto_buy_new_issues") %>
+
<%= t("bonds.form.auto_buy_new_issues_hint") %>
+
+ <%= bond_form.toggle :auto_buy_new_issues, checked: account.accountable&.auto_buy_new_issues? %>
+
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/bonds/_purchase_holding.html.erb b/app/views/bonds/_purchase_holding.html.erb
new file mode 100644
index 000000000..e89f1c2cc
--- /dev/null
+++ b/app/views/bonds/_purchase_holding.html.erb
@@ -0,0 +1,42 @@
+<%# locals: (lot:, account:) %>
+
+<%= turbo_frame_tag dom_id(lot) do %>
+ <% presenter = PurchaseHoldingPresenter.new(lot:, account:, view: self) %>
+
+
+
+ <%= render DS::FilledIcon.new(variant: :text, text: presenter.label, size: "md", rounded: true) %>
+
+
+ <%= link_to presenter.label, bond_lot_path(lot), data: { turbo_frame: :drawer }, class: "hover:underline" %>
+ <%= tag.p t(".purchased", date: l(lot.purchased_on)), class: "text-secondary text-xs uppercase" %>
+
+
+
+
+ <%= render "shared/progress_circle", progress: presenter.weight %>
+ <%= tag.p number_to_percentage(presenter.weight, precision: 1) %>
+
+
+
+ <%= tag.p presenter.rate_text, class: "privacy-sensitive" %>
+ <%= tag.p presenter.rate_meta, class: "font-normal text-secondary" %>
+
+
+
+ <%= tag.p format_money(Money.new(lot.amount, account.currency)), class: "privacy-sensitive" %>
+ <%= tag.p t(".principal_term", term: t(".term_months", count: lot.term_months)), class: "font-normal text-secondary" %>
+
+
+
+ <%= tag.p lot.maturity_date ? l(lot.maturity_date) : t(".unknown"), class: "privacy-sensitive" %>
+ <%= tag.p t(".maturity_label"), class: "font-normal text-secondary" %>
+
+
+
+ <%= tag.p format_money(Money.new(presenter.total_return_amount, account.currency)), class: ["privacy-sensitive", presenter.total_return_class] %>
+ <%= tag.p "(#{number_to_percentage(presenter.total_return_percent, precision: 1)})", class: ["privacy-sensitive", presenter.total_return_class] %>
+ <%= tag.p presenter.return_label, class: "font-normal text-secondary" %>
+
+
+<% end %>
diff --git a/app/views/bonds/edit.html.erb b/app/views/bonds/edit.html.erb
new file mode 100644
index 000000000..a5194f4a3
--- /dev/null
+++ b/app/views/bonds/edit.html.erb
@@ -0,0 +1,6 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: bond_path(@account) %>
+ <% end %>
+<% end %>
diff --git a/app/views/bonds/new.html.erb b/app/views/bonds/new.html.erb
new file mode 100644
index 000000000..156986c40
--- /dev/null
+++ b/app/views/bonds/new.html.erb
@@ -0,0 +1,13 @@
+<% if params[:step] == "method_select" %>
+ <%= render "accounts/new/method_selector",
+ path: new_bond_path(return_to: params[:return_to]),
+ provider_configs: @provider_configs,
+ accountable_type: "Bond" %>
+<% else %>
+ <%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "bonds/form", account: @account, url: bonds_path %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/bonds/tabs/_closed.html.erb b/app/views/bonds/tabs/_closed.html.erb
new file mode 100644
index 000000000..c1b0b3c2c
--- /dev/null
+++ b/app/views/bonds/tabs/_closed.html.erb
@@ -0,0 +1,35 @@
+<%# locals: (account:) %>
+
+<% closed_lots = account.bond.bond_lots.where.not(closed_on: nil).order(closed_on: :desc) %>
+
+<%= turbo_frame_tag dom_id(account, :closed) do %>
+
+
+ <%= tag.h2 t(".closed"), class: "font-medium text-lg" %>
+
+
+
+
+
+ <%= tag.p t(".name") %>
+ <%= tag.p t(".weight"), class: "justify-self-end" %>
+ <%= tag.p t(".rate"), class: "justify-self-end" %>
+ <%= tag.p t(".holdings"), class: "justify-self-end" %>
+ <%= tag.p t(".maturity"), class: "justify-self-end" %>
+ <%= tag.p t(".total_return"), class: "justify-self-end" %>
+
+
+
+ <% if closed_lots.any? %>
+ <% closed_lots.each_with_index do |lot, index| %>
+ <%= render "bonds/closed_purchase_holding", lot: lot, account: account %>
+ <%= render "shared/ruler" unless index == closed_lots.size - 1 %>
+ <% end %>
+ <% else %>
+
<%= t(".no_closed_lots") %>
+ <% end %>
+
+
+
+
+<% end %>
diff --git a/app/views/bonds/tabs/_positions.html.erb b/app/views/bonds/tabs/_positions.html.erb
new file mode 100644
index 000000000..e372f3bc0
--- /dev/null
+++ b/app/views/bonds/tabs/_positions.html.erb
@@ -0,0 +1,49 @@
+<%# locals: (account:) %>
+
+<% lots = account.bond.bond_lots.open.order(purchased_on: :desc) %>
+
+<%= turbo_frame_tag dom_id(account, :positions) do %>
+
+
+ <%= tag.h2 t(".positions"), class: "font-medium text-lg" %>
+ <%= link_to new_bond_lot_path(account_id: account.id),
+ id: dom_id(account, "new_activity"),
+ data: { turbo_frame: :modal },
+ class: "flex gap-1 font-medium items-center bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 text-primary p-2 rounded-lg" do %>
+
+ <%= icon("plus", color: "current") %>
+
+ <%= tag.span t(".new_activity"), class: "text-sm" %>
+ <% end %>
+
+
+
+
+
+ <%= tag.p t(".name") %>
+ <%= tag.p t(".weight"), class: "justify-self-end" %>
+ <%= tag.p t(".rate"), class: "justify-self-end" %>
+ <%= tag.p t(".holdings"), class: "justify-self-end" %>
+ <%= tag.p t(".maturity"), class: "justify-self-end" %>
+ <%= tag.p t(".total_return"), class: "justify-self-end" %>
+
+
+
+ <% if account.cash_balance.to_d > 0 %>
+ <%= render "bonds/cash_holding", account: account %>
+ <%= render "shared/ruler" if lots.any? %>
+ <% end %>
+
+ <% if lots.any? %>
+ <% lots.each_with_index do |lot, index| %>
+ <%= render "bonds/purchase_holding", lot: lot, account: account %>
+ <%= render "shared/ruler" unless index == lots.size - 1 %>
+ <% end %>
+ <% else %>
+
<%= t(".no_purchases") %>
+ <% end %>
+
+
+
+
+<% end %>
diff --git a/app/views/pages/dashboard/_bond_summary.html.erb b/app/views/pages/dashboard/_bond_summary.html.erb
new file mode 100644
index 000000000..f36254c2d
--- /dev/null
+++ b/app/views/pages/dashboard/_bond_summary.html.erb
@@ -0,0 +1,47 @@
+<%# locals: (bond_accounts:, total_value:, total_return:, top_lots:, **args) %>
+
+<% if bond_accounts.any? %>
+
+
+
+
+
<%= t(".title") %>
+
+
+
+ <%= format_money(Money.new(total_value, Current.family.currency)) %>
+
+
+
+ <%= t(".total_return") %>:
+ ">
+ <%= format_money(Money.new(total_return, Current.family.currency)) %>
+
+
+
+
+
+ <% if top_lots.any? %>
+
+
+
+
+
+ | <%= t(".bond") %> |
+ <%= t(".rate") %> |
+ <%= t(".principal") %> |
+ <%= t(".maturity") %> |
+ <%= t(".total_return") %> |
+
+
+
+ <% top_lots.each_with_index do |(account, lot), index| %>
+ <%= render UI::Dashboard::BondSummaryRow.new(account:, lot:, show_border: index < top_lots.size - 1) %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+<% end %>
diff --git a/compose.example.ai.yml b/compose.example.ai.yml
index 7c79178f7..44ecc91b3 100644
--- a/compose.example.ai.yml
+++ b/compose.example.ai.yml
@@ -93,6 +93,15 @@ x-rails-env: &rails_env
OPENAI_ACCESS_TOKEN: token-can-be-any-value-for-ollama
OPENAI_MODEL: llama3.1:8b # Note: Use tool-enabled model
OPENAI_URI_BASE: http://ollama:11434/v1
+ # Global CPI import for inflation-linked bonds (GUS SDP, US BLS, ES INE)
+ # Leave unset to manage via Settings UI; set to "true" to override from environment.
+ INFLATION_IMPORT_ENABLED: ${INFLATION_IMPORT_ENABLED:-}
+ GUS_SDP_API_KEY: ${GUS_SDP_API_KEY:-}
+ # Optional CPI provider overrides for international inflation-linked bonds
+ US_BLS_CPI_BASE_URL: ${US_BLS_CPI_BASE_URL:-}
+ US_BLS_CPI_SERIES_ID: ${US_BLS_CPI_SERIES_ID:-}
+ ES_INE_CPI_BASE_URL: ${ES_INE_CPI_BASE_URL:-}
+ ES_INE_CPI_SERIES_ID: ${ES_INE_CPI_SERIES_ID:-}
# Vector store — pgvector keeps all data local (requires pgvector/pgvector Docker image for db)
VECTOR_STORE_PROVIDER: pgvector
EMBEDDING_MODEL: nomic-embed-text
diff --git a/compose.example.yml b/compose.example.yml
index 108fd6b9a..0219ac8f7 100644
--- a/compose.example.yml
+++ b/compose.example.yml
@@ -58,6 +58,10 @@ x-rails-env: &rails_env
REDIS_URL: redis://redis:6379/1
# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this.
OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}
+ # Global CPI import for inflation-linked bonds (GUS SDP, US BLS, ES INE)
+ # Leave unset to manage via Settings UI; set to "true" to override from environment.
+ INFLATION_IMPORT_ENABLED: ${INFLATION_IMPORT_ENABLED}
+ GUS_SDP_API_KEY: ${GUS_SDP_API_KEY}
services:
web:
diff --git a/config/database.yml b/config/database.yml
index 5b249238a..dd2ce57b8 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -4,8 +4,8 @@ default: &default
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>
host: <%= ENV.fetch("DB_HOST") { "127.0.0.1" } %>
port: <%= ENV.fetch("DB_PORT") { "5432" } %>
- user: <%= ENV.fetch("POSTGRES_USER") { nil } %>
- password: <%= ENV.fetch("POSTGRES_PASSWORD") { nil } %>
+ username: <%= ENV.fetch("POSTGRES_USER", nil) %>
+ password: <%= ENV.fetch("POSTGRES_PASSWORD", nil) %>
development:
<<: *default
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml
index a8691be11..a5141d12a 100644
--- a/config/locales/views/accounts/en.yml
+++ b/config/locales/views/accounts/en.yml
@@ -118,6 +118,7 @@ en:
crypto: Crypto
property: Property
vehicle: Vehicle
+ bond: Bond
other_asset: Other Asset
credit_card: Credit Card
loan: Loan
diff --git a/config/locales/views/bonds/en.yml b/config/locales/views/bonds/en.yml
new file mode 100644
index 000000000..1f9593134
--- /dev/null
+++ b/config/locales/views/bonds/en.yml
@@ -0,0 +1,173 @@
+---
+en:
+ bonds:
+ edit:
+ edit: Edit %{account}
+ form:
+ initial_balance: Original bond balance
+ tax_wrapper: Tax wrapper
+ auto_buy_new_issues: Auto-buy new bond issues
+ auto_buy_new_issues_hint: After maturity settlement, automatically buy the next issue with available proceeds for IKE/IKZE accounts.
+ subtypes:
+ zero_coupon:
+ short: Zero-Coupon
+ long: Zero-Coupon Bill
+ fixed_coupon:
+ short: Fixed
+ long: Fixed Coupon Bond
+ inflation_linked:
+ short: ILB
+ long: Inflation-Linked Bond
+ savings:
+ short: Savings
+ long: Savings Bond
+ other:
+ short: Other
+ long: Other Bond
+ new:
+ title: Enter bond details
+ tabs:
+ positions:
+ positions: Bond positions
+ new_activity: New activity
+ name: Name
+ weight: Weight
+ rate: Rate
+ holdings: Holdings
+ maturity: Maturity
+ total_return: Total return
+ no_purchases: No purchases added yet.
+ closed:
+ closed: Closed bond lots
+ name: Name
+ weight: Weight
+ rate: Rate
+ holdings: Holdings
+ maturity: Maturity
+ total_return: Total return
+ no_closed_lots: No closed bond lots yet.
+ closed_lots: Closed bond lots
+ closed_lot_meta: "Purchased %{purchased}, closed %{closed}"
+ settled_net: Settled net
+ cash_holding:
+ cash_position: Bond cash
+ purchase_holding:
+ unknown: Unknown
+ month_year_format: "%m-%Y"
+ update_needed: Update needed
+ purchased: "Purchased %{date}"
+ term_months:
+ one: "%{count} month"
+ other: "%{count} months"
+ principal_term: "Principal, %{term}"
+ bond_meta: "%{rate_type} / %{coupon}"
+ bond_meta_with_coupon_amount: "%{rate_type} / %{coupon} / coupon %{coupon_amount}"
+ inflation_meta_gus: "%{inflation} inflation + %{margin} margin (PL GUS %{indicator})"
+ inflation_meta_manual: "%{inflation} inflation + %{margin} margin (manual)"
+ inflation_meta_provider: "%{inflation} inflation + %{margin} margin (%{provider})"
+ inflation_providers:
+ gus_sdp: PL GUS
+ us_bls: US BLS
+ es_ine: ES INE
+ manual: Manual
+ first_period_fixed_rate: First-period fixed rate
+ inflation_data_unavailable: "Current rate unavailable: missing CPI for ref %{reference}"
+ maturity: "Matures %{date}"
+ maturity_label: Maturity date
+ since_purchase: Since purchase
+ projected_to_maturity: Projected to maturity
+ pending_review: Awaiting updated issue rates
+ edit: Edit
+ remove: Remove
+ confirm_remove: Remove this purchase?
+
+ bond_lots:
+ not_bond_account: "This account is not a bond account."
+ new:
+ title: Add purchase for %{account}
+ edit:
+ title: Edit purchase for %{account}
+ show:
+ settings: Settings
+ history: History
+ no_history: No capitalization history yet.
+ overview_principal: Principal
+ overview_settlement: Settlement amount
+ overview_maturity: Maturity date
+ overview_closed_on: Closed on
+ history_period: "Period %{period}: %{start} - %{end}"
+ history_balance: "Balance %{opening} -> %{closing}"
+ history_interest: "Interest %{interest} at %{rate}"
+ history_capitalized: Capitalized
+ history_partial: Partial period
+ history_inflation_gus: "Inflation used: %{inflation} + %{margin} margin (GUS SDP %{indicator}, ref %{reference})"
+ history_inflation_manual: "Inflation used: %{inflation} + %{margin} margin (manual assumption)"
+ history_inflation_provider: "Inflation used: %{inflation} + %{margin} margin (%{provider})"
+ history_inflation_first_period: "First-period fixed rate"
+ unknown: Unknown
+ purchased: "Purchased %{date}"
+ delete_title: Delete purchase
+ delete_subtitle: Remove this purchase and linked activity entry.
+ delete: Delete
+ delete_confirm: Remove this purchase?
+ create:
+ success: Bond purchase added
+ update:
+ success: Bond purchase updated
+ destroy:
+ success: Bond purchase removed
+ form:
+ purchased_on: Purchase date
+ issue_date: Issue date
+ amount: Principal amount
+ product_code: Product label
+ product_code_blank: Custom / no preset
+ units: Units
+ nominal_per_unit: Nominal per unit
+ term_months: Term (months)
+ subtype: Bond type
+ subtype_derived_hint: Bond type is derived automatically from selected product preset.
+ rate_type: Rate type
+ coupon_frequency: Coupon frequency
+ interest_rate: Interest rate
+ interest_rate_placeholder: "4.25"
+ first_period_rate: First-period rate (%)
+ inflation_margin: Inflation margin (%)
+ auto_fetch_inflation: Fetch inflation automatically from provider
+ inflation_provider: Inflation data provider
+ inflation_provider_blank: Manual CPI (no provider)
+ auto_fetch_disabled_hint: Automatic CPI import is disabled globally in Self-Hosting settings. Auto-fetch for this lot requires enabling that setting, so enter inflation manually.
+ inflation_rate_assumption: CPI assumption (%)
+ cpi_lag_months: CPI lag (months)
+ early_redemption_fee: Early redemption fee
+ auto_close_on_maturity: Auto-close on maturity
+ auto_close_on_maturity_hint: Automatically settle this lot at maturity and convert proceeds to account cash.
+ tax_strategy: Tax handling at maturity
+ tax_rate: Tax rate (%)
+ tax_strategies:
+ standard: Standard tax
+ reduced: Reduced tax
+ exempt: Tax exempt (IKE/IKZE)
+ rate_types:
+ fixed: Fixed
+ variable: Variable
+ coupon_frequencies:
+ monthly: Monthly
+ quarterly: Quarterly
+ semi_annual: Semi-annual
+ annual: Annual
+ at_maturity: At maturity
+ submit: Add purchase
+ update: Update purchase
+ activity:
+ purchase_name: "Bond purchase: %{subtype}"
+ maturity_settlement_name: "Bond maturity settlement: %{subtype}"
+ maturity_settlement_notes_with_tax: "Purchase amount: %{purchase_amount}\nTotal interest: %{interest_amount}\nTax withheld: %{tax_withheld_amount}"
+ maturity_settlement_notes_without_tax: "Purchase amount: %{purchase_amount}\nTotal interest: %{interest_amount}\nTax withheld: none"
+
+ closed_purchase_holding:
+ closed_meta: "Purchased %{purchased}, closed %{closed}"
+ closed_rate_meta: "%{periods} capitalization periods"
+ settled_net: "Settled net %{net}"
+ history_meta: "Interest %{interest}, tax %{tax}"
+ not_bond_account: "This account is not a bond account."
diff --git a/config/locales/views/bonds/pl.yml b/config/locales/views/bonds/pl.yml
index c4af32682..6b0cb9257 100644
--- a/config/locales/views/bonds/pl.yml
+++ b/config/locales/views/bonds/pl.yml
@@ -9,6 +9,22 @@ pl:
auto_buy_new_issues: Automatycznie kupuj nowe emisje obligacji
auto_buy_new_issues_hint: Po rozliczeniu zapadalności automatycznie kup kolejną emisję z dostępnych środków dla kont IKE/IKZE.
subtypes:
+ zero_coupon:
+ short: Zero-kuponowa
+ long: Obligacja zero-kuponowa
+ fixed_coupon:
+ short: Stałokuponowa
+ long: Obligacja stałokuponowa
+ inflation_linked:
+ short: ILB
+ long: Obligacja inflacyjna
+ savings:
+ short: Oszczędnościowa
+ long: Obligacja oszczędnościowa
+ other:
+ short: Inna
+ long: Inna obligacja
+ # Legacy aliases for persisted lots during migration
eod:
short: EOD
long: 10-letnia obligacja oszczędnościowa Skarbu Państwa
@@ -47,6 +63,7 @@ pl:
cash_position: Gotówka z obligacji
purchase_holding:
unknown: Nieznane
+ month_year_format: "%m-%Y"
update_needed: Wymagana aktualizacja
purchased: "Zakupiono %{date}"
term_months:
@@ -56,9 +73,17 @@ pl:
other: "%{count} miesiąca"
principal_term: "Kapitał, %{term}"
bond_meta: "%{rate_type} / %{coupon}"
- inflation_meta_gus: "%{inflation} inflacji + %{margin} marży (GUS SDP %{indicator})"
+ bond_meta_with_coupon_amount: "%{rate_type} / %{coupon} / kupon %{coupon_amount}"
+ inflation_meta_gus: "%{inflation} inflacji + %{margin} marży (PL GUS %{indicator})"
inflation_meta_manual: "%{inflation} inflacji + %{margin} marży (ręcznie)"
+ inflation_meta_provider: "%{inflation} inflacji + %{margin} marży (%{provider})"
+ inflation_providers:
+ gus_sdp: PL GUS
+ us_bls: US BLS
+ es_ine: ES INE
+ manual: ręcznie
first_period_fixed_rate: Stałe oprocentowanie pierwszego okresu
+ inflation_data_unavailable: "Brak bieżącej stopy: brak CPI dla mies. odn. %{reference}"
maturity: "Zapada %{date}"
maturity_label: Data zapadalności
since_purchase: Od zakupu
@@ -89,6 +114,7 @@ pl:
history_partial: Okres częściowy
history_inflation_gus: "Użyta inflacja: %{inflation} + %{margin} marży (GUS SDP %{indicator}, odn. %{reference})"
history_inflation_manual: "Użyta inflacja: %{inflation} + %{margin} marży (założenie ręczne)"
+ history_inflation_provider: "Użyta inflacja: %{inflation} + %{margin} marży (%{provider})"
history_inflation_first_period: "Stała stopa pierwszego okresu"
unknown: Nieznane
purchased: "Zakupiono %{date}"
@@ -106,18 +132,23 @@ pl:
purchased_on: Data zakupu
issue_date: Data emisji
amount: Kwota kapitału
+ product_code: Preset produktu
+ product_code_blank: Własny / brak presetu
units: Jednostki
nominal_per_unit: Nominał na jednostkę
term_months: Okres (miesiące)
subtype: Typ obligacji
+ subtype_derived_hint: Typ obligacji jest wyznaczany automatycznie na podstawie wybranego presetu produktu.
rate_type: Typ oprocentowania
coupon_frequency: Częstotliwość kuponu
interest_rate: Oprocentowanie
interest_rate_placeholder: "4.25"
first_period_rate: Oprocentowanie pierwszego okresu (%)
inflation_margin: Marża inflacyjna (%)
- auto_fetch_inflation: Pobieraj inflację automatycznie z GUS
- auto_fetch_disabled_hint: Automatyczny import CPI jest globalnie wyłączony w ustawieniach self-hosting. Wprowadź inflację ręcznie.
+ auto_fetch_inflation: Pobieraj inflację automatycznie od dostawcy
+ inflation_provider: Dostawca danych o inflacji
+ inflation_provider_blank: Ręczne CPI (bez dostawcy)
+ auto_fetch_disabled_hint: Automatyczny import CPI jest globalnie wyłączony w ustawieniach self-hosting. Auto-fetch dla tej obligacji wymaga włączenia tej opcji, więc wprowadź inflację ręcznie.
inflation_rate_assumption: Założenie CPI (%)
cpi_lag_months: Opóźnienie CPI (miesiące)
early_redemption_fee: Opłata za wcześniejszy wykup
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml
index bded845ee..8cdc1c1b6 100644
--- a/config/locales/views/pages/en.yml
+++ b/config/locales/views/pages/en.yml
@@ -15,6 +15,9 @@ en:
welcome: "Welcome back, %{name}"
subtitle: "Here's what's happening with your finances"
new: "New"
+ bond_rate_review_notice:
+ one: "1 bond lot needs updated issue rate (%{accounts})."
+ other: "%{count} bond lots need updated issue rates (%{accounts})."
drag_to_reorder: "Drag to reorder section"
toggle_section: "Toggle section visibility"
net_worth_chart:
@@ -56,3 +59,17 @@ en:
trades: "Trades"
no_investments: "No investment accounts"
add_investment: "Add an investment account to track your portfolio"
+ bond_summary:
+ title: "Bonds"
+ total_return: "Total Return"
+ bond: "Bond"
+ rate: "Rate"
+ principal: "Principal"
+ maturity: "Maturity"
+ maturity_label: "Maturity date"
+ principal_term: "Principal, %{term}"
+ term_months:
+ one: "1 month"
+ other: "%{count} months"
+ no_bonds: "No bond accounts"
+ account_wrapper: "%{account} • %{wrapper}"
diff --git a/config/locales/views/settings/hostings/en.yml b/config/locales/views/settings/hostings/en.yml
index 0c3f8f7ab..b1bb1dc82 100644
--- a/config/locales/views/settings/hostings/en.yml
+++ b/config/locales/views/settings/hostings/en.yml
@@ -2,6 +2,9 @@
en:
settings:
hostings:
+ breadcrumbs:
+ home: Home
+ self_hosting: Self-Hosting
invite_code_settings:
description: Control how new people sign up for your instance of %{product}.
email_confirmation_description: When enabled, users must confirm their email
@@ -21,6 +24,7 @@ en:
general: General Settings
ai_assistant: AI Assistant
financial_data_providers: Financial Data Providers
+ inflation: Inflation
sync_settings: Sync Settings
invites: Invite Codes
title: Self-Hosting
diff --git a/config/locales/views/settings/hostings/pl.yml b/config/locales/views/settings/hostings/pl.yml
index 95b449e35..4d96f6f6d 100644
--- a/config/locales/views/settings/hostings/pl.yml
+++ b/config/locales/views/settings/hostings/pl.yml
@@ -2,6 +2,9 @@
pl:
settings:
hostings:
+ breadcrumbs:
+ home: Strona główna
+ self_hosting: Samodzielny hosting
invite_code_settings:
description: Kontroluj, jak nowe osoby rejestrują się w Twojej instancji %{product}.
email_confirmation_description: Gdy opcja jest włączona, użytkownicy muszą potwierdzić adres e-mail przy jego zmianie.
@@ -20,6 +23,7 @@ pl:
general: Ustawienia ogólne
ai_assistant: Asystent AI
financial_data_providers: Dostawcy danych finansowych
+ inflation: Inflacja
sync_settings: Ustawienia synchronizacji
invites: Kody zaproszeń
title: Self-Hosting
@@ -106,34 +110,6 @@ pl:
requires_plan: wymaga planu %{plan}
view_pricing: Zobacz cennik Twelve Data
title: Twelve Data
- gus_sdp_settings:
- title: GUS SDP (Inflacja)
- description: Opcjonalny klucz API do danych inflacyjnych GUS SDP. Pozostaw puste, aby korzystać z bezpłatnego poziomu anonimowego.
- env_configured_message: Pomyślnie skonfigurowano przez zmienną środowiskową GUS_SDP_API_KEY.
- configured_in_settings_message: Klucz API skonfigurowany w ustawieniach.
- configured_via_env: Klucz API jest skonfigurowany przez zmienną środowiskową GUS_SDP_API_KEY.
- free_default_message: Brak skonfigurowanego klucza API. Domyślnie używany jest bezpłatny dostęp anonimowy.
- clear_api_key: Wyczyść zapisany klucz API
- clear_api_key_confirm: Usunąć zapisany klucz API GUS?
- label: Klucz API
- placeholder: Wprowadź klucz API GUS SDP
- import_enabled_label: Włącz automatyczny import CPI GUS
- import_enabled_help: Domyślnie wyłączone. Włącz tylko, jeśli chcesz automatycznie importować stawki CPI do obliczeń EOD/ROD.
- import_enabled_env_locked: To ustawienie jest zablokowane przez zmienną środowiskową GUS_INFLATION_IMPORT_ENABLED.
- start_year: Rok początkowy
- end_year: Rok końcowy
- import_now: Importuj historię CPI teraz
- last_import: Ostatni import
- last_range: Ostatni zakres
- last_count: Zaimportowane rekordy
- last_error: Ostatni błąd
- stored_records: Zapisane rekordy
- stored_range: Zapisany zakres
- never: Nigdy
- import_gus_inflation_rates:
- import_enqueued: Import CPI został dodany do kolejki.
- import_disabled: Import CPI jest wyłączony. Najpierw włącz go w ustawieniach.
- invalid_import_range: Nieprawidłowy zakres lat dla importu CPI.
update:
failure: Nieprawidłowa wartość ustawienia
success: Ustawienia zostały zaktualizowane
diff --git a/config/routes.rb b/config/routes.rb
index 544d1c8f0..3277fe203 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -394,6 +394,8 @@
resources :vehicles, only: %i[new create edit update]
resources :credit_cards, only: %i[new create edit update]
resources :loans, only: %i[new create edit update]
+ resources :bonds, only: %i[new create edit update]
+ resources :bond_lots, only: %i[new create show edit update destroy]
resources :cryptos, only: %i[new create edit update]
resources :other_assets, only: %i[new create edit update]
resources :other_liabilities, only: %i[new create edit update]
diff --git a/config/schedule.yml b/config/schedule.yml
index c3903a229..2d24490a2 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -42,3 +42,9 @@ refresh_demo_family:
class: "DemoFamilyRefreshJob"
queue: "scheduled"
description: "Refreshes demo family data and emails super admins with daily usage summary"
+
+settle_matured_bond_lots:
+ cron: "15 2 * * *" # daily at 2:15 AM
+ class: "SettleMaturedBondLotsJob"
+ queue: "scheduled"
+ description: "Automatically settles matured bond lots into cash based on per-lot settings"
diff --git a/db/migrate/20260330233052_create_bonds.rb b/db/migrate/20260330233052_create_bonds.rb
new file mode 100644
index 000000000..50b16e918
--- /dev/null
+++ b/db/migrate/20260330233052_create_bonds.rb
@@ -0,0 +1,19 @@
+class CreateBonds < ActiveRecord::Migration[7.2]
+ def change
+ create_table :bonds, id: :uuid do |t|
+ t.decimal :initial_balance, precision: 19, scale: 4
+ t.decimal :interest_rate, precision: 10, scale: 3
+ t.integer :term_months
+ t.string :rate_type
+ t.date :maturity_date
+ t.string :coupon_frequency
+ t.string :subtype
+ t.jsonb :locked_attributes, default: {}, null: false
+ t.string :tax_wrapper, default: "none", null: false
+ t.boolean :auto_buy_new_issues, default: false, null: false
+ t.timestamps
+ end
+
+ add_index :bonds, :tax_wrapper
+ end
+end
diff --git a/db/migrate/20260331120000_create_bond_lots.rb b/db/migrate/20260331120000_create_bond_lots.rb
new file mode 100644
index 000000000..2d8fcd62f
--- /dev/null
+++ b/db/migrate/20260331120000_create_bond_lots.rb
@@ -0,0 +1,73 @@
+class CreateBondLots < ActiveRecord::Migration[7.2]
+ def change
+ create_table :bond_lots, id: :uuid do |t|
+ t.references :bond, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
+ t.date :purchased_on, null: false
+ t.decimal :amount, precision: 19, scale: 4, null: false
+ t.integer :term_months, null: false
+ t.date :maturity_date, null: false
+ t.decimal :interest_rate, precision: 10, scale: 3
+ t.string :subtype, null: false, default: "other"
+ t.string :rate_type
+ t.string :coupon_frequency
+ t.references :entry, type: :uuid, foreign_key: { to_table: :entries, on_delete: :nullify }, index: false
+
+ t.date :issue_date
+ t.decimal :first_period_rate, precision: 10, scale: 3
+ t.decimal :inflation_margin, precision: 10, scale: 3
+ t.decimal :inflation_rate_assumption, precision: 10, scale: 3
+ t.integer :cpi_lag_months
+ t.decimal :early_redemption_fee, precision: 19, scale: 4
+ t.decimal :units, precision: 12, scale: 2
+ t.decimal :nominal_per_unit, precision: 19, scale: 4
+ t.boolean :auto_fetch_inflation, null: false, default: true
+
+ t.boolean :auto_close_on_maturity, null: false, default: true
+ t.date :closed_on
+ t.decimal :settlement_amount, precision: 19, scale: 4
+ t.decimal :tax_withheld, precision: 19, scale: 4
+ t.string :tax_strategy, null: false, default: "standard"
+ t.decimal :tax_rate, precision: 6, scale: 3, null: false, default: 19.0
+ t.boolean :requires_rate_review, null: false, default: false
+
+ t.string :product_code
+ t.string :inflation_provider
+
+ t.timestamps
+ end
+
+ add_index :bond_lots, [ :bond_id, :purchased_on ]
+ add_index :bond_lots, :subtype
+ add_index :bond_lots, :issue_date
+ add_index :bond_lots, :closed_on
+ add_index :bond_lots, [ :bond_id, :closed_on ]
+ add_index :bond_lots, :requires_rate_review
+ add_index :bond_lots, :product_code
+ add_index :bond_lots, :inflation_provider
+ add_index :bond_lots,
+ %i[auto_close_on_maturity maturity_date closed_on],
+ name: "index_bond_lots_on_settlement_eligibility"
+ add_index :bond_lots, :entry_id, unique: true, where: "entry_id IS NOT NULL"
+
+ # Database-level constraints for domain invariants
+ add_check_constraint :bond_lots, "amount > 0", name: "check_bond_lots_positive_amount"
+ add_check_constraint :bond_lots, "term_months > 0", name: "check_bond_lots_positive_term"
+ add_check_constraint :bond_lots, "maturity_date >= purchased_on", name: "check_bond_lots_maturity_after_purchase"
+ add_check_constraint :bond_lots, "subtype IS NOT NULL", name: "check_bond_lots_subtype_not_null"
+ add_check_constraint :bond_lots,
+ "subtype IN ('zero_coupon','fixed_coupon','inflation_linked','savings','other')",
+ name: "check_bond_lots_subtype_valid"
+ add_check_constraint :bond_lots,
+ "rate_type IS NULL OR rate_type IN ('fixed','variable')",
+ name: "check_bond_lots_rate_type_valid"
+ add_check_constraint :bond_lots,
+ "coupon_frequency IS NULL OR coupon_frequency IN ('monthly','quarterly','semi_annual','annual','at_maturity')",
+ name: "check_bond_lots_coupon_frequency_valid"
+ add_check_constraint :bond_lots,
+ "tax_strategy IN ('standard','reduced','exempt')",
+ name: "check_bond_lots_tax_strategy_valid"
+ add_check_constraint :bond_lots,
+ "(subtype IN ('inflation_linked')) OR (rate_type IS NOT NULL AND coupon_frequency IS NOT NULL)",
+ name: "check_bond_lots_non_inflation_rate_fields_present"
+ end
+end
diff --git a/db/migrate/20260331170000_add_gus_inflation_rates_and_auto_fetch_to_bond_lots.rb b/db/migrate/20260331170000_add_gus_inflation_rates_and_auto_fetch_to_bond_lots.rb
new file mode 100644
index 000000000..25c480c7f
--- /dev/null
+++ b/db/migrate/20260331170000_add_gus_inflation_rates_and_auto_fetch_to_bond_lots.rb
@@ -0,0 +1,14 @@
+class AddGusInflationRatesAndAutoFetchToBondLots < ActiveRecord::Migration[7.2]
+ def change
+ create_table :gus_inflation_rates, id: :uuid do |t|
+ t.integer :year, null: false
+ t.integer :month, null: false
+ t.decimal :rate_yoy, precision: 8, scale: 4, null: false
+ t.string :source, null: false, default: "sdp"
+
+ t.timestamps
+ end
+
+ add_index :gus_inflation_rates, %i[year month], unique: true
+ end
+end
diff --git a/db/migrate/20260407103000_create_inflation_rates.rb b/db/migrate/20260407103000_create_inflation_rates.rb
new file mode 100644
index 000000000..c7d548dce
--- /dev/null
+++ b/db/migrate/20260407103000_create_inflation_rates.rb
@@ -0,0 +1,14 @@
+class CreateInflationRates < ActiveRecord::Migration[7.2]
+ def change
+ create_table :inflation_rates, id: :uuid do |t|
+ t.string :source, null: false
+ t.integer :year, null: false
+ t.integer :month, null: false
+ t.decimal :rate_yoy, precision: 8, scale: 4, null: false
+
+ t.timestamps
+ end
+
+ add_index :inflation_rates, %i[source year month], unique: true
+ end
+end
diff --git a/db/migrate/20260407143000_add_month_range_constraints_to_inflation_tables.rb b/db/migrate/20260407143000_add_month_range_constraints_to_inflation_tables.rb
new file mode 100644
index 000000000..bd2421cb1
--- /dev/null
+++ b/db/migrate/20260407143000_add_month_range_constraints_to_inflation_tables.rb
@@ -0,0 +1,11 @@
+class AddMonthRangeConstraintsToInflationTables < ActiveRecord::Migration[7.2]
+ def change
+ add_check_constraint :gus_inflation_rates,
+ "month BETWEEN 1 AND 12",
+ name: "chk_gus_inflation_rates_month_range"
+
+ add_check_constraint :inflation_rates,
+ "month BETWEEN 1 AND 12",
+ name: "chk_inflation_rates_month_range"
+ end
+end
diff --git a/db/migrate/20260407151000_change_bond_lots_entry_fk_to_cascade.rb b/db/migrate/20260407151000_change_bond_lots_entry_fk_to_cascade.rb
new file mode 100644
index 000000000..6e5f91f6d
--- /dev/null
+++ b/db/migrate/20260407151000_change_bond_lots_entry_fk_to_cascade.rb
@@ -0,0 +1,11 @@
+class ChangeBondLotsEntryFkToCascade < ActiveRecord::Migration[7.2]
+ def up
+ remove_foreign_key :bond_lots, :entries
+ add_foreign_key :bond_lots, :entries, on_delete: :cascade
+ end
+
+ def down
+ remove_foreign_key :bond_lots, :entries
+ add_foreign_key :bond_lots, :entries, on_delete: :nullify
+ end
+end
diff --git a/db/migrate/20260407162000_add_holdings_snapshot_columns_to_accounts.rb b/db/migrate/20260407162000_add_holdings_snapshot_columns_to_accounts.rb
new file mode 100644
index 000000000..6c7f26111
--- /dev/null
+++ b/db/migrate/20260407162000_add_holdings_snapshot_columns_to_accounts.rb
@@ -0,0 +1,11 @@
+class AddHoldingsSnapshotColumnsToAccounts < ActiveRecord::Migration[7.2]
+ def up
+ add_column :accounts, :holdings_snapshot_data, :jsonb unless column_exists?(:accounts, :holdings_snapshot_data)
+ add_column :accounts, :holdings_snapshot_at, :datetime unless column_exists?(:accounts, :holdings_snapshot_at)
+ end
+
+ def down
+ remove_column :accounts, :holdings_snapshot_data if column_exists?(:accounts, :holdings_snapshot_data)
+ remove_column :accounts, :holdings_snapshot_at if column_exists?(:accounts, :holdings_snapshot_at)
+ end
+end
diff --git a/db/migrate/20260407170000_fix_bond_lots_non_inflation_check_constraint.rb b/db/migrate/20260407170000_fix_bond_lots_non_inflation_check_constraint.rb
new file mode 100644
index 000000000..b73c5657c
--- /dev/null
+++ b/db/migrate/20260407170000_fix_bond_lots_non_inflation_check_constraint.rb
@@ -0,0 +1,9 @@
+class FixBondLotsNonInflationCheckConstraint < ActiveRecord::Migration[7.2]
+ # No-op migration: the target check expression already matches
+ # 20260331120000_create_bond_lots.rb in this branch.
+ # Keeping this migration inert avoids unnecessary constraint drop/re-add locks.
+ def up; end
+
+ # No-op rollback for symmetry with the no-op up.
+ def down; end
+end
diff --git a/db/migrate/20260407183000_add_bond_lot_enum_check_constraints.rb b/db/migrate/20260407183000_add_bond_lot_enum_check_constraints.rb
new file mode 100644
index 000000000..1bd62e316
--- /dev/null
+++ b/db/migrate/20260407183000_add_bond_lot_enum_check_constraints.rb
@@ -0,0 +1,31 @@
+class AddBondLotEnumCheckConstraints < ActiveRecord::Migration[7.2]
+ def up
+ unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_subtype_valid")
+ add_check_constraint :bond_lots,
+ "subtype IN ('zero_coupon','fixed_coupon','inflation_linked','savings','other')",
+ name: "check_bond_lots_subtype_valid"
+ end
+
+ unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_rate_type_valid")
+ add_check_constraint :bond_lots,
+ "rate_type IS NULL OR rate_type IN ('fixed','variable')",
+ name: "check_bond_lots_rate_type_valid"
+ end
+
+ unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_coupon_frequency_valid")
+ add_check_constraint :bond_lots,
+ "coupon_frequency IS NULL OR coupon_frequency IN ('monthly','quarterly','semi_annual','annual','at_maturity')",
+ name: "check_bond_lots_coupon_frequency_valid"
+ end
+
+ unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_tax_strategy_valid")
+ add_check_constraint :bond_lots,
+ "tax_strategy IN ('standard','reduced','exempt')",
+ name: "check_bond_lots_tax_strategy_valid"
+ end
+ end
+
+ def down
+ # No-op: constraints already exist in CreateBondLots baseline.
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1c7ecd693..94c3e513f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -63,6 +63,8 @@
t.string "institution_name"
t.string "institution_domain"
t.text "notes"
+ t.jsonb "holdings_snapshot_data"
+ t.datetime "holdings_snapshot_at"
t.uuid "owner_id"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
@@ -214,6 +216,75 @@
t.index ["status"], name: "index_binance_items_on_status"
end
+ create_table "bond_lots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "bond_id", null: false
+ t.date "purchased_on", null: false
+ t.decimal "amount", precision: 19, scale: 4, null: false
+ t.integer "term_months", null: false
+ t.date "maturity_date", null: false
+ t.decimal "interest_rate", precision: 10, scale: 3
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "subtype", default: "other", null: false
+ t.string "rate_type"
+ t.string "coupon_frequency"
+ t.uuid "entry_id"
+ t.date "issue_date"
+ t.decimal "first_period_rate", precision: 10, scale: 3
+ t.decimal "inflation_margin", precision: 10, scale: 3
+ t.decimal "inflation_rate_assumption", precision: 10, scale: 3
+ t.integer "cpi_lag_months"
+ t.decimal "early_redemption_fee", precision: 19, scale: 4
+ t.decimal "units", precision: 12, scale: 2
+ t.decimal "nominal_per_unit", precision: 19, scale: 4
+ t.boolean "auto_fetch_inflation", default: true, null: false
+ t.boolean "auto_close_on_maturity", default: true, null: false
+ t.date "closed_on"
+ t.decimal "settlement_amount", precision: 19, scale: 4
+ t.decimal "tax_withheld", precision: 19, scale: 4
+ t.string "tax_strategy", default: "standard", null: false
+ t.decimal "tax_rate", precision: 6, scale: 3, default: "19.0", null: false
+ t.boolean "requires_rate_review", default: false, null: false
+ t.string "product_code"
+ t.string "inflation_provider"
+ t.index ["auto_close_on_maturity", "maturity_date", "closed_on"], name: "index_bond_lots_on_settlement_eligibility"
+ t.index ["bond_id", "closed_on"], name: "index_bond_lots_on_bond_id_and_closed_on"
+ t.index ["bond_id", "purchased_on"], name: "index_bond_lots_on_bond_id_and_purchased_on"
+ t.index ["bond_id"], name: "index_bond_lots_on_bond_id"
+ t.index ["closed_on"], name: "index_bond_lots_on_closed_on"
+ t.index ["entry_id"], name: "index_bond_lots_on_entry_id", unique: true, where: "(entry_id IS NOT NULL)"
+ t.index ["inflation_provider"], name: "index_bond_lots_on_inflation_provider"
+ t.index ["issue_date"], name: "index_bond_lots_on_issue_date"
+ t.index ["product_code"], name: "index_bond_lots_on_product_code"
+ t.index ["requires_rate_review"], name: "index_bond_lots_on_requires_rate_review"
+ t.index ["subtype"], name: "index_bond_lots_on_subtype"
+ t.check_constraint "amount > 0::numeric", name: "check_bond_lots_positive_amount"
+ t.check_constraint "coupon_frequency IS NULL OR (coupon_frequency::text = ANY (ARRAY['monthly'::character varying, 'quarterly'::character varying, 'semi_annual'::character varying, 'annual'::character varying, 'at_maturity'::character varying]::text[]))", name: "check_bond_lots_coupon_frequency_valid"
+ t.check_constraint "maturity_date >= purchased_on", name: "check_bond_lots_maturity_after_purchase"
+ t.check_constraint "rate_type IS NULL OR (rate_type::text = ANY (ARRAY['fixed'::character varying, 'variable'::character varying]::text[]))", name: "check_bond_lots_rate_type_valid"
+ t.check_constraint "subtype IS NOT NULL", name: "check_bond_lots_subtype_not_null"
+ t.check_constraint "subtype::text = 'inflation_linked'::text OR rate_type IS NOT NULL AND coupon_frequency IS NOT NULL", name: "check_bond_lots_non_inflation_rate_fields_present"
+ t.check_constraint "subtype::text = ANY (ARRAY['zero_coupon'::character varying, 'fixed_coupon'::character varying, 'inflation_linked'::character varying, 'savings'::character varying, 'other'::character varying]::text[])", name: "check_bond_lots_subtype_valid"
+ t.check_constraint "tax_strategy::text = ANY (ARRAY['standard'::character varying, 'reduced'::character varying, 'exempt'::character varying]::text[])", name: "check_bond_lots_tax_strategy_valid"
+ t.check_constraint "term_months > 0", name: "check_bond_lots_positive_term"
+ end
+
+ create_table "bonds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.decimal "initial_balance", precision: 19, scale: 4
+ t.decimal "interest_rate", precision: 10, scale: 3
+ t.integer "term_months"
+ t.string "rate_type"
+ t.date "maturity_date"
+ t.string "coupon_frequency"
+ t.string "subtype"
+ t.jsonb "locked_attributes", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "tax_wrapper", default: "none", null: false
+ t.boolean "auto_buy_new_issues", default: false, null: false
+ t.index ["tax_wrapper"], name: "index_bonds_on_tax_wrapper"
+ end
+
create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "budget_id", null: false
t.uuid "category_id", null: false
@@ -438,9 +509,9 @@
t.jsonb "aspsp_required_psu_headers", default: []
t.integer "aspsp_maximum_consent_validity"
t.string "aspsp_auth_approach"
+ t.string "psu_type"
t.jsonb "aspsp_psu_types", default: []
t.string "last_psu_ip"
- t.string "psu_type"
t.index ["family_id"], name: "index_enable_banking_items_on_family_id"
t.index ["status"], name: "index_enable_banking_items_on_status"
end
@@ -633,6 +704,17 @@
t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id"
end
+ create_table "gus_inflation_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.integer "year", null: false
+ t.integer "month", null: false
+ t.decimal "rate_yoy", precision: 8, scale: 4, null: false
+ t.string "source", default: "sdp", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["year", "month"], name: "index_gus_inflation_rates_on_year_and_month", unique: true
+ t.check_constraint "month >= 1 AND month <= 12", name: "chk_gus_inflation_rates_month_range"
+ end
+
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -814,6 +896,17 @@
t.index ["status"], name: "index_indexa_capital_items_on_status"
end
+ create_table "inflation_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "source", null: false
+ t.integer "year", null: false
+ t.integer "month", null: false
+ t.decimal "rate_yoy", precision: 8, scale: 4, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["source", "year", "month"], name: "index_inflation_rates_on_source_and_year_and_month", unique: true
+ t.check_constraint "month >= 1 AND month <= 12", name: "chk_inflation_rates_month_range"
+ end
+
create_table "investments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -1245,7 +1338,7 @@
t.index ["kind"], name: "index_securities_on_kind"
t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason"
t.index ["price_provider"], name: "index_securities_on_price_provider"
- t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind"
+ t.check_constraint "kind::text = ANY (ARRAY['standard'::text, 'cash'::text])", name: "chk_securities_kind"
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1403,7 +1496,6 @@
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
- t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
end
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1420,8 +1512,8 @@
t.datetime "sync_start_date"
t.jsonb "raw_payload"
t.jsonb "raw_institution_payload"
- t.string "user_id", null: false
- t.string "access_key", null: false
+ t.string "user_id"
+ t.string "access_key"
t.string "base_url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -1647,6 +1739,8 @@
add_foreign_key "balances", "accounts", on_delete: :cascade
add_foreign_key "binance_accounts", "binance_items"
add_foreign_key "binance_items", "families"
+ add_foreign_key "bond_lots", "bonds", on_delete: :cascade
+ add_foreign_key "bond_lots", "entries", on_delete: :cascade
add_foreign_key "budget_categories", "budgets"
add_foreign_key "budget_categories", "categories"
add_foreign_key "budgets", "families"
diff --git a/docs/hosting/docker.md b/docs/hosting/docker.md
index 96ec28065..d4d24ac8b 100644
--- a/docs/hosting/docker.md
+++ b/docs/hosting/docker.md
@@ -95,6 +95,21 @@ SECRET_KEY_BASE="replacemewiththegeneratedstringfromthepriorstep"
POSTGRES_PASSWORD="replacemewithyourdesireddatabasepassword"
```
+#### Optional: GUS inflation import for EOD/ROD bonds
+
+If you use inflation-linked bonds, you can enable automatic CPI imports from GUS SDP.
+
+```txt
+# default is off
+INFLATION_IMPORT_ENABLED=false
+
+# optional API key for higher limits (anonymous mode works without this)
+GUS_SDP_API_KEY=
+```
+
+When enabled, you can also trigger a manual historical import from:
+Settings > Self-Hosting > Inflation.
+
#### Using HTTPS
Assuming you want to access your instance from the internet, you should have secured your URL address with an SSL certificate.
diff --git a/docs/localization/pl-localization-prep.md b/docs/localization/pl-localization-prep.md
new file mode 100644
index 000000000..0b49b701b
--- /dev/null
+++ b/docs/localization/pl-localization-prep.md
@@ -0,0 +1,52 @@
+# Polish Localization Preparation
+
+## Current status
+
+- Bond and CPI provider features are implemented and verified by targeted tests.
+- UI tab split for Bond is implemented as: Activity, Positions, Closed.
+- Obsolete Bond holdings tab partial was removed.
+- Polish locale files for Bond/views are present and actively maintained.
+
+## Verification performed
+
+- Bond and CPI provider suite:
+ - test/models/bond_test.rb
+ - test/models/bond_lot_test.rb
+ - test/models/gus_inflation_rate_test.rb
+ - test/jobs/import_inflation_rates_job_test.rb
+ - test/jobs/settle_matured_bond_lots_job_test.rb
+ - test/controllers/bonds_controller_test.rb
+ - test/controllers/bond_lots_controller_test.rb
+ - test/controllers/accounts_controller_test.rb
+ - test/controllers/pages_controller_test.rb
+ - test/controllers/settings/hostings_controller_test.rb
+ - test/controllers/transactions/bulk_deletions_controller_test.rb
+
+## PL scope for next phase
+
+Polish localization baseline is no longer limited to `config/locales/defaults/pl.yml`.
+Bond locale files already exist (including `config/locales/views/bonds/pl.yml`) and should be iterated, not created from scratch.
+
+For the next PL phase, prioritize incremental coverage in:
+
+1. Dashboard additions
+- config/locales/views/pages/pl.yml (add/adjust bond summary and rate review notice)
+
+2. Self-hosting settings additions
+- config/locales/views/settings/hostings/pl.yml (add/adjust GUS CPI settings labels/messages)
+
+3. Accounts additions
+- config/locales/views/accounts/pl.yml (review account-type labels and new bond-related strings)
+
+## Recommended execution order
+
+1. Diff PL vs EN locale trees and translate only missing or changed keys.
+2. Keep Bond translations aligned with current subtype/product terminology.
+3. Run focused smoke checks in Bond views and settings pages.
+4. Run targeted controller/model tests for Bond and hostings.
+5. Open separate PR for PL localization only.
+
+## PR split recommendation
+
+- PR 1: Bond and GUS CPI feature implementation.
+- PR 2: Polish localization.
diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb
index cda1e81e7..d1988e9f2 100644
--- a/test/controllers/accounts_controller_test.rb
+++ b/test/controllers/accounts_controller_test.rb
@@ -19,6 +19,47 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "shows bond positions tab without strict locals errors" do
+ bond_account = accounts(:bond)
+
+ get account_url(bond_account, tab: "positions")
+
+ assert_response :success
+ assert_includes @response.body, bond_account.name
+ end
+
+ test "bond activity new action opens bond purchase form" do
+ bond_account = accounts(:bond)
+
+ get account_url(bond_account, tab: "activity")
+
+ assert_response :success
+ assert_includes @response.body, "New activity"
+ assert_includes @response.body, new_bond_lot_path(account_id: bond_account.id)
+ end
+
+ test "opening bond account does not fail when matured lots exist" do
+ bond_account = accounts(:bond)
+ lot = BondLot.create!(
+ bond: bond_account.bond,
+ purchased_on: Date.current - 2.years,
+ amount: 1000,
+ subtype: "other_bond",
+ term_months: 12,
+ interest_rate: 10,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ auto_close_on_maturity: true,
+ tax_strategy: "standard",
+ tax_rate: 19
+ )
+
+ get account_url(bond_account, tab: "positions")
+
+ assert_response :success
+ assert_not_nil lot.reload
+ end
+
test "account activity marks trade amounts as privacy-sensitive" do
trade_entry = entries(:trade)
expected_amount = ApplicationController.helpers.format_money(-trade_entry.amount_money)
diff --git a/test/controllers/bond_lots_controller_test.rb b/test/controllers/bond_lots_controller_test.rb
new file mode 100644
index 000000000..5a423ba98
--- /dev/null
+++ b/test/controllers/bond_lots_controller_test.rb
@@ -0,0 +1,275 @@
+require "test_helper"
+
+class BondLotsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in @user = users(:family_admin)
+ @account = accounts(:bond)
+ end
+
+ test "creates a purchase lot and calculates maturity date" do
+ purchase_date = Date.new(2026, 3, 1)
+
+ assert_difference [ "BondLot.count", "Entry.count", "Transaction.count" ], 1 do
+ assert_enqueued_jobs 1, only: SyncJob do
+ post bond_lots_path, params: {
+ account_id: @account.id,
+ bond_lot: {
+ purchased_on: purchase_date,
+ amount: 2500,
+ term_months: 4,
+ interest_rate: 4.75,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ }
+ }
+ end
+ end
+
+ lot = BondLot.order(:created_at).last
+ assert_equal @account.bond, lot.bond
+ assert_equal Date.new(2026, 7, 1), lot.maturity_date
+ assert_not_nil lot.entry
+ assert_equal purchase_date, lot.entry.date
+ assert_equal 2500.to_d, lot.entry.amount
+ assert_redirected_to account_path(@account)
+ end
+
+ test "removes a purchase lot" do
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.current,
+ amount: 1000,
+ term_months: 6,
+ interest_rate: 4.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ entry: @account.entries.create!(
+ date: Date.current,
+ name: "Bond purchase",
+ amount: 1000,
+ currency: @account.currency,
+ entryable: Transaction.new(kind: :funds_movement)
+ )
+ )
+
+ assert_difference [ "BondLot.count", "Entry.count" ], -1 do
+ assert_enqueued_jobs 1, only: SyncJob do
+ delete bond_lot_path(lot)
+ end
+ end
+
+ assert_redirected_to account_path(@account)
+ end
+
+ test "renders edit form for a purchase lot" do
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 1, 1),
+ amount: 500,
+ term_months: 6,
+ interest_rate: 3.5,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ get edit_bond_lot_path(lot)
+
+ assert_response :success
+ end
+
+ test "renders drawer show for a purchase lot" do
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 1, 1),
+ amount: 500,
+ term_months: 6,
+ interest_rate: 3.5,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ get bond_lot_path(lot)
+
+ assert_response :success
+ assert_includes @response.body, "Delete"
+ end
+
+ test "updates a purchase lot and its entry" do
+ entry_record = @account.entries.create!(
+ date: Date.new(2026, 2, 1),
+ name: "Bond purchase: Treasury Bill",
+ amount: 1000,
+ currency: @account.currency,
+ entryable: Transaction.new(kind: :funds_movement, extra: {})
+ )
+
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ term_months: 12,
+ interest_rate: 4.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ entry: entry_record
+ )
+
+ assert_enqueued_jobs 1, only: SyncJob do
+ patch bond_lot_path(lot), params: {
+ bond_lot: {
+ purchased_on: Date.new(2026, 2, 15),
+ amount: 1200,
+ issue_date: Date.new(2026, 2, 1),
+ units: 12,
+ nominal_per_unit: 100,
+ interest_rate: 4.5,
+ subtype: "rod",
+ rate_type: "variable",
+ coupon_frequency: "at_maturity",
+ first_period_rate: 6.0,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 4.0,
+ cpi_lag_months: 2
+ }
+ }
+ end
+
+ assert_redirected_to account_path(@account)
+
+ lot.reload
+ assert_equal 1200.to_d, lot.amount
+ assert_equal 4.5.to_d, lot.interest_rate
+ assert_equal "inflation_linked", lot.subtype
+ assert_equal "at_maturity", lot.coupon_frequency
+ assert_equal Date.new(2026, 2, 15), lot.purchased_on
+
+ entry_record.reload
+ assert_equal Date.new(2026, 2, 15), entry_record.date
+ assert_equal 1200.to_d, entry_record.amount
+ end
+
+ test "update returns unprocessable entity for invalid params" do
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 1, 1),
+ amount: 500,
+ term_months: 6,
+ interest_rate: 3.5,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ patch bond_lot_path(lot), params: {
+ bond_lot: { amount: -1 }
+ }
+
+ assert_response :unprocessable_entity
+ end
+
+ test "update returns drawer show on invalid params for drawer frame requests" do
+ lot = @account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 1, 1),
+ amount: 500,
+ term_months: 6,
+ interest_rate: 3.5,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ patch bond_lot_path(lot),
+ params: { bond_lot: { amount: -1 } },
+ headers: { "Turbo-Frame" => "drawer" }
+
+ assert_response :unprocessable_entity
+ assert_select "turbo-frame#drawer"
+ end
+
+ test "creates EOD purchase without term months input" do
+ purchase_date = Date.new(2026, 4, 1)
+
+ assert_difference [ "BondLot.count", "Entry.count", "Transaction.count" ], 1 do
+ assert_enqueued_jobs 1, only: SyncJob do
+ post bond_lots_path, params: {
+ account_id: @account.id,
+ bond_lot: {
+ purchased_on: purchase_date,
+ issue_date: purchase_date,
+ amount: 1000,
+ units: 10,
+ nominal_per_unit: 100,
+ subtype: "eod",
+ rate_type: "variable",
+ coupon_frequency: "at_maturity",
+ first_period_rate: 6.5,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 4.0,
+ cpi_lag_months: 2
+ }
+ }
+ end
+ end
+
+ lot = BondLot.order(:created_at).last
+ assert_equal 120, lot.term_months
+ assert_equal Date.new(2036, 4, 1), lot.maturity_date
+ assert_redirected_to account_path(@account)
+ end
+
+ test "creates lot with product preset and normalizes rate and coupon fields" do
+ purchase_date = Date.new(2026, 5, 1)
+
+ assert_difference [ "BondLot.count", "Entry.count", "Transaction.count" ], 1 do
+ assert_enqueued_jobs 1, only: SyncJob do
+ post bond_lots_path, params: {
+ account_id: @account.id,
+ bond_lot: {
+ purchased_on: purchase_date,
+ amount: 1500,
+ product_code: "us_t_note_2y",
+ subtype: "other",
+ term_months: 6,
+ interest_rate: 4.2,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ }
+ }
+ end
+ end
+
+ lot = BondLot.order(:created_at).last
+ assert_equal "fixed_coupon", lot.subtype
+ assert_equal "fixed", lot.rate_type
+ assert_equal "semi_annual", lot.coupon_frequency
+ assert_equal 24, lot.term_months
+ assert_redirected_to account_path(@account)
+ end
+
+ test "ignores incoming tax params for tax-exempt wrapper" do
+ @account.bond.update!(tax_wrapper: "ike")
+
+ assert_difference [ "BondLot.count", "Entry.count", "Transaction.count" ], 1 do
+ assert_enqueued_jobs 1, only: SyncJob do
+ post bond_lots_path, params: {
+ account_id: @account.id,
+ bond_lot: {
+ purchased_on: Date.new(2026, 6, 1),
+ amount: 1000,
+ term_months: 12,
+ interest_rate: 4.0,
+ subtype: "other",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ tax_strategy: "standard",
+ tax_rate: 19
+ }
+ }
+ end
+ end
+
+ lot = BondLot.order(:created_at).last
+ assert_equal "exempt", lot.tax_strategy
+ assert_equal 0.to_d, lot.tax_rate.to_d
+ end
+end
diff --git a/test/controllers/bonds_controller_test.rb b/test/controllers/bonds_controller_test.rb
new file mode 100644
index 000000000..1f16577b8
--- /dev/null
+++ b/test/controllers/bonds_controller_test.rb
@@ -0,0 +1,86 @@
+require "test_helper"
+
+class BondsControllerTest < ActionDispatch::IntegrationTest
+ include AccountableResourceInterfaceTest
+
+ setup do
+ sign_in @user = users(:family_admin)
+ @account = accounts(:bond)
+ end
+
+ test "creates with bond details" do
+ assert_difference -> { Account.count } => 1,
+ -> { Bond.count } => 1,
+ -> { Valuation.count } => 1,
+ -> { Entry.count } => 1 do
+ post bonds_path, params: {
+ account: {
+ name: "New Treasury Bill",
+ balance: 20000,
+ currency: "USD",
+ institution_name: "TreasuryDirect",
+ institution_domain: "treasurydirect.gov",
+ notes: "4-week bill",
+ accountable_type: "Bond",
+ accountable_attributes: {
+ initial_balance: 20000,
+ tax_wrapper: "ike",
+ auto_buy_new_issues: true
+ }
+ }
+ }
+ end
+
+ created_account = Account.order(:created_at).last
+
+ assert_equal "New Treasury Bill", created_account.name
+ assert_equal 20000, created_account.balance
+ assert_equal "USD", created_account.currency
+ assert_equal "TreasuryDirect", created_account[:institution_name]
+ assert_equal "treasurydirect.gov", created_account[:institution_domain]
+ assert_equal "4-week bill", created_account[:notes]
+ assert_equal 20000, created_account.accountable.initial_balance
+ assert_equal "ike", created_account.accountable.tax_wrapper
+ assert created_account.accountable.auto_buy_new_issues?
+
+ assert_redirected_to created_account
+ assert_equal "Bond account created", flash[:notice]
+ assert_enqueued_with(job: SyncJob)
+ end
+
+ test "updates with bond details" do
+ assert_no_difference [ "Account.count", "Bond.count" ] do
+ patch bond_path(@account), params: {
+ account: {
+ name: "Updated Bond",
+ balance: 10000,
+ currency: "USD",
+ institution_name: "Broker",
+ institution_domain: "broker.example",
+ notes: "Updated bond notes",
+ accountable_type: "Bond",
+ accountable_attributes: {
+ id: @account.accountable_id,
+ initial_balance: 19000,
+ tax_wrapper: "ikze",
+ auto_buy_new_issues: true
+ }
+ }
+ }
+ end
+
+ @account.reload
+
+ assert_equal "Updated Bond", @account.name
+ assert_equal 10000, @account.balance
+ assert_equal "Broker", @account[:institution_name]
+ assert_equal "broker.example", @account[:institution_domain]
+ assert_equal "Updated bond notes", @account[:notes]
+ assert_equal 19000, @account.accountable.initial_balance
+ assert_equal "ikze", @account.accountable.tax_wrapper
+ assert @account.accountable.auto_buy_new_issues?
+
+ assert_redirected_to @account
+ assert_equal "Bond account updated", flash[:notice]
+ end
+end
diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb
index 73c39f5a1..23a10b180 100644
--- a/test/controllers/pages_controller_test.rb
+++ b/test/controllers/pages_controller_test.rb
@@ -12,6 +12,31 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
test "dashboard" do
get root_path
assert_response :ok
+ assert_select "#bond-summary"
+ assert_no_match(/Principal, \{one:/, @response.body)
+ end
+
+ test "dashboard shows bond rate review notice once per session" do
+ accounts(:bond).bond.bond_lots.create!(
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "rod",
+ auto_fetch_inflation: false,
+ auto_close_on_maturity: true,
+ issue_date: Date.current,
+ units: 10,
+ nominal_per_unit: 100,
+ cpi_lag_months: 2,
+ requires_rate_review: true
+ )
+
+ get root_path
+ assert_response :ok
+ assert_match(/1 bond lot needs updated issue rate/, flash[:notice])
+
+ get root_path
+ assert_response :ok
+ assert_nil flash[:notice]
end
test "intro page requires guest role" do
diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb
index 4ebe20f87..87ef3c094 100644
--- a/test/controllers/settings/hostings_controller_test.rb
+++ b/test/controllers/settings/hostings_controller_test.rb
@@ -321,7 +321,6 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "fake_provider", "hacked" ] } }
assert_redirected_to settings_hosting_url
- # Only valid providers are stored
enabled = Setting.enabled_securities_providers
assert_includes enabled, "twelve_data"
refute_includes enabled, "fake_provider"
@@ -335,10 +334,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
with_self_hosting do
security = Security.create!(ticker: "CSPX", exchange_operating_mic: "XLON", price_provider: "tiingo", offline: false)
- # First enable tiingo
Setting.securities_providers = "twelve_data,tiingo"
- # Then remove tiingo
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data" ] } }
security.reload
@@ -356,10 +353,8 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
price_provider: "tiingo", offline: true, offline_reason: "provider_disabled"
)
- # Start without tiingo
Setting.securities_providers = "twelve_data"
- # Re-add tiingo
patch settings_hosting_url, params: { setting: { securities_providers: [ "twelve_data", "tiingo" ] } }
security.reload
diff --git a/test/controllers/transactions/bulk_deletions_controller_test.rb b/test/controllers/transactions/bulk_deletions_controller_test.rb
index aa981d6c4..d02c4b425 100644
--- a/test/controllers/transactions/bulk_deletions_controller_test.rb
+++ b/test/controllers/transactions/bulk_deletions_controller_test.rb
@@ -21,4 +21,36 @@ class Transactions::BulkDeletionsControllerTest < ActionDispatch::IntegrationTes
assert_redirected_to transactions_url
assert_equal "#{delete_count} transactions deleted", flash[:notice]
end
+
+ test "bulk delete also removes linked bond lots" do
+ bond_account = accounts(:bond)
+ entry = bond_account.entries.create!(
+ name: "Bond purchase",
+ date: Date.current,
+ amount: 1500,
+ currency: bond_account.currency,
+ entryable: Transaction.new(kind: :funds_movement)
+ )
+ lot = bond_account.bond.bond_lots.create!(
+ purchased_on: Date.current,
+ amount: 1500,
+ term_months: 12,
+ interest_rate: 5.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ entry: entry
+ )
+
+ assert_difference([ "Entry.count", "Transaction.count", "BondLot.count" ], -1) do
+ post transactions_bulk_deletion_url, params: {
+ bulk_delete: {
+ entry_ids: [ entry.id ]
+ }
+ }
+ end
+
+ assert_not BondLot.exists?(lot.id)
+ assert_redirected_to transactions_url
+ end
end
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index e2554e5dc..f2ef8ae26 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -70,6 +70,16 @@ loan:
accountable: one
status: active
+bond:
+ family: dylan_family
+ owner: family_admin
+ name: US T-Bill
+ balance: 10000
+ currency: USD
+ accountable_type: Bond
+ accountable: one
+ status: active
+
property:
family: dylan_family
owner: family_admin
diff --git a/test/fixtures/bond_lots.yml b/test/fixtures/bond_lots.yml
new file mode 100644
index 000000000..9cbff609b
--- /dev/null
+++ b/test/fixtures/bond_lots.yml
@@ -0,0 +1,21 @@
+one:
+ bond: one
+ purchased_on: <%= Date.current - 3.months %>
+ amount: 10000
+ term_months: 12
+ maturity_date: <%= Date.current + 9.months %>
+ interest_rate: 4.25
+ subtype: other
+ rate_type: fixed
+ coupon_frequency: at_maturity
+
+two:
+ bond: one
+ purchased_on: <%= Date.current - 1.month %>
+ amount: 5000
+ term_months: 6
+ maturity_date: <%= Date.current + 5.months %>
+ interest_rate: 4.15
+ subtype: other
+ rate_type: fixed
+ coupon_frequency: semi_annual
\ No newline at end of file
diff --git a/test/fixtures/bonds.yml b/test/fixtures/bonds.yml
new file mode 100644
index 000000000..5e2e9b7b2
--- /dev/null
+++ b/test/fixtures/bonds.yml
@@ -0,0 +1,8 @@
+one:
+ interest_rate: 4.25
+ term_months: 12
+ rate_type: fixed
+ initial_balance: 10000
+ maturity_date: <%= 1.year.from_now.to_date %>
+ coupon_frequency: at_maturity
+ subtype: other
diff --git a/test/jobs/settle_matured_bond_lots_job_test.rb b/test/jobs/settle_matured_bond_lots_job_test.rb
new file mode 100644
index 000000000..9de19b1aa
--- /dev/null
+++ b/test/jobs/settle_matured_bond_lots_job_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class SettleMaturedBondLotsJobTest < ActiveJob::TestCase
+ test "settles matured lots with auto-close enabled" do
+ lot = BondLot.create!(
+ bond: accounts(:bond).bond,
+ purchased_on: Date.current - 2.years,
+ amount: 1000,
+ subtype: "other_bond",
+ term_months: 12,
+ interest_rate: 10,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ auto_close_on_maturity: true,
+ tax_strategy: "standard",
+ tax_rate: 19
+ )
+
+ assert_nil lot.closed_on
+
+ SettleMaturedBondLotsJob.perform_now
+
+ assert_not_nil lot.reload.closed_on
+ end
+end
diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb
index c3ba12ba4..e2c1dd8c9 100644
--- a/test/models/balance/reverse_calculator_test.rb
+++ b/test/models/balance/reverse_calculator_test.rb
@@ -440,6 +440,41 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
)
end
+ test "bond lot purchase is treated as non-cash flow, not market change" do
+ account = create_account_with_ledger(
+ account: { type: Bond, balance: 1000, cash_balance: 0, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 1000 },
+ { type: "opening_anchor", date: 2.days.ago.to_date, balance: 0 },
+ {
+ type: "transaction",
+ date: 1.day.ago.to_date,
+ amount: 1000,
+ kind: "funds_movement",
+ extra: { "bond_lot_id" => "test-lot-1" }
+ }
+ ]
+ )
+
+ account.bond.bond_lots.create!(
+ purchased_on: 1.day.ago.to_date,
+ amount: 1000,
+ subtype: "other_bond",
+ term_months: 12,
+ maturity_date: 1.year.from_now.to_date,
+ interest_rate: 5,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ calculated = Balance::ReverseCalculator.new(account).calculate
+ balance = calculated.find { |b| b.date == 1.day.ago.to_date }
+
+ assert_equal 1000, balance.cash_outflows
+ assert_equal 1000, balance.non_cash_inflows
+ assert_equal 0, balance.net_market_flows
+ end
+
test "uses provider reported holdings and cash value on current day" do
# Implied holdings value of $1,000 from provider
account = create_account_with_ledger(
diff --git a/test/models/bond/inflation_provider_test.rb b/test/models/bond/inflation_provider_test.rb
new file mode 100644
index 000000000..28cc6556d
--- /dev/null
+++ b/test/models/bond/inflation_provider_test.rb
@@ -0,0 +1,165 @@
+require "test_helper"
+
+class BondInflationProviderTest < ActiveSupport::TestCase
+ test "default_provider_for derives provider from product code market" do
+ assert_equal "gus_sdp", Bond::InflationProvider.default_provider_for(product_code: "pl_eod")
+ assert_equal "us_bls", Bond::InflationProvider.default_provider_for(product_code: "us_tips_10y")
+ assert_equal "es_ine", Bond::InflationProvider.default_provider_for(product_code: "es_letra_3m")
+ end
+
+ test "default_provider_for falls back to locale when product code is absent" do
+ assert_equal "gus_sdp", Bond::InflationProvider.default_provider_for(locale: "pl")
+ assert_equal "us_bls", Bond::InflationProvider.default_provider_for(locale: "en-US")
+ assert_equal "es_ine", Bond::InflationProvider.default_provider_for(locale: "es")
+ end
+
+ test "default_provider_for falls back to gus_sdp for unknown locale" do
+ assert_equal "gus_sdp", Bond::InflationProvider.default_provider_for(locale: "de")
+ end
+
+ test "record_for_date reads from GUS storage for gus_sdp provider" do
+ GusInflationRate.create!(year: 2025, month: 1, rate_yoy: 105.2, source: "sdp")
+
+ record = Bond::InflationProvider.record_for_date(
+ provider: "gus_sdp",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2
+ )
+
+ assert_not_nil record
+ assert_equal 2025, record.year
+ assert_equal 1, record.month
+ assert_equal 105.2.to_d, record.rate_yoy
+ end
+
+ test "record_for_date attempts GUS on-demand import when allow_import is true" do
+ target_date = Date.new(2025, 3, 10)
+
+ Bond::InflationProvider.expects(:automatic_import_enabled?).with("gus_sdp").returns(true)
+
+ GusInflationRate.expects(:for_date).with(date: target_date, lag_months: 2).twice.returns(nil)
+ GusInflationRate.expects(:import_year!).with(year: 2025).once
+
+ record = Bond::InflationProvider.record_for_date(
+ provider: "gus_sdp",
+ date: target_date,
+ lag_months: 2,
+ allow_import: true
+ )
+
+ assert_nil record
+ end
+
+ test "record_for_date reads from provider adapter for non-gus providers" do
+ fake_adapter = mock("adapter")
+ fake_adapter.expects(:fetch_cpi_yoy_for_year).with(year: 2025).returns(
+ Provider::Response.new(
+ success?: true,
+ data: [ { month: 1, rate_yoy: 106.4.to_d } ],
+ error: nil
+ )
+ ).once
+
+ provider_klass = mock("provider_klass")
+ provider_klass.stubs(:new).returns(fake_adapter)
+ Bond::InflationProvider.stubs(:provider_class).with("us_bls").returns(provider_klass)
+
+ record = Bond::InflationProvider.record_for_date(
+ provider: "us_bls",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2
+ )
+
+ # Second call should use persisted data and avoid extra provider call.
+ second_record = Bond::InflationProvider.record_for_date(
+ provider: "us_bls",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2
+ )
+
+ assert_not_nil record
+ assert_equal 2025, record.year
+ assert_equal 1, record.month
+ assert_equal 106.4.to_d, record.rate_yoy
+ assert_not_nil second_record
+ assert_equal 106.4.to_d, second_record.rate_yoy
+ assert_equal 1, InflationRate.where(source: "us_bls", year: 2025, month: 1).count
+ end
+
+ test "record_for_date uses self-hosted settings to configure us_bls provider" do
+ fake_adapter = mock("adapter")
+ fake_adapter.expects(:fetch_cpi_yoy_for_year).with(year: 2025).returns(
+ Provider::Response.new(success?: true, data: [ { month: 1, rate_yoy: 104.0.to_d } ], error: nil)
+ ).once
+
+ provider_klass = mock("provider_klass")
+ provider_klass.expects(:new).with(base_url: "https://example-bsl.test", series_id: "SERIES_123").returns(fake_adapter)
+ Bond::InflationProvider.stubs(:provider_class).with("us_bls").returns(provider_klass)
+
+ old_base_url = Setting.us_bls_cpi_base_url
+ old_series_id = Setting.us_bls_cpi_series_id
+ Setting.us_bls_cpi_base_url = "https://example-bsl.test"
+ Setting.us_bls_cpi_series_id = "SERIES_123"
+
+ begin
+ record = with_env_overrides("US_BLS_CPI_BASE_URL" => nil, "US_BLS_CPI_SERIES_ID" => nil) do
+ Bond::InflationProvider.record_for_date(
+ provider: "us_bls",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2
+ )
+ end
+
+ assert_not_nil record
+ assert_equal 104.0.to_d, record.rate_yoy
+ ensure
+ Setting.us_bls_cpi_base_url = old_base_url
+ Setting.us_bls_cpi_series_id = old_series_id
+ end
+ end
+
+ test "record_for_date with allow_import false reads only persisted non-gus data" do
+ InflationRate.create!(source: "us_bls", year: 2025, month: 1, rate_yoy: 105.7)
+
+ Bond::InflationProvider.expects(:provider_class).never
+
+ record = Bond::InflationProvider.record_for_date(
+ provider: "us_bls",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2,
+ allow_import: false
+ )
+
+ assert_not_nil record
+ assert_equal 105.7.to_d, record.rate_yoy
+
+ missing = Bond::InflationProvider.record_for_date(
+ provider: "es_ine",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2,
+ allow_import: false
+ )
+
+ assert_nil missing
+ end
+
+ test "record_for_date does not attempt ES import when series id is missing" do
+ old_series_id = Setting.es_ine_cpi_series_id
+ Setting.es_ine_cpi_series_id = nil
+
+ Bond::InflationProvider.expects(:provider_class).never
+
+ record = with_env_overrides("ES_INE_CPI_SERIES_ID" => nil) do
+ Bond::InflationProvider.record_for_date(
+ provider: "es_ine",
+ date: Date.new(2025, 3, 10),
+ lag_months: 2,
+ allow_import: true
+ )
+ end
+
+ assert_nil record
+ ensure
+ Setting.es_ine_cpi_series_id = old_series_id
+ end
+end
diff --git a/test/models/bond_lot_test.rb b/test/models/bond_lot_test.rb
new file mode 100644
index 000000000..5f36047e7
--- /dev/null
+++ b/test/models/bond_lot_test.rb
@@ -0,0 +1,927 @@
+require "test_helper"
+
+class BondLotTest < ActiveSupport::TestCase
+ test "auto-assigns maturity date from purchase date and term" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 1, 15),
+ term_months: 3,
+ amount: 1000,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ maturity_date: nil
+ )
+
+ lot.valid?
+
+ assert_equal Date.new(2026, 4, 15), lot.maturity_date
+ end
+
+ test "recomputes maturity date when term changes" do
+ lot = BondLot.create!(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 1, 15),
+ term_months: 3,
+ amount: 1000,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ interest_rate: 5.0
+ )
+
+ assert_equal Date.new(2026, 4, 15), lot.maturity_date
+
+ lot.update!(term_months: 6)
+
+ assert_equal Date.new(2026, 7, 15), lot.reload.maturity_date
+ end
+
+ test "requires positive principal and term" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ term_months: 0,
+ amount: 0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_not lot.valid?
+ assert_includes lot.errors[:amount], "must be greater than 0"
+ assert_includes lot.errors[:term_months], "must be greater than 0"
+ end
+
+ test "inherits subtype and rate defaults from bond" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000
+ )
+
+ assert lot.valid?
+ assert_equal "other", lot.subtype
+ assert_equal "fixed", lot.rate_type
+ assert_equal "at_maturity", lot.coupon_frequency
+ end
+
+ test "create_purchase_entry! creates and attaches entry with bond metadata" do
+ account = accounts(:bond)
+ lot = account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ term_months: 12,
+ interest_rate: 4.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_difference [ "Entry.count", "Transaction.count" ], 1 do
+ lot.create_purchase_entry!
+ end
+
+ lot.reload
+ assert_not_nil lot.entry
+ assert_equal Date.new(2026, 2, 1), lot.entry.date
+ assert_equal 1000.to_d, lot.entry.amount
+ assert_equal lot.id, lot.entry.entryable.extra["bond_lot_id"]
+ assert_equal "other", lot.entry.entryable.extra["bond_subtype"]
+ assert_equal 12, lot.entry.entryable.extra["bond_term_months"]
+ assert_equal 4.0.to_d, lot.entry.entryable.extra["bond_interest_rate"].to_d
+ end
+
+ test "create_purchase_entry! is idempotent when entry already exists" do
+ account = accounts(:bond)
+ lot = account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ term_months: 12,
+ interest_rate: 4.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ first_entry = lot.create_purchase_entry!
+
+ assert_no_difference [ "Entry.count", "Transaction.count" ] do
+ second_entry = lot.create_purchase_entry!
+ assert_equal first_entry.id, second_entry.id
+ end
+ end
+
+ test "update_purchase_entry! updates entry and preserves unrelated extra fields" do
+ account = accounts(:bond)
+ entry_record = account.entries.create!(
+ date: Date.new(2026, 2, 1),
+ name: "Bond purchase",
+ amount: 1000,
+ currency: account.currency,
+ entryable: Transaction.new(kind: :funds_movement, extra: { "custom" => "keep" })
+ )
+
+ lot = account.bond.bond_lots.create!(
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ term_months: 12,
+ interest_rate: 4.0,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ entry: entry_record
+ )
+
+ lot.update!(
+ purchased_on: Date.new(2026, 2, 15),
+ amount: 1200,
+ term_months: 24,
+ interest_rate: 4.5,
+ subtype: "other_bond"
+ )
+ lot.update_purchase_entry!
+
+ entry_record.reload
+ assert_equal Date.new(2026, 2, 15), entry_record.date
+ assert_equal 1200.to_d, entry_record.amount
+ assert_equal "keep", entry_record.entryable.extra["custom"]
+ assert_equal lot.id, entry_record.entryable.extra["bond_lot_id"]
+ assert_equal "other", entry_record.entryable.extra["bond_subtype"]
+ assert_equal 24, entry_record.entryable.extra["bond_term_months"]
+ assert_equal 4.5.to_d, entry_record.entryable.extra["bond_interest_rate"].to_d
+ end
+
+ test "calculates total return from elapsed time and annual rate" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 1, 1),
+ maturity_date: Date.new(2027, 1, 1),
+ term_months: 12,
+ amount: 1000,
+ interest_rate: 10,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ current_value = lot.estimated_current_value(on: Date.new(2026, 7, 1))
+ total_return = lot.total_return_amount(on: Date.new(2026, 7, 1))
+ total_return_percent = lot.total_return_percent(on: Date.new(2026, 7, 1))
+
+ assert_in_delta 1049.59, current_value.to_f, 0.2
+ assert_in_delta 49.59, total_return.to_f, 0.2
+ assert_in_delta 4.959, total_return_percent.to_f, 0.05
+ end
+
+ test "builds capitalization history events" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ maturity_date: Date.new(2026, 1, 1),
+ term_months: 48,
+ amount: 1000,
+ interest_rate: 10,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ history = lot.capitalization_history(on: Date.new(2025, 1, 1))
+
+ assert_equal 1, history.size
+ assert_equal 1, history.first[:period_number]
+ assert history.first[:interest_earned].positive?
+ assert history.first[:full_year_capitalization]
+ end
+
+ test "total return caps accrual at maturity date" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 1, 1),
+ maturity_date: Date.new(2026, 7, 1),
+ term_months: 6,
+ amount: 1000,
+ interest_rate: 12,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ value_at_maturity = lot.estimated_current_value(on: Date.new(2026, 7, 1))
+ value_after_maturity = lot.estimated_current_value(on: Date.new(2026, 12, 31))
+
+ assert_in_delta value_at_maturity.to_f, value_after_maturity.to_f, 0.001
+ end
+
+ test "uses EOD inflation-linked setup after first year" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ term_months: 120,
+ amount: 1000,
+ subtype: "eod",
+ first_period_rate: 7.0,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 4.0,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ current_value = lot.estimated_current_value(on: Date.new(2026, 1, 1))
+
+ assert current_value > 1000
+ end
+
+ test "does not require term_months for EOD because product defaults set it" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "eod",
+ rate_type: "variable",
+ coupon_frequency: "at_maturity",
+ first_period_rate: 6.0,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 4.0,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.current,
+ cpi_lag_months: 2
+ )
+
+ assert lot.valid?
+ assert_equal 120, lot.term_months
+ end
+
+ test "requires inflation-linked fields only for EOD and ROD" do
+ eod_lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "eod",
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_not eod_lot.valid?
+ assert_includes eod_lot.errors[:first_period_rate], "can't be blank"
+ assert_includes eod_lot.errors[:inflation_margin], "can't be blank"
+ assert_not_includes eod_lot.errors[:interest_rate], "can't be blank"
+
+ other_lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ term_months: 12,
+ interest_rate: 4.5
+ )
+
+ assert other_lot.valid?
+ end
+
+ test "requires interest_rate for Other Bond" do
+ bond = bonds(:one)
+ bond.interest_rate = nil
+
+ lot = BondLot.new(
+ bond: bond,
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "other_bond",
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ term_months: 12,
+ interest_rate: nil
+ )
+
+ assert_not lot.valid?
+ assert_includes lot.errors[:interest_rate], "can't be blank"
+ end
+
+ test "uses manual inflation assumption after the first year" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "eod",
+ first_period_rate: 7.0,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 5.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ value = lot.estimated_current_value(on: Date.new(2026, 1, 1))
+
+ assert_in_delta 1139.55, value.to_f, 1.0
+ end
+
+ test "keeps auto fetch enabled when inflation provider is blank" do
+ Setting.stubs(:inflation_import_enabled_effective).returns(true)
+
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "inflation_linked",
+ auto_fetch_inflation: true,
+ first_period_rate: 6.0,
+ inflation_margin: 1.0,
+ inflation_rate_assumption: 2.0,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.current,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ lot.valid?
+
+ assert lot.auto_fetch_inflation
+ assert_nil lot.inflation_provider
+ end
+
+ test "does not require first period rate for late-purchase inflation linked lot" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ subtype: "inflation_linked",
+ term_months: 48,
+ first_period_rate: nil,
+ inflation_margin: 1.0,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert lot.valid?
+ assert_not lot.needs_first_period_rate?
+ end
+
+ test "clears inflation_provider for non-inflation-linked lot" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "other",
+ auto_fetch_inflation: true,
+ inflation_provider: "gus_sdp",
+ term_months: 12,
+ interest_rate: 4.0,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity"
+ )
+
+ lot.valid?
+
+ assert_nil lot.inflation_provider
+ assert_not lot.auto_fetch_inflation?
+ end
+
+
+ test "coupon_amount_per_period computes value for periodic coupon bonds" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1200,
+ subtype: "fixed_coupon",
+ term_months: 24,
+ interest_rate: 6,
+ rate_type: "fixed",
+ coupon_frequency: "semi_annual"
+ )
+
+ coupon = lot.coupon_amount_per_period
+
+ assert_in_delta 36.0, coupon.amount.to_f, 0.001
+ end
+
+ test "coupon_amount_per_period supports all periodic frequencies" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1200,
+ subtype: "fixed_coupon",
+ term_months: 24,
+ interest_rate: 6,
+ rate_type: "fixed"
+ )
+
+ {
+ "monthly" => 6.0,
+ "quarterly" => 18.0,
+ "semi_annual" => 36.0,
+ "annual" => 72.0
+ }.each do |frequency, expected_amount|
+ lot.coupon_frequency = frequency
+ coupon = lot.coupon_amount_per_period
+ assert_in_delta expected_amount, coupon.amount.to_f, 0.001
+ end
+
+ lot.coupon_frequency = "at_maturity"
+ assert_nil lot.coupon_amount_per_period
+ end
+
+ test "coupon_amount_per_period uses dynamic rate for inflation-linked periodic bond" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ issue_date: Date.new(2024, 1, 1),
+ amount: 1200,
+ subtype: "inflation_linked",
+ term_months: 120,
+ coupon_frequency: "semi_annual",
+ first_period_rate: 4.0,
+ inflation_margin: 2.0,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 12,
+ nominal_per_unit: 100,
+ rate_type: "variable"
+ )
+
+ coupon = lot.coupon_amount_per_period(on: Date.new(2026, 3, 31))
+
+ # Year 2+ annual rate = inflation assumption (3.0) + margin (2.0) = 5.0%
+ # Semi-annual coupon for 1200 principal = 1200 * 5% / 2 = 30.0
+ assert_in_delta 30.0, coupon.amount.to_f, 0.001
+ end
+
+
+ test "estimated_current_value for periodic coupon bond excludes already paid coupons" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ maturity_date: Date.new(2025, 1, 1),
+ term_months: 12,
+ amount: 1000,
+ interest_rate: 12,
+ subtype: "fixed_coupon",
+ rate_type: "fixed",
+ coupon_frequency: "semi_annual"
+ )
+
+ value = lot.estimated_current_value(on: Date.new(2024, 9, 1))
+
+ assert_in_delta 1020.33, value.to_f, 0.2
+ end
+
+ test "product change re-applies inflation-linked product defaults" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ product_code: "us_tips_10y",
+ first_period_rate: 4.0,
+ inflation_margin: 1.0,
+ inflation_rate_assumption: 3.0,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.current
+ )
+
+ assert lot.valid?
+ assert_equal "inflation_linked", lot.subtype
+
+ lot.product_code = "pl_eod"
+ assert lot.valid?
+ assert_equal "inflation_linked", lot.subtype
+ assert_equal 120, lot.term_months
+ end
+
+ test "derive_amount_from_units keeps explicit non-par amount for non-inflation lots" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 950,
+ subtype: "fixed_coupon",
+ term_months: 12,
+ interest_rate: 5,
+ rate_type: "fixed",
+ coupon_frequency: "semi_annual",
+ units: 10,
+ nominal_per_unit: 100
+ )
+
+ assert lot.valid?
+ assert_equal 950.to_d, lot.amount.to_d
+ assert_equal 1000.to_d, lot.send(:cashflow_principal), "cashflow_principal uses face value (units * nominal_per_unit), not purchase price"
+ end
+
+ test "product presets override conflicting rate and coupon settings" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ product_code: "us_t_note_2y",
+ subtype: "other",
+ rate_type: "variable",
+ coupon_frequency: "at_maturity",
+ term_months: 6,
+ interest_rate: 4.5
+ )
+
+ assert lot.valid?
+ assert_equal "fixed_coupon", lot.subtype
+ assert_equal "fixed", lot.rate_type
+ assert_equal "semi_annual", lot.coupon_frequency
+ assert_equal 24, lot.term_months
+ end
+
+
+ test "falls back to manual inflation assumption when GUS value missing" do
+ Setting.stubs(:inflation_import_enabled_effective).returns(true)
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "eod",
+ first_period_rate: 7.0,
+ inflation_margin: 1.5,
+ inflation_rate_assumption: 4.0,
+ auto_fetch_inflation: true,
+ inflation_provider: "gus_sdp",
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ # No CPI row => should use manual assumption 4.0 + 1.5 = 5.5% for year-2+
+ value = lot.estimated_current_value(on: Date.new(2026, 1, 1))
+
+ assert_in_delta 1128.85, value.to_f, 1.0
+ end
+
+ test "does not require manual inflation assumption when global auto-import is disabled" do
+ Setting.stubs(:inflation_import_enabled_effective).returns(false)
+
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.current,
+ amount: 1000,
+ subtype: "eod",
+ first_period_rate: 7.0,
+ inflation_margin: 1.5,
+ auto_fetch_inflation: true,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.current,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert lot.valid?
+ end
+
+ test "current_rate_percent uses the manual inflation-linked rate after the first year" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2014, 5, 31),
+ amount: 1000,
+ subtype: "rod",
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ inflation_rate_assumption: 1.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2014, 5, 31),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ current_rate = lot.current_rate_percent(on: Date.new(2025, 3, 31))
+
+ assert_in_delta 1.9, current_rate.to_f, 0.001
+ end
+
+ test "uses manual assumption for inflation-linked lots after the first year" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2014, 5, 31),
+ amount: 1000,
+ subtype: "inflation_linked",
+ first_period_rate: 4.0,
+ inflation_margin: 2.0,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2014, 5, 31),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_in_delta 5.0, lot.current_rate_percent(on: Date.new(2025, 3, 31)).to_f, 0.001
+ assert_equal "manual", lot.current_inflation_source(on: Date.new(2025, 3, 31))
+ end
+
+ test "keeps the manual inflation-linked rate stable within the same annual reset period" do
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 15),
+ amount: 1000,
+ subtype: "rod",
+ first_period_rate: 4.0,
+ inflation_margin: 1.0,
+ inflation_rate_assumption: 5.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 15),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_in_delta 6.0, lot.current_rate_percent(on: Date.new(2025, 3, 31)).to_f, 0.001
+ assert_in_delta 6.0, lot.current_rate_percent(on: Date.new(2025, 9, 30)).to_f, 0.001
+ end
+
+ test "hides inflation breakdown during first period" do
+ Setting.stubs(:inflation_import_enabled_effective).returns(true)
+ GusInflationRate.create!(year: 2024, month: 3, rate_yoy: 106.0, source: "sdp")
+
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 5, 31),
+ amount: 1000,
+ subtype: "rod",
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ inflation_rate_assumption: 1.0,
+ auto_fetch_inflation: true,
+ inflation_provider: "gus_sdp",
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 5, 31),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_nil lot.current_inflation_component_percent(on: Date.new(2024, 10, 1))
+ assert_nil lot.current_inflation_source(on: Date.new(2024, 10, 1))
+ assert_nil lot.current_margin_percent(on: Date.new(2024, 10, 1))
+ end
+
+ test "does not clear requires_rate_review while CPI periods are unresolved" do
+ Setting.stubs(:inflation_import_enabled_effective).returns(true)
+
+ lot = BondLot.new(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "rod",
+ term_months: 24,
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ auto_fetch_inflation: true,
+ inflation_provider: "gus_sdp",
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ requires_rate_review: true,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ lot.valid?
+
+ assert lot.requires_rate_review?
+ end
+
+ test "needs_rate_review ignores stale persisted flags once manual rates are present" do
+ lot = BondLot.create!(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "inflation_linked",
+ term_months: 24,
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ requires_rate_review: true,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_not_includes BondLot.needs_rate_review, lot
+ end
+
+ test "needs_rate_review uses maturity date when a matured lot has manual rates" do
+ lot = BondLot.create!(
+ bond: bonds(:one),
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "inflation_linked",
+ term_months: 12,
+ maturity_date: Date.new(2025, 1, 1),
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ requires_rate_review: true,
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_not_includes BondLot.needs_rate_review, lot
+ end
+
+ test "needs_rate_review ignores missing first period rate after intro period" do
+ lot = BondLot.create!(
+ bond: bonds(:one),
+ purchased_on: Date.new(2026, 2, 1),
+ amount: 1000,
+ subtype: "inflation_linked",
+ term_months: 48,
+ first_period_rate: nil,
+ inflation_margin: 1.0,
+ inflation_rate_assumption: 3.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2024, 1, 1),
+ rate_type: "variable",
+ coupon_frequency: "at_maturity"
+ )
+
+ assert_not_includes BondLot.needs_rate_review, lot
+ end
+
+ test "auto-settles matured lot and withholds standard tax" do
+ account = accounts(:bond)
+ lot = BondLot.create!(
+ bond: account.bond,
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "other_bond",
+ term_months: 12,
+ interest_rate: 10,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ auto_close_on_maturity: true,
+ tax_strategy: "standard",
+ tax_rate: 19
+ )
+
+ assert lot.settle_if_matured!(on: Date.new(2025, 2, 1))
+
+ lot.reload
+ assert_equal Date.new(2025, 1, 1), lot.closed_on
+ assert lot.tax_withheld.to_d.positive?
+ assert lot.settlement_amount.to_d.positive?
+ settlement_entry = account.entries.order(created_at: :desc).first
+ assert_includes settlement_entry.notes, "Purchase amount:"
+ assert_includes settlement_entry.notes, "Total interest:"
+ assert_includes settlement_entry.notes, "Tax withheld:"
+ end
+
+ test "auto-settles lot immediately when created already after maturity" do
+ account = accounts(:bond)
+ lot = account.bond.bond_lots.build(
+ bond: account.bond,
+ purchased_on: Date.new(2013, 4, 7),
+ amount: 1000,
+ subtype: "fixed_coupon",
+ term_months: 120,
+ issue_date: Date.new(2013, 4, 7),
+ interest_rate: 5.0,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ auto_close_on_maturity: true
+ )
+
+ lot.save_with_purchase_entry!
+
+ lot.reload
+
+ assert lot.closed_on.present?
+ assert_equal lot.maturity_date, lot.closed_on
+ assert lot.settlement_amount.to_d.positive?
+ end
+
+ test "auto-settles matured lot tax exempt for IKE/IKZE scenario" do
+ account = accounts(:bond)
+ account.bond.update!(tax_wrapper: "ike")
+
+ lot = BondLot.create!(
+ bond: account.bond,
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "other_bond",
+ term_months: 12,
+ interest_rate: 10,
+ rate_type: "fixed",
+ coupon_frequency: "at_maturity",
+ auto_close_on_maturity: true,
+ tax_strategy: "exempt",
+ tax_rate: 0
+ )
+
+ assert lot.settle_if_matured!(on: Date.new(2025, 2, 1))
+
+ lot.reload
+ assert_equal 0.to_d, lot.tax_withheld.to_d
+ assert_in_delta lot.estimated_current_value(on: lot.maturity_date).to_f, lot.settlement_amount.to_d.to_f, 0.01
+ settlement_entry = account.entries.order(created_at: :desc).first
+ assert_includes settlement_entry.notes, "Purchase amount:"
+ assert_includes settlement_entry.notes, "Total interest:"
+ assert_includes settlement_entry.notes, "Tax withheld: none"
+ end
+
+ test "auto-settlement for periodic coupon bond excludes previously paid coupons" do
+ account = accounts(:bond)
+ account.bond.update!(tax_wrapper: "ike")
+
+ lot = BondLot.create!(
+ bond: account.bond,
+ purchased_on: Date.new(2024, 1, 1),
+ amount: 1000,
+ subtype: "fixed_coupon",
+ term_months: 12,
+ interest_rate: 12,
+ rate_type: "fixed",
+ coupon_frequency: "semi_annual",
+ auto_close_on_maturity: true,
+ tax_strategy: "exempt",
+ tax_rate: 0
+ )
+
+ assert lot.settle_if_matured!(on: Date.new(2025, 2, 1))
+
+ lot.reload
+ assert_in_delta 1060.33, lot.settlement_amount.to_d.to_f, 0.2
+ end
+
+ test "auto-buys replacement inflation-linked lot and flags rate review" do
+ account = accounts(:bond)
+ account.bond.update!(tax_wrapper: "ike", auto_buy_new_issues: true)
+
+ lot = BondLot.create!(
+ bond: account.bond,
+ purchased_on: Date.new(2014, 5, 31),
+ amount: 1000,
+ subtype: "rod",
+ first_period_rate: 4.0,
+ inflation_margin: 0.9,
+ inflation_rate_assumption: 4.0,
+ auto_fetch_inflation: false,
+ cpi_lag_months: 2,
+ units: 10,
+ nominal_per_unit: 100,
+ issue_date: Date.new(2014, 5, 31),
+ auto_close_on_maturity: true
+ )
+ lot.update_column(:coupon_frequency, "annual")
+ lot.reload
+
+ assert_difference -> { account.bond.bond_lots.count }, 1 do
+ assert lot.settle_if_matured!(on: Date.new(2026, 6, 1))
+ end
+
+ replacement_lot = account.bond.bond_lots.order(created_at: :desc).first
+
+ assert replacement_lot.requires_rate_review?
+ assert_equal "inflation_linked", replacement_lot.subtype
+ assert_equal "annual", replacement_lot.coupon_frequency
+ assert_nil replacement_lot.first_period_rate
+ assert_nil replacement_lot.inflation_margin
+ assert replacement_lot.entry.present?
+ end
+end
diff --git a/test/models/bond_test.rb b/test/models/bond_test.rb
new file mode 100644
index 000000000..bb197cc62
--- /dev/null
+++ b/test/models/bond_test.rb
@@ -0,0 +1,31 @@
+require "test_helper"
+
+class BondTest < ActiveSupport::TestCase
+ test "returns original balance from bond lots when present" do
+ account = accounts(:bond)
+
+ assert_equal 15000, account.bond.original_balance.amount
+ assert_equal "USD", account.bond.original_balance.currency.iso_code
+ end
+
+ test "auto-assigns maturity date from term months" do
+ bond = Bond.new(term_months: 6, maturity_date: nil)
+
+ bond.valid?
+
+ assert_equal Time.zone.today + 6.months, bond.maturity_date
+ end
+
+ test "is an asset accountable type" do
+ assert_equal "asset", Bond.classification
+ assert_equal "badge-percent", Bond.icon
+ end
+
+ test "normalizes legacy EOD subtype to inflation_linked" do
+ bond = Bond.new(subtype: "eod")
+
+ bond.valid?
+
+ assert_equal "inflation_linked", bond.subtype
+ end
+end
diff --git a/test/models/gus_inflation_rate_test.rb b/test/models/gus_inflation_rate_test.rb
new file mode 100644
index 000000000..aac67bc04
--- /dev/null
+++ b/test/models/gus_inflation_rate_test.rb
@@ -0,0 +1,100 @@
+require "test_helper"
+
+class GusInflationRateTest < ActiveSupport::TestCase
+ test "enforces uniqueness per year and month" do
+ GusInflationRate.create!(year: 2025, month: 1, rate_yoy: 104.7, source: "sdp")
+
+ duplicate = GusInflationRate.new(year: 2025, month: 1, rate_yoy: 105.0, source: "sdp")
+
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:month], "has already been taken"
+ end
+
+ test "allows same month in different years" do
+ GusInflationRate.create!(year: 2025, month: 1, rate_yoy: 104.7, source: "sdp")
+
+ next_year = GusInflationRate.new(year: 2026, month: 1, rate_yoy: 102.1, source: "sdp")
+
+ assert next_year.valid?
+ end
+
+ test "for_date applies lag months" do
+ GusInflationRate.create!(year: 2025, month: 12, rate_yoy: 104.7, source: "sdp")
+
+ record = GusInflationRate.for_date(date: Date.new(2026, 2, 5), lag_months: 2)
+
+ assert_not_nil record
+ assert_equal 2025, record.year
+ assert_equal 12, record.month
+ assert_equal 104.7.to_d, record.rate_yoy
+ end
+
+ test "import_year maps period IDs to months" do
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).with(year: 2026).returns(
+ Provider::Response.new(
+ success?: true,
+ data: [
+ { period_id: 247, value: "102.1" },
+ { period_id: 248, value: "103.3" }
+ ],
+ error: nil
+ )
+ )
+
+ GusInflationRate.stubs(:provider).returns(fake_provider)
+
+ imported = GusInflationRate.import_year!(year: 2026)
+
+ assert_equal 2, imported
+ assert_equal 102.1.to_d, GusInflationRate.find_by!(year: 2026, month: 1).rate_yoy
+ assert_equal 103.3.to_d, GusInflationRate.find_by!(year: 2026, month: 2).rate_yoy
+ end
+
+ test "import_year raises provider error when request fails" do
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).with(year: 2026).returns(
+ Provider::Response.new(success?: false, data: nil, error: Provider::Error.new("boom"))
+ )
+
+ GusInflationRate.stubs(:provider).returns(fake_provider)
+
+ error = assert_raises(Provider::Error) { GusInflationRate.import_year!(year: 2026) }
+ assert_equal "boom", error.message
+ end
+
+ test "import_year returns zero when provider responds with 404 for unavailable year" do
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).with(year: 2026).returns(
+ Provider::Response.new(success?: false, data: nil, error: Provider::Error.new("the server responded with status 404"))
+ )
+
+ GusInflationRate.stubs(:provider).returns(fake_provider)
+
+ assert_equal 0, GusInflationRate.import_year!(year: 2026)
+ end
+
+ test "import_year raises error when provider responds with 429 rate limit" do
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).with(year: 2026).returns(
+ Provider::Response.new(success?: false, data: nil, error: Provider::Error.new("the server responded with status 429"))
+ )
+
+ GusInflationRate.stubs(:provider).returns(fake_provider)
+
+ error = assert_raises(Provider::Error) { GusInflationRate.import_year!(year: 2026) }
+ assert_match(/429/, error.message)
+ end
+
+ test "import_year skips provider call for complete year when force is false" do
+ (1..12).each do |month|
+ GusInflationRate.create!(year: 2025, month: month, rate_yoy: 101.0 + month, source: "sdp")
+ end
+
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).never
+ GusInflationRate.stubs(:provider).returns(fake_provider)
+
+ assert_equal 0, GusInflationRate.import_year!(year: 2025, force: false)
+ end
+end
diff --git a/test/models/inflation_rate_test.rb b/test/models/inflation_rate_test.rb
new file mode 100644
index 000000000..cc1ca0000
--- /dev/null
+++ b/test/models/inflation_rate_test.rb
@@ -0,0 +1,34 @@
+require "test_helper"
+
+class InflationRateTest < ActiveSupport::TestCase
+ test "for_date applies lag months and source" do
+ InflationRate.create!(source: "us_bls", year: 2025, month: 1, rate_yoy: 103.2)
+
+ record = InflationRate.for_date(source: "us_bls", date: Date.new(2025, 3, 15), lag_months: 2)
+
+ assert_not_nil record
+ assert_equal 2025, record.year
+ assert_equal 1, record.month
+ assert_equal 103.2.to_d, record.rate_yoy
+ end
+
+ test "import_year persists provider rows" do
+ fake_provider = mock("provider")
+ fake_provider.expects(:fetch_cpi_yoy_for_year).with(year: 2025).returns(
+ Provider::Response.new(
+ success?: true,
+ data: [
+ { month: 1, rate_yoy: 103.1 },
+ { month: 2, rate_yoy: 103.4 }
+ ],
+ error: nil
+ )
+ )
+
+ imported = InflationRate.import_year!(source: "us_bls", provider: fake_provider, year: 2025)
+
+ assert_equal 2, imported
+ assert_equal 103.1.to_d, InflationRate.find_by!(source: "us_bls", year: 2025, month: 1).rate_yoy
+ assert_equal 103.4.to_d, InflationRate.find_by!(source: "us_bls", year: 2025, month: 2).rate_yoy
+ end
+end
diff --git a/test/models/provider/es_ine_cpi_test.rb b/test/models/provider/es_ine_cpi_test.rb
new file mode 100644
index 000000000..d7bbb0d55
--- /dev/null
+++ b/test/models/provider/es_ine_cpi_test.rb
@@ -0,0 +1,42 @@
+require "test_helper"
+
+class Provider::EsIneCpiTest < ActiveSupport::TestCase
+ test "fetch_cpi_yoy_for_year filters rows to requested year" do
+ provider = Provider::EsIneCpi.new(series_id: "IPC_TEST")
+ provider.stubs(:fetch_rows).returns([
+ { year: 2024, month: 12, rate_yoy: 102.1.to_d },
+ { year: 2025, month: 1, rate_yoy: 103.4.to_d },
+ { year: 2025, month: 2, rate_yoy: 103.7.to_d }
+ ])
+
+ result = provider.fetch_cpi_yoy_for_year(year: 2025)
+
+ assert result.success?
+ assert_equal 2, result.data.size
+ assert_equal({ month: 1, rate_yoy: 103.4.to_d }, result.data[0])
+ assert_equal({ month: 2, rate_yoy: 103.7.to_d }, result.data[1])
+ end
+
+ test "fetch_cpi_yoy_for_year returns error when series id missing" do
+ provider = Provider::EsIneCpi.new(series_id: nil)
+
+ result = provider.fetch_cpi_yoy_for_year(year: 2025)
+
+ assert_not result.success?
+ assert_instance_of Provider::EsIneCpi::Error, result.error
+ assert_match(/Missing ES_INE_CPI_SERIES_ID/, result.error.message)
+ end
+
+ test "fetch_cpi_yoy_for_year returns error when requested year has no rows" do
+ provider = Provider::EsIneCpi.new(series_id: "IPC_TEST")
+ provider.stubs(:fetch_rows).returns([
+ { year: 2024, month: 12, rate_yoy: 102.1.to_d }
+ ])
+
+ result = provider.fetch_cpi_yoy_for_year(year: 2025)
+
+ assert_not result.success?
+ assert_instance_of Provider::EsIneCpi::Error, result.error
+ assert_match(/No ES INE CPI data returned for 2025/, result.error.message)
+ end
+end
diff --git a/test/models/provider/us_bls_cpi_test.rb b/test/models/provider/us_bls_cpi_test.rb
new file mode 100644
index 000000000..5480e45e6
--- /dev/null
+++ b/test/models/provider/us_bls_cpi_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class Provider::UsBlsCpiTest < ActiveSupport::TestCase
+ test "fetch_cpi_yoy_for_year calculates monthly yoy values from index series" do
+ provider = Provider::UsBlsCpi.new
+ provider.stubs(:fetch_series_rows).returns([
+ { year: 2024, month: 1, value: 100.to_d },
+ { year: 2024, month: 2, value: 100.to_d },
+ { year: 2025, month: 1, value: 103.to_d },
+ { year: 2025, month: 2, value: 104.to_d }
+ ])
+
+ result = provider.fetch_cpi_yoy_for_year(year: 2025)
+
+ assert result.success?
+ assert_equal 2, result.data.size
+ assert_equal 1, result.data[0][:month]
+ assert_equal 103.0.to_d, result.data[0][:rate_yoy]
+ assert_equal 2, result.data[1][:month]
+ assert_equal 104.0.to_d, result.data[1][:rate_yoy]
+ end
+
+ test "fetch_cpi_yoy_for_year returns provider error when api status is failed" do
+ provider = Provider::UsBlsCpi.new
+ provider.stubs(:fetch_series_rows).raises(Provider::UsBlsCpi::Error.new("BLS API request failed with status REQUEST_FAILED"))
+
+ result = provider.fetch_cpi_yoy_for_year(year: 2025)
+
+ assert_not result.success?
+ assert_instance_of Provider::UsBlsCpi::Error, result.error
+ assert_match(/REQUEST_FAILED/, result.error.message)
+ end
+end
diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb
index a2266b679..bb1abf261 100644
--- a/test/support/ledger_testing_helper.rb
+++ b/test/support/ledger_testing_helper.rb
@@ -62,7 +62,10 @@ def create_account_with_ledger(account:, entries: [], exchange_rates: [], securi
date: entry_data[:date],
amount: entry_data[:amount],
currency: currency,
- entryable: Transaction.new
+ entryable: Transaction.new(
+ kind: entry_data[:kind] || "standard",
+ extra: entry_data[:extra] || {}
+ )
)
when "trade"
# Find or create security