diff --git a/.env.example b/.env.example index 64d0f4810..7d8825159 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,21 @@ TWELVE_DATA_API_KEY= # EXCHANGE_RATE_PROVIDER=twelve_data # SECURITIES_PROVIDER=twelve_data +# Optional: GUS SDP inflation import for EOD/ROD bonds +# Disabled by default. Enable only if you want automatic monthly CPI imports. +INFLATION_IMPORT_ENABLED=false +# Optional API key for higher GUS SDP limits (free anonymous mode works without it) +GUS_SDP_API_KEY= +# Optional: CPI source overrides for US and ES inflation providers +# US defaults: base_url=https://api.bls.gov/publicAPI/v2, series_id=CUUR0000SA0 +# US_BLS_CPI_BASE_URL= +# US_BLS_CPI_SERIES_ID= +# ES defaults: base_url=https://servicios.ine.es/wstempus/js/EN/DATOS_SERIE/ +# ES_INE_CPI_BASE_URL= +# ES_INE_CPI_SERIES_ID= +# Optional: override the default CPI indicator series ID used for inflation imports +# GUS_SDP_CPI_INDICATOR_ID= + # Alternative: Use Yahoo Finance as provider (free, no API key required) EXCHANGE_RATE_PROVIDER=yahoo_finance SECURITIES_PROVIDER=yahoo_finance diff --git a/.env.local.example b/.env.local.example index e03ce605f..71c416f4b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -24,6 +24,17 @@ ONBOARDING_STATE = open # Enable Twelve market data (careful, this will use your API credits) TWELVE_DATA_API_KEY = +# Global CPI import for inflation-linked bond calculations +# Disabled by default; enable only when needed +INFLATION_IMPORT_ENABLED=false +# Optional API key (anonymous mode works without this) +GUS_SDP_API_KEY= +# Optional CPI provider overrides for US/ES inflation-linked bonds +# US_BLS_CPI_BASE_URL= +# US_BLS_CPI_SERIES_ID= +# ES_INE_CPI_BASE_URL= +# ES_INE_CPI_SERIES_ID= + # OpenAI-compatible API endpoint config OPENAI_ACCESS_TOKEN = OPENAI_URI_BASE = diff --git a/.env.test.example b/.env.test.example index 9b0fbb62b..9cee1db90 100644 --- a/.env.test.example +++ b/.env.test.example @@ -32,6 +32,12 @@ OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback # Uncomment and fill in live keys when you need to generate a VCR cassette fixture # ================ +# Global CPI import for inflation-linked bond calculations. +# Leave blank unless you intentionally want ENV to override the persisted setting. +INFLATION_IMPORT_ENABLED= +# Optional API key for GUS SDP +GUS_SDP_API_KEY= + # ================ # Miscellaneous diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1c098fd39..f8e0b65dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -281,3 +281,71 @@ Style for suggestions Notes from repository config - If .gemini/config.yaml disables automated code_review, still provide clear summaries and fix suggestions in PRs. + +--- + +## Bond Feature (PR #1330 - UberDudePL/feature/bond) + +### Review context +- Polish localization PR has already been merged. +- Do not flag updates in `config/locales/views/**/pl.yml` as out-of-scope by default for Bond follow-up fixes. + +### Architecture +- `Bond` → has many `BondLot` → references inflation provider +- Subtypes: `zero_coupon`, `fixed_coupon`, `inflation_linked`, `savings`, `other` +- Product codes: `pl_eod`, `pl_rod`, `us_tips_10y`, `es_letra_3m`, `us_t_bill_4w` +- Coupon frequency: `at_maturity` (EOD/ROD), `semi_annual` (TIPS), `quarterly`, `monthly` + +### Inflation Providers (Pluggable) +- `gus_sdp` — GUS SDP API (Poland, implemented) +- `us_bls` — BLS/FRED CPI API (USA, implemented) +- `es_ine` — INE/Eurostat CPI (Spain, implemented) +- `nil` — manual CPI entry + +### Key Files +- Models: `app/models/bond.rb`, `app/models/bond_lot.rb`, `app/models/gus_inflation_rate.rb` +- Controllers: `app/controllers/bonds_controller.rb`, `app/controllers/bond_lots_controller.rb` +- Tests: `test/models/bond_lot_test.rb`, `test/controllers/bond_lots_controller_test.rb` + +--- + +## Response Style + +### Be Concise +- Short, actionable answers +- No fluff: avoid "Great question!", "Sure, I'd be happy to help!", "Of course!" +- First: facts / results. Then details if needed. +- Don't repeat context already known. + +### Tool First +- Run commands / read files first +- Then short summary of what happened +- Then offer next steps if relevant. + +### Polish Context +- User (UberDudePL) prefers Polish language in conversation +- Code, commits, docs: English only +- When in doubt, ask. + +--- + +## Caveman-Style Behavior + +When working with code: +1. **Tools first** — run commands, read files, check status +2. **Then short summary** — 1-3 sentences max +3. **Then offer action** — if something needs to be done, ask/confirm + +Example: +- Don't: "Great question! Let me check the bond model for you. I'll read the file and analyze it." +- Do: "Checking bond.rb..." → "Found SUBTYPES at line 15. Want me to refactor to generic version?" + +--- + +## Pre-PR Checklist for Bond Feature + +- [ ] Tests pass: `bin/rails test test/models/bond_lot_test.rb` +- [ ] Tests pass: `bin/rails test test/controllers/bond_lots_controller_test.rb` +- [ ] Rubocop clean: `bin/rubocop app/models/bond* app/controllers/bond*` +- [ ] Backfill migration works (eod/rod → inflation_linked + product_code) +- [ ] US/ES bonds added to PRODUCT_DEFAULTS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae6bb5e3..1a3959d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,8 @@ jobs: PLAID_CLIENT_ID: foo PLAID_SECRET: bar DATABASE_URL: postgres://postgres:postgres@localhost:5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres REDIS_URL: redis://localhost:6379 RAILS_ENV: test diff --git a/.gitignore b/.gitignore index 731dd2f9e..11008c3db 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,9 @@ node_modules/ *.ntvs* *.njsproj *.sln + +# Code review comments (local development only) +bond-comments.md *.sw? *.roo* # OS specific @@ -123,4 +126,7 @@ scripts/ .auto-claude-status .claude_settings.json .security-key -logs/security/ \ No newline at end of file +logs/security/ +# Local bond implementation notes +Bond.md +Bond.txt diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 00adaf3fd..e89c9e79c 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -26,6 +26,13 @@ icon: "arrow-left-right", href: new_trade_path(account_id: account.id), data: { turbo_frame: :modal }) %> + <% elsif account.bond? %> + <% menu.with_item( + variant: "link", + text: t("accounts.show.activity.new_activity", default: "New activity"), + icon: "arrow-left-right", + href: new_bond_lot_path(account_id: account.id), + data: { turbo_frame: :modal }) %> <% else %> <% menu.with_item( variant: "link", diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index efcdca7d6..7691d8687 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -4,7 +4,7 @@
<%= tag.p title, class: "text-sm font-medium text-secondary" %> - <% if account.investment? %> + <% if account.investment? || account.bond? %> <%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %> <% end %>
@@ -19,7 +19,7 @@ <%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
- <% if account.investment? %> + <% if account.investment? || account.bond? %> <%= form.select :chart_view, [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]], { selected: view }, diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index ee55e499a..8f66cc902 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -28,7 +28,7 @@ def view_balance_money def title case account.accountable_type - when "Investment", "Crypto" + when "Investment", "Crypto", "Bond" case view when "balance" "Total account value" diff --git a/app/components/UI/account_page.rb b/app/components/UI/account_page.rb index 0fc5f0e53..167c4b740 100644 --- a/app/components/UI/account_page.rb +++ b/app/components/UI/account_page.rb @@ -33,6 +33,8 @@ def subtitle end def active_tab + return :positions if account.bond? && @active_tab&.to_sym == :holdings + tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first end @@ -40,6 +42,8 @@ def tabs case account.accountable_type when "Investment", "Crypto" [ :activity, :holdings ] + when "Bond" + [ :activity, :positions, :closed ] when "Property", "Vehicle", "Loan" [ :activity, :overview ] else @@ -68,7 +72,7 @@ def tab_content_for(tab) case tab when :activity activity_feed - when :holdings, :overview + when :holdings, :overview, :positions, :closed # Accountable is responsible for implementing the partial in the correct folder render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account end diff --git a/app/components/UI/dashboard/bond_summary_row.html.erb b/app/components/UI/dashboard/bond_summary_row.html.erb new file mode 100644 index 000000000..565efa880 --- /dev/null +++ b/app/components/UI/dashboard/bond_summary_row.html.erb @@ -0,0 +1,31 @@ + + +
+ <%= render DS::FilledIcon.new(variant: :text, text: subtype_label, size: "md", rounded: true) %> +
+ <%= link_to subtype_label, account_path(account, tab: "positions"), class: "truncate hover:underline" %> +

<%= t("pages.dashboard.bond_summary.account_wrapper", account: account.name, wrapper: account.bond_wrapper_label || account.short_subtype_label) %>

+
+
+ + + +

<%= rate_text %>

+

<%= rate_meta %>

+ + + +

<%= helpers.format_money(Money.new(lot.amount, account.currency)) %>

+

<%= t("pages.dashboard.bond_summary.principal_term", term: t("pages.dashboard.bond_summary.term_months", count: lot.term_months)) %>

+ + + +

<%= lot.maturity_date ? l(lot.maturity_date) : t("bonds.purchase_holding.unknown") %>

+

<%= t("pages.dashboard.bond_summary.maturity_label") %>

+ + + +

<%= helpers.format_money(Money.new(total_return_amount, account.currency)) %>

+

<%= total_return_label %>

+ + diff --git a/app/components/UI/dashboard/bond_summary_row.rb b/app/components/UI/dashboard/bond_summary_row.rb new file mode 100644 index 000000000..1a6e3a232 --- /dev/null +++ b/app/components/UI/dashboard/bond_summary_row.rb @@ -0,0 +1,107 @@ +class UI::Dashboard::BondSummaryRow < ApplicationComponent + attr_reader :account, :lot, :show_border + + def initialize(account:, lot:, show_border: false) + @account = account + @lot = lot + @show_border = show_border + end + + def subtype_label + Bond.long_subtype_label_for(lot.subtype) || t("bonds.purchase_holding.unknown") + end + + def total_return_amount + @total_return_amount ||= projected_total_return? ? projected_return_amount : current_return_amount + end + + def total_return_label + if projected_total_return? + t("bonds.purchase_holding.projected_to_maturity") + else + t("bonds.purchase_holding.since_purchase") + end + end + + def total_return_class + total_return_amount.negative? ? "text-destructive" : "text-success" + end + + def rate_text + if lot.inflation_linked? + return t("bonds.purchase_holding.update_needed") if lot.requires_rate_review? + + current_rate = lot.current_rate_percent(allow_import: false) + return "#{current_rate.round(3)}%" if current_rate.present? + + t("bonds.purchase_holding.unknown") + else + rate = lot.interest_rate + rate.present? ? "#{(rate * 100).round(3)}%" : t("bonds.purchase_holding.unknown") + end + end + + def rate_meta + if lot.inflation_linked? + inflation_linked_rate_meta + else + t( + "bonds.purchase_holding.bond_meta", + rate_type: localized_rate_type, + coupon: localized_coupon_frequency + ) + end + end + + def row_classes + classes = [ "text-sm", "font-medium", "text-primary" ] + classes << "border-b border-divider" if show_border + classes.join(" ") + end + + private + def current_return_amount + @current_return_amount ||= lot.total_return_amount(allow_import: false) + end + + def projected_return_amount + @projected_return_amount ||= lot.projected_total_return_amount(allow_import: false) + end + + def projected_total_return? + return @projected_total_return if defined?(@projected_total_return) + + @projected_total_return = current_return_amount.abs < 0.01.to_d && + projected_return_amount.positive? + end + + def inflation_linked_rate_meta + return t("bonds.purchase_holding.pending_review") if lot.requires_rate_review? + + inflation_component = lot.current_inflation_component_percent(allow_import: false) + margin_component = lot.current_margin_percent(allow_import: false) + return t("bonds.purchase_holding.first_period_fixed_rate") if lot.in_first_rate_period? + return t("bonds.purchase_holding.unknown") if inflation_component.nil? || margin_component.nil? + + inflation = helpers.number_to_percentage(inflation_component.to_d, precision: 3) + margin = helpers.number_to_percentage(margin_component.to_d, precision: 3) + + t( + "bonds.purchase_holding.inflation_meta_manual", + inflation: inflation, + margin: margin + ) + end + + def localized_rate_type + return t("bonds.purchase_holding.unknown") if lot.rate_type.blank? + + t("bond_lots.form.rate_types.#{lot.rate_type}", default: t("bonds.purchase_holding.unknown")) + end + + def localized_coupon_frequency + return t("bonds.purchase_holding.unknown") if lot.coupon_frequency.blank? + + t("bond_lots.form.coupon_frequencies.#{lot.coupon_frequency}", default: t("bonds.purchase_holding.unknown")) + end +end diff --git a/app/controllers/bond_lots_controller.rb b/app/controllers/bond_lots_controller.rb new file mode 100644 index 000000000..a63cc9baf --- /dev/null +++ b/app/controllers/bond_lots_controller.rb @@ -0,0 +1,118 @@ +class BondLotsController < ApplicationController + before_action :set_bond_lot, only: %i[show edit update destroy] + + def new + @account = accessible_accounts.find(params[:account_id]) + return unless require_account_permission!(@account) + return redirect_back_or_to(account_path(@account), alert: t("bond_lots.not_bond_account")) unless @account.bond? + + @bond_lot = @account.bond.bond_lots.build( + purchased_on: Date.current, + term_months: @account.bond.term_months, + interest_rate: @account.bond.interest_rate, + subtype: @account.bond.subtype, + rate_type: @account.bond.rate_type, + coupon_frequency: @account.bond.coupon_frequency + ) + end + + def edit + @account = @bond_lot.account + return unless require_account_permission!(@account) # rubocop:disable Style/RedundantReturn + end + + def show + @account = @bond_lot.account + return unless require_account_permission!(@account) # rubocop:disable Style/RedundantReturn + end + + def create + @account = accessible_accounts.find(params[:account_id]) + return unless require_account_permission!(@account) + + return redirect_back_or_to(account_path(@account), alert: t("bond_lots.not_bond_account")) unless @account.bond? + + @bond_lot = @account.bond.bond_lots.build(bond_lot_params(@account.bond)) + + if @bond_lot.valid? + begin + @bond_lot.save_with_purchase_entry! + rescue ActiveRecord::RecordInvalid => e + @bond_lot.errors.add(:base, e.record.errors.full_messages.to_sentence) + return render :new, status: :unprocessable_entity + end + + @account.sync_later(window_start_date: @bond_lot.purchased_on) + redirect_back_or_to account_path(@account), notice: t("bond_lots.create.success") + else + render :new, status: :unprocessable_entity + end + end + + def update + @account = @bond_lot.account + return unless require_account_permission!(@account) + + old_purchased_on = @bond_lot.purchased_on + + begin + @bond_lot.update_with_purchase_entry!(bond_lot_params(@bond_lot.bond)) + @bond_lot.account.sync_later(window_start_date: [ old_purchased_on, @bond_lot.purchased_on ].min) + redirect_back_or_to account_path(@account), notice: t("bond_lots.update.success") + rescue ActiveRecord::RecordInvalid => e + @bond_lot.errors.add(:base, e.record.errors.full_messages.to_sentence) if e.record != @bond_lot + template = request.headers["Turbo-Frame"] == "drawer" ? :show : :edit + render template, status: :unprocessable_entity + end + end + + def destroy + return unless require_account_permission!(@bond_lot.account) + + account = @bond_lot.account + sync_start_date = @bond_lot.purchased_on + + @bond_lot.destroy_with_purchase_entry! + + account.sync_later(window_start_date: sync_start_date) + + redirect_back_or_to account_path(account), notice: t("bond_lots.destroy.success") + end + + private + def set_bond_lot + @bond_lot = BondLot.joins(bond: :account) + .where(accounts: { family_id: Current.family.id }) + .merge(Account.accessible_by(Current.user)) + .find(params[:id]) + end + + def bond_lot_params(bond = nil) + params.require(:bond_lot).permit( + :purchased_on, + :issue_date, + :amount, + :units, + :nominal_per_unit, + :term_months, + :interest_rate, + :first_period_rate, + :inflation_margin, + :inflation_rate_assumption, + :cpi_lag_months, + :auto_fetch_inflation, + :inflation_provider, + :auto_close_on_maturity, + :early_redemption_fee, + :subtype, + :product_code, + :rate_type, + :coupon_frequency + ).tap do |permitted| + # Allow tax fields only if bond is not tax-exempt + if !bond&.tax_exempt_wrapper? + permitted.merge!(params.require(:bond_lot).permit(:tax_strategy, :tax_rate)) + end + end + end +end diff --git a/app/controllers/bonds_controller.rb b/app/controllers/bonds_controller.rb new file mode 100644 index 000000000..590f7c304 --- /dev/null +++ b/app/controllers/bonds_controller.rb @@ -0,0 +1,10 @@ +class BondsController < ApplicationController + include AccountableResource + + permitted_accountable_attributes( + :id, + :initial_balance, + :tax_wrapper, + :auto_buy_new_issues + ) +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e0802c1c5..034abef87 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -9,11 +9,18 @@ def dashboard redirect_to chats_path and return end + show_bond_rate_review_notice! + @balance_sheet = Current.family.balance_sheet @investment_statement = Current.family.investment_statement - @accounts = Current.user.accessible_accounts.visible.with_attached_logo + @accounts = Current.user.accessible_accounts.visible.with_attached_logo.includes(:accountable) + @bond_accounts = Current.user.accessible_accounts.visible.where(accountable_type: "Bond").includes(accountable: :bond_lots) family_currency = Current.family.currency + bond_summary = BondLot.dashboard_summary(@bond_accounts, family_currency) + @bond_total_value = bond_summary.total_value + @bond_total_return = bond_summary.total_return + @bond_top_lots = bond_summary.top_lots # Use IncomeStatement for all cashflow data (now includes categorized trades) income_statement = Current.family.income_statement @@ -109,6 +116,14 @@ def build_dashboard_sections visible: @accounts.any? && @investment_statement.investment_accounts.any?, collapsible: true }, + { + key: "bond_summary", + title: "pages.dashboard.bond_summary.title", + partial: "pages/dashboard/bond_summary", + locals: { bond_accounts: @bond_accounts, total_value: @bond_total_value, total_return: @bond_total_return, top_lots: @bond_top_lots }, + visible: @accounts.any? && @bond_accounts.any?, + collapsible: true + }, { key: "net_worth_chart", title: "pages.dashboard.net_worth_chart.title", @@ -341,4 +356,22 @@ def ensure_intro_guest! redirect_to root_path, alert: t("pages.intro.not_authorized", default: "Intro is only available to guest users.") end + + def show_bond_rate_review_notice! + session_key = "bond_rate_review_prompted_#{Current.family&.id}" + return if session[session_key] + + scoped_lots = BondLot + .joins(bond: :account) + .where(accounts: { family_id: Current.family.id }) + .includes(bond: :account) + .merge(Account.visible.accessible_by(Current.user)) + + pending_lots = BondLot.needs_rate_review(scoped_lots).load + return if pending_lots.empty? + + account_names = pending_lots.map { |lot| lot.account&.name }.compact.uniq.first(3).join(", ") + flash.now[:notice] = t("pages.dashboard.bond_rate_review_notice", count: pending_lots.size, accounts: account_names) + session[session_key] = true + end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index 063f2a6b0..ccbfe3d4d 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -14,34 +14,9 @@ class Settings::HostingsController < ApplicationController before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ] before_action :ensure_super_admin_for_onboarding, only: :update + before_action :set_hosting_page_state, only: :show def show - @breadcrumbs = [ - [ "Home", root_path ], - [ "Self-Hosting", nil ] - ] - - # Determine which providers are currently selected - exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider - enabled_securities = Setting.enabled_securities_providers - - # Show provider settings if used for FX or enabled for securities - @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data") - @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance") - @show_tiingo_settings = enabled_securities.include?("tiingo") - @show_eodhd_settings = enabled_securities.include?("eodhd") - @show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage") - - # Only fetch provider data if we're showing the section - if @show_twelve_data_settings - twelve_data_provider = Provider::Registry.get_provider(:twelve_data) - @twelve_data_usage = twelve_data_provider&.usage - @plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData") - end - - if @show_yahoo_finance_settings - @yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance) - end end def update @@ -200,6 +175,7 @@ def update redirect_to settings_hosting_path, notice: t(".success") rescue Setting::ValidationError => error + set_hosting_page_state flash.now[:alert] = error.message render :show, status: :unprocessable_entity end @@ -223,7 +199,60 @@ def disconnect_external_assistant private def hosting_params return ActionController::Parameters.new unless params.key?(:setting) - params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :tiingo_api_key, :eodhd_api_key, :alpha_vantage_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :llm_context_window, :llm_max_response_tokens, :llm_max_items_per_call, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id, securities_providers: []) + + params.require(:setting).permit( + :onboarding_state, + :require_email_confirmation, + :invite_only_default_family_id, + :brand_fetch_client_id, + :brand_fetch_high_res_logos, + :twelve_data_api_key, + :tiingo_api_key, + :eodhd_api_key, + :alpha_vantage_api_key, + :openai_access_token, + :openai_uri_base, + :openai_model, + :openai_json_mode, + :llm_context_window, + :llm_max_response_tokens, + :llm_max_items_per_call, + :exchange_rate_provider, + :securities_provider, + :syncs_include_pending, + :auto_sync_enabled, + :auto_sync_time, + :external_assistant_url, + :external_assistant_token, + :external_assistant_agent_id, + securities_providers: [] + ) + end + + def set_hosting_page_state + @breadcrumbs = [ + [ t("settings.hostings.breadcrumbs.home"), root_path ], + [ t("settings.hostings.breadcrumbs.self_hosting"), nil ] + ] + + exchange_rate_provider = ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider + enabled_securities = Setting.enabled_securities_providers + + @show_twelve_data_settings = exchange_rate_provider == "twelve_data" || enabled_securities.include?("twelve_data") + @show_yahoo_finance_settings = exchange_rate_provider == "yahoo_finance" || enabled_securities.include?("yahoo_finance") + @show_tiingo_settings = enabled_securities.include?("tiingo") + @show_eodhd_settings = enabled_securities.include?("eodhd") + @show_alpha_vantage_settings = enabled_securities.include?("alpha_vantage") + + if @show_twelve_data_settings + twelve_data_provider = Provider::Registry.get_provider(:twelve_data) + @twelve_data_usage = twelve_data_provider&.usage + @plan_restricted_securities = Current.family.securities_with_plan_restrictions(provider: "TwelveData") + end + + if @show_yahoo_finance_settings + @yahoo_finance_provider = Provider::Registry.get_provider(:yahoo_finance) + end end def update_assistant_type diff --git a/app/javascript/controllers/bond_account_form_controller.js b/app/javascript/controllers/bond_account_form_controller.js new file mode 100644 index 000000000..a8c86099a --- /dev/null +++ b/app/javascript/controllers/bond_account_form_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["wrapperSelect", "taxExemptFields"]; + static values = { taxExemptWrappers: Array }; + + connect() { + this.toggleTaxWrapperFields(); + } + + toggleTaxWrapperFields() { + const wrapper = this.wrapperSelectTarget.value; + const enabled = this.taxExemptWrappersValue.includes(wrapper); + + this.taxExemptFieldsTargets.forEach((element) => { + element.classList.toggle("hidden", !enabled); + element.querySelectorAll("input").forEach((input) => { + if (input.type === "checkbox") { + input.checked = enabled ? input.checked : false; + } + }); + }); + } +} \ No newline at end of file diff --git a/app/javascript/controllers/bond_lot_form_controller.js b/app/javascript/controllers/bond_lot_form_controller.js new file mode 100644 index 000000000..10a8ddb76 --- /dev/null +++ b/app/javascript/controllers/bond_lot_form_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["productCodeSelect", "subtypeSelect", "subtypeDerivedHint"] + static values = { + productSubtypeMap: Object, + productTermMap: Object + } + + connect() { + queueMicrotask(() => this.syncSubtypeWithProduct()) + } + + syncSubtypeWithProduct() { + if (!this.hasProductCodeSelectTarget || !this.hasSubtypeSelectTarget) return + + const productCode = this.productCodeSelectTarget.value + const mappedSubtype = this.productSubtypeMapValue?.[productCode] + const subtypeDerived = Boolean(mappedSubtype) + + if (subtypeDerived) { + this.subtypeSelectTarget.value = mappedSubtype + } + + this.subtypeSelectTarget.disabled = subtypeDerived + + if (this.hasSubtypeDerivedHintTarget) { + this.subtypeDerivedHintTarget.classList.toggle("hidden", !subtypeDerived) + } + + this.#syncTermWithProduct(productCode) + this.#inflationController()?.toggleSubtypeFields() + } + + syncIssueDateWithPurchase() { + const purchasedOnInput = this.element.querySelector('input[name="bond_lot[purchased_on]"]') + const issueDateInput = this.element.querySelector('input[name="bond_lot[issue_date]"]') + if (!purchasedOnInput || !issueDateInput) return + + if (!issueDateInput.value && purchasedOnInput.value) { + issueDateInput.value = purchasedOnInput.value + } + } + + #syncTermWithProduct(productCode) { + const termInput = this.element.querySelector('input[name="bond_lot[term_months]"]') + if (!termInput) return + + const mappedTerm = this.productTermMapValue?.[productCode] + const termDerived = mappedTerm !== undefined && mappedTerm !== null && `${mappedTerm}` !== "" + + if (termDerived) { + termInput.value = mappedTerm + } + + termInput.readOnly = termDerived + } + + #inflationController() { + return this.application.getControllerForElementAndIdentifier(this.element, "bond-lot-inflation") + } +} diff --git a/app/javascript/controllers/bond_lot_inflation_controller.js b/app/javascript/controllers/bond_lot_inflation_controller.js new file mode 100644 index 000000000..30f0da2f9 --- /dev/null +++ b/app/javascript/controllers/bond_lot_inflation_controller.js @@ -0,0 +1,87 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "inflationFields", + "otherFields", + "inflationInput", + "otherRequiredInput", + "manualInflationField", + "manualInflationInput" + ] + + static values = { + inflationSubtypes: Array + } + + connect() { + this.toggleSubtypeFields() + } + + toggleSubtypeFields() { + const subtype = this.#subtypeValue() + const inflationLinked = this.inflationSubtypesValue.includes(subtype) + const firstPeriodRateRequired = this.#firstPeriodRateRequired() + + this.inflationFieldsTargets.forEach((element) => { + element.classList.toggle("hidden", !inflationLinked) + }) + + this.otherFieldsTargets.forEach((element) => { + element.classList.toggle("hidden", inflationLinked) + }) + + this.inflationInputTargets.forEach((input) => { + input.disabled = !inflationLinked + if (input.dataset.requiresFirstPeriodCheck) { + input.required = inflationLinked && firstPeriodRateRequired + } else { + input.required = inflationLinked && !input.dataset.optional + } + }) + + this.otherRequiredInputTargets.forEach((input) => { + input.disabled = inflationLinked + input.required = !inflationLinked + }) + + this.toggleManualInflationField() + } + + toggleManualInflationField() { + if (!this.hasManualInflationFieldTarget || !this.hasManualInflationInputTarget) return + + const inflationLinked = this.inflationSubtypesValue.includes(this.#subtypeValue()) + + this.manualInflationFieldTarget.classList.toggle("hidden", !inflationLinked) + this.manualInflationInputTarget.disabled = !inflationLinked + this.manualInflationInputTarget.required = inflationLinked + } + + #subtypeValue() { + const input = this.element.querySelector('select[name="bond_lot[subtype]"]') + return `${input?.value || ""}` + } + + #firstPeriodRateRequired() { + const purchasedOnInput = this.element.querySelector('input[name="bond_lot[purchased_on]"]') + const issueDateInput = this.element.querySelector('input[name="bond_lot[issue_date]"]') + const purchasedOn = this.#parseDate(purchasedOnInput?.value) + const issueDate = this.#parseDate(issueDateInput?.value) + + if (!purchasedOn) return false + + const baseDate = issueDate || purchasedOn + const firstPeriodEnd = new Date(baseDate) + firstPeriodEnd.setFullYear(firstPeriodEnd.getFullYear() + 1) + + return purchasedOn < firstPeriodEnd + } + + #parseDate(value) { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + +} \ No newline at end of file diff --git a/app/jobs/settle_matured_bond_lots_job.rb b/app/jobs/settle_matured_bond_lots_job.rb new file mode 100644 index 000000000..1e47a8380 --- /dev/null +++ b/app/jobs/settle_matured_bond_lots_job.rb @@ -0,0 +1,22 @@ +class SettleMaturedBondLotsJob < ApplicationJob + queue_as :scheduled + + def perform(on: Date.current) + errors = [] + + BondLot.open + .where(auto_close_on_maturity: true) + .where("maturity_date <= ?", on) + .includes(bond: :account) + .find_each do |lot| + lot.settle_if_matured!(on:) + rescue StandardError => e + Rails.logger.error( + "SettleMaturedBondLotsJob failed for lot_id=#{lot.id} account_id=#{lot.account.id}: #{e.class}: #{e.message}" + ) + errors << e + end + + raise errors.first if errors.any? + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 8a31a8d5e..8fdb69800 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -371,6 +371,13 @@ def long_subtype_label accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end + # Bond-specific helper for rendering tax wrapper where needed. + def bond_wrapper_label(format: :short) + return unless bond? && accountable.respond_to?(:wrapper_label) + + accountable.wrapper_label(format: format) + end + def supports_default? depository? || credit_card? end @@ -406,7 +413,7 @@ def balance_type :cash when "Property", "Vehicle", "OtherAsset", "Loan", "OtherLiability" :non_cash - when "Investment", "Crypto" + when "Investment", "Crypto", "Bond" :investment else raise "Unknown account type: #{accountable_type}" diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index a1e43d99e..67069e632 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -16,7 +16,54 @@ def sync_cache def holdings_value_for_date(date) @holdings_value_for_date ||= {} - @holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount) + return @holdings_value_for_date[date] if @holdings_value_for_date.key?(date) + + @holdings_value_for_date[date] = if account.bond? + bond_holdings_value_for_date(date) + else + sync_cache.get_holdings(date).sum(&:amount) + end + end + + def bond_holdings_value_for_date(date) + return 0.to_d if bond_lot_change_dates.empty? + + next_change_index = bond_lot_change_dates.bsearch_index { |change_date| change_date > date } + + if next_change_index.nil? + bond_lot_running_totals.last || 0.to_d + elsif next_change_index.zero? + 0.to_d + else + bond_lot_running_totals[next_change_index - 1] + end + end + + def bond_lot_change_dates + @bond_lot_change_dates ||= bond_lot_changes_by_date.keys.sort + end + + def bond_lot_running_totals + @bond_lot_running_totals ||= begin + running_total = 0.to_d + + bond_lot_change_dates.map do |change_date| + running_total += bond_lot_changes_by_date[change_date] + end + end + end + + def bond_lot_changes_by_date + @bond_lot_changes_by_date ||= bond_lots_for_holdings.each_with_object(Hash.new(0.to_d)) do |lot, changes| + amount = lot.amount.to_d + + changes[lot.purchased_on] += amount + changes[lot.closed_on] -= amount if lot.closed_on.present? + end + end + + def bond_lots_for_holdings + @bond_lots_for_holdings ||= account.bond.bond_lots.select(:purchased_on, :closed_on, :amount).to_a end def derive_cash_balance_on_date_from_total(total_balance:, date:) @@ -74,12 +121,21 @@ def flows_for_date(date) non_cash_inflows = txn_inflow_sum.abs non_cash_outflows = txn_outflow_sum elsif account.balance_type != :non_cash + bond_lot_cash_inflow_sum = 0 + bond_lot_cash_outflow_sum = 0 + if account.bond? + bond_lot_transaction_entries = entries.select { |e| bond_lot_transaction_entry?(e) } + bond_lot_cash_inflow_sum = bond_lot_transaction_entries.select { |e| e.amount < 0 }.sum(&:amount) + bond_lot_cash_outflow_sum = bond_lot_transaction_entries.select { |e| e.amount >= 0 }.sum(&:amount) + end + cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs cash_outflows = txn_outflow_sum + trade_cash_outflow_sum - # Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings") - non_cash_outflows = trade_cash_inflow_sum.abs - non_cash_inflows = trade_cash_outflow_sum + # Trades and bond lot-linked transactions are inverse (a "buy" is outflow of cash, + # but "inflow" of non-cash, aka holdings). + non_cash_outflows = trade_cash_inflow_sum.abs + bond_lot_cash_inflow_sum.abs + non_cash_inflows = trade_cash_outflow_sum + bond_lot_cash_outflow_sum end { @@ -137,4 +193,11 @@ def build_balance(date:, **args) flows_factor: account.classification == "asset" ? 1 : -1 ) end + + def bond_lot_transaction_entry?(entry) + return false unless entry.transaction? + + extra = entry.entryable&.extra + extra.is_a?(Hash) && extra["bond_lot_id"].present? + end end diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index 582774c38..915ed485d 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -29,6 +29,7 @@ def holdings_by_date def converted_entries @converted_entries ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e| converted_entry = e.dup + converted_entry.entryable = e.entryable if e.association(:entryable).loaded? # Extract custom exchange rate if present on Transaction custom_rate = if e.entryable.is_a?(Transaction) diff --git a/app/models/bond.rb b/app/models/bond.rb new file mode 100644 index 000000000..ef5f81f58 --- /dev/null +++ b/app/models/bond.rb @@ -0,0 +1,213 @@ +class Bond < ApplicationRecord + include Accountable + + has_many :bond_lots, dependent: :destroy + + TAX_WRAPPERS = { + "none" => { short: "Standard", long: "Standard" }, + "ike" => { short: "IKE", long: "IKE" }, + "ikze" => { short: "IKZE", long: "IKZE" } + }.freeze + + before_validation :assign_maturity_date_from_term + before_validation :normalize_tax_wrapper_settings + before_validation :normalize_legacy_subtype + + 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" } + }.freeze + + LEGACY_SUBTYPE_ALIASES = { + "eod" => "inflation_linked", + "rod" => "inflation_linked", + "other_bond" => "other" + }.freeze + + INFLATION_LINKED_SUBTYPES = %w[inflation_linked].freeze + + PRODUCT_DEFAULTS = { + "us_t_bill_4w" => { + subtype: "zero_coupon", + term_months: 1, + rate_type: "fixed", + coupon_frequency: "at_maturity" + }, + "us_t_bill_52w" => { + subtype: "zero_coupon", + term_months: 12, + rate_type: "fixed", + coupon_frequency: "at_maturity" + }, + "us_t_note_2y" => { + subtype: "fixed_coupon", + term_months: 24, + rate_type: "fixed", + coupon_frequency: "semi_annual" + }, + "us_t_note_10y" => { + subtype: "fixed_coupon", + term_months: 120, + rate_type: "fixed", + coupon_frequency: "semi_annual" + }, + "us_tips_10y" => { + subtype: "inflation_linked", + term_months: 120, + rate_type: "variable", + coupon_frequency: "semi_annual", + cpi_lag_months: 3, + inflation_provider: "us_bls" + }, + "us_i_bond" => { + subtype: "inflation_linked", + term_months: 120, + rate_type: "variable", + coupon_frequency: "at_maturity", + cpi_lag_months: 6, + inflation_provider: "us_bls" + }, + "es_letra_3m" => { + subtype: "zero_coupon", + term_months: 3, + rate_type: "fixed", + coupon_frequency: "at_maturity" + }, + "es_letra_6m" => { + subtype: "zero_coupon", + term_months: 6, + rate_type: "fixed", + coupon_frequency: "at_maturity" + }, + "es_letra_12m" => { + subtype: "zero_coupon", + term_months: 12, + rate_type: "fixed", + coupon_frequency: "at_maturity" + }, + "pl_eod" => { + subtype: "inflation_linked", + term_months: 120, + rate_type: "variable", + coupon_frequency: "at_maturity", + cpi_lag_months: 2 + }, + "pl_rod" => { + subtype: "inflation_linked", + term_months: 144, + rate_type: "variable", + coupon_frequency: "at_maturity", + cpi_lag_months: 2 + } + }.freeze + + PRODUCT_LABELS = { + "us_t_bill_4w" => "US T-Bill (4 weeks)", + "us_t_bill_52w" => "US T-Bill (52 weeks)", + "us_t_note_2y" => "US T-Note (2 years)", + "us_t_note_10y" => "US T-Note (10 years)", + "us_tips_10y" => "US TIPS (10 years)", + "us_i_bond" => "US I Bond", + "es_letra_3m" => "ES Letra del Tesoro (3 months)", + "es_letra_6m" => "ES Letra del Tesoro (6 months)", + "es_letra_12m" => "ES Letra del Tesoro (12 months)", + "pl_eod" => "PL EOD (10 years)", + "pl_rod" => "PL ROD (12 years)" + }.freeze + + RATE_TYPES = %w[fixed variable].freeze + COUPON_FREQUENCIES = %w[monthly quarterly semi_annual annual at_maturity].freeze + + validates :subtype, inclusion: { in: SUBTYPES.keys }, allow_nil: true + validates :rate_type, inclusion: { in: RATE_TYPES }, allow_nil: true + validates :coupon_frequency, inclusion: { in: COUPON_FREQUENCIES }, allow_nil: true + validates :tax_wrapper, inclusion: { in: TAX_WRAPPERS.keys } + + def original_balance + total = bond_lots.sum(:amount) + return Money.new(total, account.currency) if total.positive? + + fallback = account.first_valuation_amount + Money.new(fallback.amount, fallback.currency) + end + + def holdings_balance + total = 0.to_d + BondLot.with_inflation_lookup_cache do + bond_lots.open.find_each(batch_size: 200) do |lot| + total += lot.estimated_current_value(allow_import: false) + end + end + Money.new(total, account.currency) + end + + def settle_matured_lots!(on: Date.current) + bond_lots.open.find_in_batches(batch_size: 1000) do |batch| + batch.each do |lot| + lot.settle_if_matured!(on:) + end + end + end + + def tax_exempt_wrapper? + tax_wrapper.in?(%w[ike ikze]) + end + + def default_tax_strategy + tax_exempt_wrapper? ? "exempt" : "standard" + end + + def pending_rate_review_lots + BondLot.needs_rate_review(BondLot.where(bond: self)) + end + + def wrapper_label(format: :short) + label_type = format == :long ? :long : :short + TAX_WRAPPERS.dig(tax_wrapper, label_type) + end + + class << self + def color + "#2BBB0E" + end + + def icon + "badge-percent" + end + + def classification + "asset" + end + + def display_name + I18n.t("accounts.sidebar.types.bond", default: super) + end + + def product_options_for_select + PRODUCT_DEFAULTS.keys.map { |code| [ PRODUCT_LABELS.fetch(code, code.humanize), code ] } + end + end + + def inflation_linked? + subtype&.in?(INFLATION_LINKED_SUBTYPES) || false + end + + private + def normalize_legacy_subtype + self.subtype = LEGACY_SUBTYPE_ALIASES.fetch(subtype, subtype) if subtype.present? + end + + def normalize_tax_wrapper_settings + self.tax_wrapper = "none" if tax_wrapper.blank? + self.auto_buy_new_issues = false unless tax_exempt_wrapper? + end + + def assign_maturity_date_from_term + return if term_months.blank? || maturity_date.present? + + self.maturity_date = Date.current + term_months.months + end +end diff --git a/app/models/bond/inflation_provider.rb b/app/models/bond/inflation_provider.rb new file mode 100644 index 000000000..ce60393ce --- /dev/null +++ b/app/models/bond/inflation_provider.rb @@ -0,0 +1,162 @@ +module Bond::InflationProvider + InflationRecord = Data.define(:year, :month, :rate_yoy) + DEFAULT_PROVIDER_BY_LOCALE = { + "pl" => "gus_sdp", + "es" => "es_ine", + "us" => "us_bls", + "en-us" => "us_bls" + }.freeze + + PROVIDERS = { + "gus_sdp" => "Provider::GusSdp", + "us_bls" => "Provider::UsBlsCpi", + "es_ine" => "Provider::EsIneCpi" + }.freeze + + module_function + + def valid?(provider) + provider.blank? || PROVIDERS.key?(provider) + end + + def key_for(provider) + provider.presence || "gus_sdp" + end + + def default_provider_for(account: nil, bond: nil, lot: nil, product_code: nil, locale: nil) + resolved_product_code = product_code.presence || lot&.product_code + provider_for_product_code(resolved_product_code) || + provider_for_locale(locale.presence || lot_locale(lot:, bond:, account:)) || + "gus_sdp" + end + + def record_for_date(provider:, date:, lag_months: 0, allow_import: true) + provider_key = key_for(provider) + return nil unless PROVIDERS.key?(provider_key) + + if provider_key == "gus_sdp" + record = GusInflationRate.for_date(date: date, lag_months: lag_months) + if record.nil? && allow_import && automatic_import_enabled?(provider_key) + target_date = date.beginning_of_month - lag_months.to_i.months + GusInflationRate.import_year!(year: target_date.year) + record = GusInflationRate.for_date(date: date, lag_months: lag_months) + end + return nil if record.nil? + return InflationRecord.new(year: record.year, month: record.month, rate_yoy: record.rate_yoy) + end + + source_key = provider_key + persisted = InflationRate.for_date(source: source_key, date: date, lag_months: lag_months) + if persisted.present? + return InflationRecord.new(year: persisted.year, month: persisted.month, rate_yoy: persisted.rate_yoy) + end + return nil unless allow_import + return nil if provider_key == "es_ine" && es_ine_series_id.blank? + + provider_klass = provider_class(provider_key) + provider_instance = provider_instance_for(provider_key, provider_klass) + return nil if provider_instance.blank? + + target_date = date.beginning_of_month - lag_months.to_i.months + InflationRate.import_year!( + source: source_key, + provider: provider_instance, + year: target_date.year + ) + + result = InflationRate.for_date(source: source_key, date: date, lag_months: lag_months) + return nil if result.nil? + InflationRecord.new(year: result.year, month: result.month, rate_yoy: result.rate_yoy) + rescue Faraday::Error, Provider::Error, ActiveRecord::RecordInvalid, RuntimeError => e + Rails.logger.warn("[Bond::InflationProvider] record_for_date failed: #{e.class} - #{e.message}") + nil + end + + # automatic_import_enabled? intentionally differs by key_for(provider): + # - "gus_sdp" respects Setting.inflation_import_enabled_effective + # - "es_ine" requires configured es_ine_series_id + # - "us_bls" is always enabled because it uses public defaults and needs no tenant-specific setup + def automatic_import_enabled?(provider) + case key_for(provider) + when "gus_sdp" + Setting.inflation_import_enabled_effective + when "es_ine" + es_ine_series_id.present? + when "us_bls" + true + else + false + end + end + + def stats_for(provider) + provider_key = key_for(provider) + + if provider_key == "gus_sdp" + GusInflationRate.stats + else + InflationRate.stats_for(source: provider_key) + end + end + + def provider_class(provider) + klass_name = PROVIDERS[key_for(provider)] + return nil if klass_name.blank? + + klass_name.constantize + end + + def provider_instance_for(provider_key, provider_klass) + return nil if provider_klass.blank? + + case provider_key + when "us_bls" + provider_klass.new( + base_url: ENV["US_BLS_CPI_BASE_URL"].presence || Setting.us_bls_cpi_base_url.presence || Provider::UsBlsCpi::DEFAULT_BASE_URL, + series_id: ENV["US_BLS_CPI_SERIES_ID"].presence || Setting.us_bls_cpi_series_id.presence || Provider::UsBlsCpi::DEFAULT_SERIES_ID + ) + when "es_ine" + provider_klass.new( + base_url: ENV["ES_INE_CPI_BASE_URL"].presence || Setting.es_ine_cpi_base_url.presence || Provider::EsIneCpi::DEFAULT_BASE_URL, + series_id: ENV["ES_INE_CPI_SERIES_ID"].presence || Setting.es_ine_cpi_series_id.presence + ) + else + provider_klass.new + end + end + + def es_ine_series_id + ENV["ES_INE_CPI_SERIES_ID"].presence || Setting.es_ine_cpi_series_id.presence + end + + def provider_for_product_code(product_code) + return nil if product_code.blank? + + Bond::PRODUCT_DEFAULTS.dig(product_code, :inflation_provider) || + case product_code.to_s + when /^pl_/ + "gus_sdp" + when /^us_/ + "us_bls" + when /^es_/ + "es_ine" + end + end + + def provider_for_locale(locale) + normalized_locale = locale.to_s.strip.downcase + return nil if normalized_locale.blank? + + DEFAULT_PROVIDER_BY_LOCALE[normalized_locale] || + DEFAULT_PROVIDER_BY_LOCALE[normalized_locale.split(/[_-]/).first] + end + + def lot_locale(lot:, bond:, account:) + lot&.account&.family&.locale.presence || + bond&.account&.family&.locale.presence || + account&.family&.locale.presence || + Current.family&.locale.presence || + Current.user&.family&.locale.presence || + I18n.locale + end +end diff --git a/app/models/bond_lot.rb b/app/models/bond_lot.rb new file mode 100644 index 000000000..f22b2c792 --- /dev/null +++ b/app/models/bond_lot.rb @@ -0,0 +1,870 @@ +class BondLot < ApplicationRecord + attr_accessor :_preserve_coupon_frequency + + belongs_to :bond + belongs_to :entry, optional: true + + TAX_STRATEGIES = %w[standard reduced exempt].freeze + DEFAULT_TAX_RATE_PERCENT = 19 + TOP_LOTS_LIMIT = 5 + + scope :open, -> { where(closed_on: nil) } + + def self.needs_rate_review(scope = all) + with_inflation_lookup_cache do + unresolved_ids = [] + scoped_relation = scope.open.where(subtype: Bond::INFLATION_LINKED_SUBTYPES) + scoped_relation.includes(:bond).find_in_batches(batch_size: 200) do |batch| + batch.each do |lot| + review_on = [ Date.current, lot.maturity_date ].compact.min + unresolved_ids << lot.id unless lot.rate_review_complete?(on: review_on) + end + end + + ids = unresolved_ids.uniq + ids.empty? ? scope.none : scope.open.where(id: ids) + end + end + + # Returns an OpenStruct with :total_value, :total_return, :top_lots + # for the dashboard summary card. + def self.dashboard_summary(bond_accounts, family_currency) + with_inflation_lookup_cache do + lots_relation = open + .joins(bond: :account) + .includes(bond: :account) + .where(accounts: { id: bond_accounts.select(:id) }) + + total_value = 0.to_d + total_return = 0.to_d + top_enriched = [] + + lots_relation.find_each(batch_size: 200) do |lot| + account = lot.account + lot_value = lot.estimated_current_value(allow_import: false).to_d + lot_return = lot_value - lot.amount.to_d + converted_value = Money.new(lot_value, account.currency).exchange_to(family_currency).amount + converted_return = Money.new(lot_return, account.currency).exchange_to(family_currency).amount + + total_value += converted_value + total_return += converted_return + + top_enriched << [ account, lot, converted_value ] + if top_enriched.size > TOP_LOTS_LIMIT + top_enriched.sort_by! { |_, _, cv| cv } + top_enriched.shift + end + end + + top_lots = top_enriched + .sort_by { |_, _, cv| -cv } + .map { |account, lot, _| [ account, lot ] } + + OpenStruct.new(total_value: total_value, total_return: total_return, top_lots: top_lots) + end + end + + def self.with_inflation_lookup_cache + previous_cache = Thread.current[:bond_inflation_record_cache] + Thread.current[:bond_inflation_record_cache] = {} + yield + ensure + Thread.current[:bond_inflation_record_cache] = previous_cache + end + + before_validation :inherit_defaults_from_bond + before_validation :normalize_legacy_subtype + before_validation :normalize_subtype_from_product + before_validation :apply_product_defaults + before_validation :assign_maturity_date_from_term + before_validation :derive_amount_from_units + before_validation :normalize_auto_fetch_inflation + before_validation :normalize_inflation_provider + before_validation :normalize_tax_settings + before_validation :clear_rate_review_flag + + after_commit :settle_if_already_matured!, on: %i[create update], if: :should_settle_if_already_matured? + + validates :purchased_on, :amount, :subtype, presence: true + validates :auto_fetch_inflation, inclusion: { in: [ true, false ] } + validates :amount, numericality: { greater_than: 0 } + validates :term_months, presence: true + validates :term_months, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :interest_rate, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :first_period_rate, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :inflation_margin, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :inflation_rate_assumption, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :early_redemption_fee, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :units, numericality: { greater_than: 0 }, allow_nil: true + validates :nominal_per_unit, numericality: { greater_than: 0 }, allow_nil: true + validates :cpi_lag_months, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true + validates :subtype, inclusion: { in: Bond::SUBTYPES.keys } + validates :product_code, inclusion: { in: Bond::PRODUCT_DEFAULTS.keys }, allow_blank: true + validates :inflation_provider, inclusion: { in: %w[gus_sdp us_bls es_ine] }, allow_blank: true + validates :rate_type, inclusion: { in: Bond::RATE_TYPES }, allow_nil: true + validates :coupon_frequency, inclusion: { in: Bond::COUPON_FREQUENCIES }, allow_nil: true + validates :tax_strategy, inclusion: { in: TAX_STRATEGIES } + validates :tax_rate, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }, allow_nil: true + validates :entry_id, uniqueness: true, allow_nil: true + validate :validate_issue_date_not_after_purchased_on + validate :validate_maturity_date_not_before_purchased_on + + with_options if: :inflation_linked? do + validates :issue_date, presence: true + validates :units, presence: true + validates :nominal_per_unit, presence: true + validates :first_period_rate, presence: true, if: -> { needs_first_period_rate? && !requires_rate_review? } + validates :inflation_margin, presence: true, unless: :requires_rate_review? + validates :cpi_lag_months, presence: true + validates :inflation_rate_assumption, presence: true, unless: -> { auto_fetch_inflation? || requires_rate_review? } + end + + with_options unless: :inflation_linked? do + validates :interest_rate, presence: true, unless: -> { requires_rate_review? || inflation_linked_selection? } + validates :rate_type, presence: true + validates :coupon_frequency, presence: true + end + + delegate :account, to: :bond + + def open? + closed_on.blank? + end + + def matured?(on: Date.current) + maturity_date.present? && on >= maturity_date + end + + def inflation_linked? + canonical_subtype.in?(Bond::INFLATION_LINKED_SUBTYPES) + end + + def inflation_linked_selection? + return true if inflation_linked? + + preset_subtype = Bond::PRODUCT_DEFAULTS.dig(product_code, :subtype) + preset_subtype == "inflation_linked" + end + + def auto_fetch_inflation? + inflation_linked? && auto_fetch_inflation + end + + def in_first_rate_period?(on: Date.current) + return false if purchased_on.blank? + + period_base = issue_date.presence || purchased_on + on < period_base + 1.year + end + + def current_cpi_reference_on(on: Date.current) + return nil unless inflation_linked? + + rate_period_start = current_rate_period_start(on:) + return nil if rate_period_start.blank? + + rate_period_start.beginning_of_month - cpi_lag_months.to_i.months + end + + def needs_first_period_rate?(on: purchased_on || Date.current) + inflation_linked? && in_first_rate_period?(on:) + end + + def estimated_current_value(on: Date.current, allow_import: true) + principal = cashflow_principal + return principal if principal.zero? || purchased_on.blank? + + period_end = [ on, maturity_date ].compact.min + return principal if period_end.blank? || period_end <= purchased_on + + value = principal + unpaid_coupon_accrual = 0.to_d + cursor = purchased_on + issue_base = anniversary_issue_base + + while cursor < period_end + next_accrual_boundary, _accrual_start = accrual_boundaries(cursor:, issue_base:) + next_anniversary, anniversary_start = anniversary_boundaries(cursor:, issue_base:) + + next_cursor = [ next_accrual_boundary, period_end ].min + days_in_step = [ (next_cursor - cursor).to_i, 0 ].max + break if days_in_step.zero? + + annual_rate_decimal = annual_rate_for(on: cursor, allow_import:) + break if annual_rate_decimal.blank? + + days_in_year = [ (next_anniversary - anniversary_start).to_i, 1 ].max + interest_earned = value * annual_rate_decimal * (days_in_step.to_d / days_in_year) + if coupon_reinvested? + value += interest_earned + else + unpaid_coupon_accrual += interest_earned + unpaid_coupon_accrual = 0.to_d if coupon_paid_before_maturity?(next_cursor:, next_accrual_boundary:) + end + + cursor = next_cursor + end + + value + unpaid_coupon_accrual + end + + def total_return_amount(on: Date.current, allow_import: true) + estimated_current_value(on:, allow_import:) - amount.to_d + end + + def total_return_percent(on: Date.current, allow_import: true) + principal = amount.to_d + return 0 if principal.zero? + + (total_return_amount(on:, allow_import:) / principal) * 100 + end + + def projected_total_return_amount(allow_import: true) + maturity = maturity_date || (purchased_on + term_months.to_i.months if term_months.present?) + return 0.to_d if maturity.blank? + + estimated_current_value(on: maturity, allow_import:) - amount.to_d + end + + def projected_total_return_percent(allow_import: true) + principal = amount.to_d + return 0 if principal.zero? + + (projected_total_return_amount(allow_import:) / principal) * 100 + end + + def coupon_amount_per_period(on: Date.current, allow_import: false) + return nil if coupon_frequency.blank? || coupon_frequency == "at_maturity" + + periods = { + "monthly" => 12, + "quarterly" => 4, + "semi_annual" => 2, + "annual" => 1 + } + per_year = periods[coupon_frequency] + return nil if per_year.blank? + + annual_rate_decimal = if inflation_linked? + annual_rate_for(on:, allow_import:) + else + interest_rate&.to_d&./(100) + end + return nil if annual_rate_decimal.blank? + + Money.new((cashflow_principal * annual_rate_decimal / per_year).round(4), account.currency) + end + + def create_purchase_entry!(auto_purchased: false, requires_rate_review: false) + raise ArgumentError, "BondLot must be persisted before creating purchase entry" unless persisted? + + with_lock do + return entry if entry.present? + + ActiveRecord::Base.transaction do + created_entry = account.entries.create!( + date: purchased_on, + name: I18n.t("bond_lots.activity.purchase_name", subtype: subtype_label), + amount: amount, + currency: account.currency, + entryable: Transaction.new( + kind: :funds_movement, + extra: purchase_entry_extra(auto_purchased:, requires_rate_review:) + ) + ) + + created_entry.lock_saved_attributes! + created_entry.mark_user_modified! + + update!(entry: created_entry) + created_entry + end + end + end + + def save_with_purchase_entry! + ActiveRecord::Base.transaction do + save! + create_purchase_entry! + end + end + + def update_purchase_entry! + return unless entry + + existing_extra = entry.entryable&.extra || {} + entry.update!( + date: purchased_on, + name: I18n.t("bond_lots.activity.purchase_name", subtype: subtype_label), + amount: amount, + entryable_attributes: { + id: entry.entryable_id, + extra: existing_extra.merge(purchase_entry_extra) + } + ) + entry.lock_saved_attributes! + entry.mark_user_modified! + end + + def update_with_purchase_entry!(attributes) + with_lock do + ActiveRecord::Base.transaction do + update!(attributes) + update_purchase_entry! + end + end + end + + def destroy_with_purchase_entry! + ActiveRecord::Base.transaction do + purchase_entry = entry + + destroy! + purchase_entry.destroy! if purchase_entry && !purchase_entry.destroyed? + end + end + + def current_rate_percent(on: Date.current, allow_import: true) + annual_rate_for(on:, allow_import:)&.*(100) + end + + def current_inflation_component_percent(on: Date.current, allow_import: true) + return nil unless inflation_linked? + + rate_context_for(on:, allow_import:)[:inflation_component_percent] + end + + def current_inflation_source(on: Date.current, allow_import: true) + return nil unless inflation_linked? + + source = rate_context_for(on:, allow_import:)[:inflation_source] + source == "first_period" ? nil : source + end + + def gus_inflation_source?(on: Date.current, allow_import: true) + current_inflation_source(on:, allow_import:) == "gus_sdp" + end + + def current_margin_percent(on: Date.current, allow_import: true) + return nil unless inflation_linked? + + rate_context_for(on:, allow_import:)[:margin_component_percent] + end + + def current_inflation_indicator_id + nil + end + + def settlement_tax_rate_percent + return 0.to_d if tax_strategy == "exempt" + + rate = tax_rate.presence || DEFAULT_TAX_RATE_PERCENT + rate.to_d + end + + def settle_if_matured!(on: Date.current) + settlement_date_for_sync = nil + + # Lock the row to prevent concurrent settlements. + settled = with_lock do + return false unless auto_close_on_maturity? + return false unless open? + return false unless matured?(on:) + + settlement_date = [ on, maturity_date ].compact.min + + # Abort if any rate period cannot be resolved — prevents closing the lot with a wrong value. + unless rates_resolvable_through?(date: settlement_date) + update_column(:requires_rate_review, true) + return false + end + + gross_value = estimated_current_value(on: settlement_date) + gain = [ gross_value - amount.to_d, 0.to_d ].max + tax_withheld_amount = (gain * settlement_tax_rate_percent / 100).round(4) + net_value = (gross_value - tax_withheld_amount).round(4) + + ActiveRecord::Base.transaction do + create_settlement_entry!(settlement_date:, net_value:, tax_withheld_amount:, gross_value:) + update!( + closed_on: settlement_date, + settlement_amount: net_value, + tax_withheld: tax_withheld_amount + ) + create_reinvestment_lot!(settlement_date:, net_value:) if should_auto_buy_new_issue?(net_value:) + end + settlement_date_for_sync = settlement_date + + Rails.logger.info( + "[BondSettlement] Settled lot_id=#{id} account_id=#{account.id}" + ) + true + end + + account.sync_later(window_start_date: settlement_date_for_sync) if settled + settled + end + + def capitalization_history(on: Date.current) + principal = cashflow_principal + return [] if principal.zero? || purchased_on.blank? + + history_end = [ on, maturity_date, closed_on ].compact.min + return [] if history_end.blank? || history_end <= purchased_on + + events = [] + period_number = 1 + opening_balance = principal + cursor = purchased_on + issue_base = anniversary_issue_base + + while cursor < history_end + next_accrual_boundary, _accrual_start = accrual_boundaries(cursor:, issue_base:) + next_anniversary, anniversary_start = anniversary_boundaries(cursor:, issue_base:) + + next_cursor = [ next_accrual_boundary, history_end ].min + days_in_step = [ (next_cursor - cursor).to_i, 0 ].max + break if days_in_step.zero? + + rate_context = rate_context_for(on: cursor, allow_import: false) + annual_rate_decimal = rate_context[:annual_rate_decimal] + break if annual_rate_decimal.blank? + + days_in_year = [ (next_anniversary - anniversary_start).to_i, 1 ].max + full_year_capitalization = coupon_reinvested? && (days_in_step == days_in_year) + interest_earned = opening_balance * annual_rate_decimal * (days_in_step.to_d / days_in_year) + + closing_balance = coupon_reinvested? ? opening_balance + interest_earned : opening_balance + + events << { + period_number: period_number, + start_on: cursor, + end_on: next_cursor, + annual_rate_percent: annual_rate_decimal * 100, + inflation_component_percent: rate_context[:inflation_component_percent], + margin_component_percent: rate_context[:margin_component_percent], + inflation_source: rate_context[:inflation_source], + inflation_reference_on: rate_context[:inflation_reference_on], + inflation_indicator_id: rate_context[:inflation_indicator_id], + opening_balance: opening_balance, + interest_earned: interest_earned, + closing_balance: closing_balance, + full_year_capitalization: full_year_capitalization + } + + opening_balance = closing_balance + cursor = next_cursor + period_number += 1 + end + + events + end + + def rate_review_complete?(on: Date.current) + rates_present_for_review?(on:) && rates_resolvable_through?(date: on, allow_import: false) + end + + private + def coupon_reinvested? + coupon_frequency.to_s == "at_maturity" + end + + def coupon_paid_before_maturity?(next_cursor:, next_accrual_boundary:) + next_cursor == next_accrual_boundary && maturity_date.present? && next_cursor < maturity_date + end + + def rate_context_for(on:, allow_import: true) + if inflation_linked? + inflation_linked_rate_context(on:, allow_import:) + else + annual_rate = interest_rate.presence || bond&.interest_rate + { + annual_rate_decimal: annual_rate&.to_d&./(100), + inflation_component_percent: nil, + margin_component_percent: nil, + inflation_source: nil, + inflation_reference_on: nil, + inflation_indicator_id: nil + } + end + end + + def annual_rate_for(on:, allow_import: true) + rate_context_for(on:, allow_import:)[:annual_rate_decimal] + end + + def anniversary_issue_base + (inflation_linked? && issue_date.present?) ? issue_date : purchased_on + end + + def current_rate_period_start(on:) + return nil if purchased_on.blank? + + issue_base = anniversary_issue_base + years_since = 0 + years_since += 1 while issue_base + (years_since + 1).years <= on + issue_base + years_since.years + end + + # Returns [next_anniversary, anniversary_start] for the period containing cursor. + def anniversary_boundaries(cursor:, issue_base:) + years_since = 0 + years_since += 1 while issue_base + years_since.years <= cursor + [ issue_base + years_since.years, issue_base + (years_since - 1).years ] + end + + # Returns [next_accrual_boundary, accrual_start] for the period containing cursor. + def accrual_boundaries(cursor:, issue_base:) + months = accrual_period_months + periods_since = 0 + periods_since += 1 while issue_base + ((periods_since + 1) * months).months <= cursor + [ issue_base + ((periods_since + 1) * months).months, issue_base + (periods_since * months).months ] + end + + def accrual_period_months + { + "monthly" => 1, + "quarterly" => 3, + "semi_annual" => 6, + "annual" => 12, + "at_maturity" => 12 + }.fetch(coupon_frequency.to_s, 12) + end + + def inflation_linked_rate_context(on:, allow_import: true) + if purchased_on.blank? + return { + annual_rate_decimal: nil, + inflation_component_percent: nil, + margin_component_percent: nil, + inflation_source: nil, + inflation_reference_on: nil, + inflation_indicator_id: nil + } + end + + rate_period_start = current_rate_period_start(on:) + return { annual_rate_decimal: nil } if rate_period_start.blank? + + if needs_first_period_rate?(on: rate_period_start) + { + annual_rate_decimal: first_period_rate&.to_d&./(100), + inflation_component_percent: nil, + margin_component_percent: nil, + inflation_source: "first_period", + inflation_reference_on: nil, + inflation_indicator_id: nil + } + else + inflation_snapshot = inflation_snapshot_for(on: rate_period_start, allow_import:) + inflation_component = inflation_snapshot[:inflation_component_percent] + margin_component = inflation_margin&.to_d + + # Cannot compute rate without inflation component or margin — do not coerce nil values to 0. + return { annual_rate_decimal: nil } if inflation_component.nil? || margin_component.nil? + + raw_rate = (inflation_component + margin_component) / 100 + # Apply 0% floor only for products where deflation protection applies (e.g. Polish treasury bonds). + annual_rate = deflation_floor_applies? ? [ raw_rate, 0.to_d ].max : raw_rate + + { + annual_rate_decimal: annual_rate, + inflation_component_percent: inflation_component, + margin_component_percent: margin_component, + inflation_source: inflation_snapshot[:source], + inflation_reference_on: inflation_snapshot[:reference_on], + inflation_indicator_id: inflation_snapshot[:indicator_id] + } + end + end + + def inflation_snapshot_for(on:, allow_import: true) + { + inflation_component_percent: inflation_rate_assumption&.to_d, + source: inflation_rate_assumption.present? ? "manual" : nil, + reference_on: nil, + indicator_id: nil + } + end + + def inherit_defaults_from_bond + self.subtype ||= bond&.subtype + self.rate_type ||= bond&.rate_type + self.coupon_frequency ||= bond&.coupon_frequency + self.interest_rate = bond.interest_rate if interest_rate.blank? && bond&.interest_rate.present? + self.term_months ||= bond&.term_months + end + + def normalize_legacy_subtype + return if subtype.blank? + + mapped = Bond::LEGACY_SUBTYPE_ALIASES[subtype] + return unless mapped + + self.product_code ||= (subtype == "eod" ? "pl_eod" : subtype == "rod" ? "pl_rod" : nil) + self.subtype = mapped + end + + def normalize_subtype_from_product + return if product_code.blank? + + defaults = Bond::PRODUCT_DEFAULTS[product_code] + return if defaults.blank? + + self.subtype = defaults[:subtype] + end + + def apply_product_defaults + return unless product_code.present? + + defaults = Bond::PRODUCT_DEFAULTS[product_code] + return if defaults.blank? + + # Override all fields if product_code is freshly set (new record or changed), + # but preserve if explicitly requested (e.g., during reinvestment) + preserve_existing = _preserve_coupon_frequency + is_product_new = !persisted? || product_code_changed? + + self.subtype = defaults[:subtype] if subtype.blank? || Bond::LEGACY_SUBTYPE_ALIASES.key?(subtype) + self.term_months = defaults[:term_months] if defaults[:term_months].present? && (term_months.blank? || is_product_new) + self.rate_type = defaults[:rate_type] if defaults[:rate_type].present? && (rate_type.blank? || is_product_new) + self.coupon_frequency = defaults[:coupon_frequency] if defaults[:coupon_frequency].present? && (coupon_frequency.blank? || (is_product_new && !preserve_existing)) + self.cpi_lag_months = defaults[:cpi_lag_months] if defaults[:cpi_lag_months].present? && (cpi_lag_months.blank? || is_product_new) + self.nominal_per_unit ||= 100 + self.issue_date ||= purchased_on + self.auto_fetch_inflation = false if auto_fetch_inflation.nil? + end + + def normalize_auto_fetch_inflation + self.auto_fetch_inflation = false if auto_fetch_inflation.nil? + return if inflation_linked? + + self.auto_fetch_inflation = false + self.inflation_provider = nil + end + + def normalize_inflation_provider + inflation_like = canonical_subtype.in?(Bond::INFLATION_LINKED_SUBTYPES) + self.inflation_provider = nil unless inflation_like + end + + def deflation_floor_applies? + product_code&.start_with?("pl_") + end + + + def create_settlement_entry!(settlement_date:, net_value:, tax_withheld_amount:, gross_value:) + subtype_label = Bond.long_subtype_label_for(subtype) || Bond.display_name.singularize + interest_amount = (gross_value - amount.to_d).round(4) + + settlement_entry = account.entries.create!( + date: settlement_date, + name: I18n.t("bond_lots.activity.maturity_settlement_name", subtype: subtype_label), + notes: settlement_notes( + purchase_amount: amount.to_d, + interest_amount: interest_amount, + tax_withheld_amount: tax_withheld_amount + ), + amount: -net_value, + currency: account.currency, + entryable: Transaction.new( + kind: :funds_movement, + extra: { + "bond_lot_id" => id, + "bond_lot_settlement" => true, + "bond_subtype" => subtype, + "bond_maturity_date" => maturity_date, + "bond_settlement_gross" => gross_value, + "bond_settlement_net" => net_value, + "bond_settlement_tax_withheld" => tax_withheld_amount, + "bond_settlement_tax_strategy" => tax_strategy, + "bond_settlement_tax_rate" => settlement_tax_rate_percent + } + ) + ) + + settlement_entry.lock_saved_attributes! + settlement_entry.mark_user_modified! + end + + def create_reinvestment_lot!(settlement_date:, net_value:) + nominal = nominal_per_unit.presence || 100 + replacement_units = inflation_linked? ? (net_value.to_d / nominal.to_d).floor : nil + replacement_amount = if inflation_linked? + replacement_units.to_d * nominal.to_d + else + net_value.to_d + end + + return if replacement_amount <= 0 + + replacement_lot = bond.bond_lots.new( + purchased_on: settlement_date, + issue_date: inflation_linked? ? settlement_date : nil, + amount: replacement_amount, + product_code: product_code, + units: replacement_units, + nominal_per_unit: inflation_linked? ? nominal : nil, + subtype: subtype, + interest_rate: inflation_linked? ? nil : interest_rate, + rate_type: inflation_linked? ? nil : rate_type, + coupon_frequency: coupon_frequency, + first_period_rate: nil, + inflation_margin: nil, + inflation_rate_assumption: inflation_rate_assumption, + inflation_provider: inflation_provider, + cpi_lag_months: cpi_lag_months, + auto_fetch_inflation: auto_fetch_inflation, + auto_close_on_maturity: auto_close_on_maturity, + early_redemption_fee: early_redemption_fee, + tax_strategy: tax_strategy, + tax_rate: tax_rate, + requires_rate_review: true + ) + replacement_lot._preserve_coupon_frequency = true + replacement_lot.save! + replacement_lot.create_purchase_entry!(auto_purchased: true, requires_rate_review: true) + end + + def subtype_label + Bond.long_subtype_label_for(canonical_subtype) || Bond.display_name.singularize + end + + def canonical_subtype + Bond::LEGACY_SUBTYPE_ALIASES.fetch(subtype.to_s, subtype) + end + + def purchase_entry_extra(auto_purchased: false, requires_rate_review: false) + { + "bond_lot_id" => id, + "bond_subtype" => subtype, + "bond_term_months" => term_months, + "bond_interest_rate" => interest_rate + }.tap do |extra| + extra["bond_auto_purchased"] = true if auto_purchased + extra["bond_requires_rate_review"] = true if requires_rate_review + end + end + + def settlement_notes(purchase_amount:, interest_amount:, tax_withheld_amount:) + formatted_purchase_amount = Money.new(purchase_amount, account.currency).format + formatted_interest_amount = Money.new(interest_amount, account.currency).format + + if tax_withheld_amount.to_d.positive? + I18n.t( + "bond_lots.activity.maturity_settlement_notes_with_tax", + purchase_amount: formatted_purchase_amount, + interest_amount: formatted_interest_amount, + tax_withheld_amount: Money.new(tax_withheld_amount, account.currency).format + ) + else + I18n.t( + "bond_lots.activity.maturity_settlement_notes_without_tax", + purchase_amount: formatted_purchase_amount, + interest_amount: formatted_interest_amount + ) + end + end + + def cashflow_principal + if units.present? && nominal_per_unit.present? + units.to_d * nominal_per_unit.to_d + else + amount.to_d + end + end + + def derive_amount_from_units + return if units.blank? || nominal_per_unit.blank? + + expected = units.to_d * nominal_per_unit.to_d + self.amount = expected if amount.blank? || inflation_linked? + end + + def normalize_tax_settings + if bond&.tax_exempt_wrapper? + self.tax_strategy = "exempt" + self.tax_rate = 0 + return + end + + self.tax_strategy = "standard" if tax_strategy.blank? + self.tax_rate = if tax_strategy == "exempt" + 0 + else + tax_rate.presence || DEFAULT_TAX_RATE_PERCENT + end + end + + def clear_rate_review_flag + return unless requires_rate_review? + + review_date = [ Date.current, maturity_date ].compact.min + self.requires_rate_review = false if review_date.present? && rate_review_complete?(on: review_date) + end + + def rates_present_for_review?(on: purchased_on || Date.current) + if inflation_linked? + (!needs_first_period_rate?(on:) || first_period_rate.present?) && inflation_margin.present? + else + interest_rate.present? + end + end + + def validate_issue_date_not_after_purchased_on + return if issue_date.blank? || purchased_on.blank? + errors.add(:issue_date, "cannot be after purchase date") if issue_date > purchased_on + end + + def validate_maturity_date_not_before_purchased_on + return if purchased_on.blank? || maturity_date.blank? + errors.add(:maturity_date, "must be on or after purchase date") if maturity_date < purchased_on + end + + def assign_maturity_date_from_term + return if term_months.blank? + base_date = (issue_date.present? && (purchased_on.blank? || issue_date < purchased_on)) ? issue_date : purchased_on + return if base_date.blank? + + return unless maturity_date.blank? || will_save_change_to_term_months? || will_save_change_to_issue_date? || will_save_change_to_purchased_on? + + self.maturity_date = base_date + term_months.months + end + + def should_settle_if_already_matured? + open? && auto_close_on_maturity? && maturity_date.present? && maturity_date <= Date.current && entry_id.present? + end + + def settle_if_already_matured! + settle_if_matured!(on: Date.current) + end + + def should_auto_buy_new_issue?(net_value:) + return false unless bond&.auto_buy_new_issues? + return false unless bond&.tax_exempt_wrapper? + return false unless inflation_linked? + + nominal = nominal_per_unit.presence || 100 + (net_value.to_d / nominal.to_d).floor.positive? + end + + # Returns false if any annual rate period between purchased_on and date cannot be resolved. + # Used by settle_if_matured! to abort settlement when GUS data or rates are missing. + # Also used by needs_rate_review class method to identify lots with unresolvable rates. + def rates_resolvable_through?(date:, allow_import: true) + return true unless purchased_on.present? + + issue_base = anniversary_issue_base + cursor = purchased_on + + while cursor < date + return false if annual_rate_for(on: cursor, allow_import:).blank? + + next_anniversary, _ = anniversary_boundaries(cursor:, issue_base:) + cursor = [ next_anniversary, date ].min + end + + true + end + public :rates_resolvable_through? +end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 9324e34ab..92cf8bb84 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -1,7 +1,7 @@ module Accountable extend ActiveSupport::Concern - TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability] + TYPES = %w[Depository Investment Crypto Property Vehicle Bond OtherAsset CreditCard Loan OtherLiability] # Define empty hash to ensure all accountables have this defined SUBTYPES = {}.freeze diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 7f3b6a540..3c4c22ec1 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -53,8 +53,10 @@ def needs_authorization? validate :psu_type_in_aspsp_types def psu_type_in_aspsp_types - return if psu_type.blank? || aspsp_psu_types.blank? - unless aspsp_psu_types.include?(psu_type) + current_psu_type = has_attribute?(:psu_type) ? self[:psu_type] : nil + return if current_psu_type.blank? || aspsp_psu_types.blank? + + unless aspsp_psu_types.include?(current_psu_type) errors.add(:psu_type, "must be one of the ASPSP supported types") end end diff --git a/app/models/entry.rb b/app/models/entry.rb index 48b216f36..198f1ce11 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -11,6 +11,7 @@ class Entry < ApplicationRecord belongs_to :parent_entry, class_name: "Entry", optional: true has_many :child_entries, class_name: "Entry", foreign_key: :parent_entry_id, dependent: :destroy + has_one :bond_lot, dependent: :destroy delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable diff --git a/app/models/gus_inflation_rate.rb b/app/models/gus_inflation_rate.rb new file mode 100644 index 000000000..0aa20d2c9 --- /dev/null +++ b/app/models/gus_inflation_rate.rb @@ -0,0 +1,113 @@ +class GusInflationRate < ApplicationRecord + PERIOD_TO_MONTH = { + 247 => 1, + 248 => 2, + 249 => 3, + 250 => 4, + 251 => 5, + 252 => 6, + 253 => 7, + 254 => 8, + 255 => 9, + 256 => 10, + 257 => 11, + 258 => 12 + }.freeze + + validates :year, :month, :rate_yoy, presence: true + validates :month, inclusion: { in: 1..12 } + validates :month, uniqueness: { scope: :year } + + class << self + def stats + cnt, min_yr, max_yr = pick( + Arel.sql("COUNT(*)"), + Arel.sql("MIN(year)"), + Arel.sql("MAX(year)") + ) || [ 0, nil, nil ] + + { count: cnt.to_i, min_year: min_yr, max_year: max_yr } + end + + def for_date(date:, lag_months: 0) + target_date = date.beginning_of_month - lag_months.to_i.months + find_by(year: target_date.year, month: target_date.month) + end + + def yoy_index_for(date:, lag_months: 0) + for_date(date:, lag_months:)&.rate_yoy + end + + def import_year!(year:, force: false) + return 0 if !force && year_complete?(year) + + response = provider.fetch_cpi_yoy_for_year(year: year) + unless response.success? + return 0 if not_found_error?(response.error) + raise response.error.is_a?(Exception) ? response.error : RuntimeError.new(response.error.to_s) + end + + rows = response.data + return 0 if rows.blank? + + imported = 0 + + rows.each do |row| + month = PERIOD_TO_MONTH[row[:period_id].to_i] + next if month.blank? + + value = row[:value] + next if value.blank? + + begin + rate_yoy = BigDecimal(value.to_s) + rescue ArgumentError + next + end + + existing = find_by(year: year.to_i, month: month) + next if !force && existing.present? && existing.rate_yoy == rate_yoy + + upsert({ year: year.to_i, month: month, rate_yoy: rate_yoy, source: "sdp" }, + unique_by: :index_gus_inflation_rates_on_year_and_month) + imported += 1 + end + + imported + end + + def import_range!(start_year:, end_year:, force: false) + from = start_year.to_i + to = end_year.to_i + return 0 if from > to + + (from..to).sum { |year| import_year!(year: year, force: force) } + end + + private + def provider + # Don't memoize; allow credential changes without restart. + Provider::GusSdp.new(client_id: gus_client_id, cpi_indicator_id: cpi_indicator_id) + end + + def gus_client_id + ENV["GUS_SDP_API_KEY"].presence || Setting.gus_sdp_api_key + end + + def cpi_indicator_id + indicator_id = ENV["GUS_SDP_CPI_INDICATOR_ID"].presence + indicator_id.presence || Provider::GusSdp::DEFAULT_CPI_INDICATOR_ID + end + + def not_found_error?(error) + return false if error.blank? + + message = error.message.to_s + message.include?("status 404") || message.include?("404") + end + + def year_complete?(year) + where(year: year.to_i).count >= 12 + end + end +end diff --git a/app/models/inflation_rate.rb b/app/models/inflation_rate.rb new file mode 100644 index 000000000..06df74531 --- /dev/null +++ b/app/models/inflation_rate.rb @@ -0,0 +1,73 @@ +class InflationRate < ApplicationRecord + validates :source, :year, :month, :rate_yoy, presence: true + validates :month, inclusion: { in: 1..12 } + validates :month, uniqueness: { scope: %i[source year] } + + class << self + def stats_for(source:) + cnt, min_yr, max_yr = where(source: source).pick( + Arel.sql("COUNT(*)"), + Arel.sql("MIN(year)"), + Arel.sql("MAX(year)") + ) || [ 0, nil, nil ] + + { count: cnt.to_i, min_year: min_yr, max_year: max_yr } + end + + def for_date(source:, date:, lag_months: 0) + target_date = date.beginning_of_month - lag_months.to_i.months + find_by(source: source.to_s, year: target_date.year, month: target_date.month) + end + + def import_year!(source:, provider:, year:, force: false) + source_key = source.to_s + target_year = year.to_i + return 0 if !force && year_complete?(source: source_key, year: target_year) + + response = provider.fetch_cpi_yoy_for_year(year: target_year) + unless response.success? + return 0 if not_found_error?(response.error) + raise response.error.is_a?(Exception) ? response.error : RuntimeError.new(response.error.to_s) + end + + rows = response.data + return 0 if rows.blank? + + imported = 0 + + rows.each do |row| + month = row[:month].to_i + next unless month.between?(1, 12) + + raw_rate_yoy = row[:rate_yoy] + # Skip rows with no rate data. Zero is valid (0% YoY change) and must not be dropped. + next if raw_rate_yoy.blank? + rate_yoy = raw_rate_yoy.to_d + + existing = find_by(source: source_key, year: target_year, month: month) + next if !force && existing.present? && existing.rate_yoy == rate_yoy + + upsert( + { source: source_key, year: target_year, month: month, rate_yoy: rate_yoy }, + unique_by: :index_inflation_rates_on_source_and_year_and_month + ) + imported += 1 + end + + imported + end + + private + def year_complete?(source:, year:) + where(source: source, year: year).count >= 12 + end + + def not_found_error?(error) + return false if error.blank? + return true if error.respond_to?(:response) && error.response&.dig(:status) == 404 + + message = error.message.to_s + message.include?("status 404") || message.match?(/\b404\b/) + end + end +end diff --git a/app/models/inflation_rate_importer.rb b/app/models/inflation_rate_importer.rb new file mode 100644 index 000000000..4bb28e03a --- /dev/null +++ b/app/models/inflation_rate_importer.rb @@ -0,0 +1,52 @@ +class InflationRateImporter + def initialize(start_year:, end_year:, force: false, providers: nil) + @start_year = start_year.to_i + @end_year = end_year.to_i + @force = force + @providers = normalize_providers(providers) + end + + def import_all + providers.index_with { |provider| import_provider(provider) } + end + + private + attr_reader :start_year, :end_year, :force, :providers + + def normalize_providers(providers) + supported_providers = Bond::InflationProvider::PROVIDERS.keys + selected = Array(providers).presence || supported_providers + selected.map(&:to_s).uniq.select { |provider| supported_providers.include?(provider) } + end + + def import_provider(provider) + case provider + when "gus_sdp" + GusInflationRate.import_range!(start_year:, end_year:, force:) + when "us_bls", "es_ine" + import_international_provider(provider) + else + 0 + end + end + + def import_international_provider(provider) + provider_class = Bond::InflationProvider.provider_class(provider) + provider_instance = Bond::InflationProvider.provider_instance_for(provider, provider_class) + return 0 if provider_instance.blank? + return 0 if provider == "es_ine" && es_ine_series_id.blank? + + (start_year..end_year).sum do |year| + InflationRate.import_year!( + source: provider, + provider: provider_instance, + year: year, + force: force + ) + end + end + + def es_ine_series_id + ENV["ES_INE_CPI_SERIES_ID"].presence || Setting.es_ine_cpi_series_id.presence + end +end diff --git a/app/models/provider/es_ine_cpi.rb b/app/models/provider/es_ine_cpi.rb new file mode 100644 index 000000000..e64bb2a54 --- /dev/null +++ b/app/models/provider/es_ine_cpi.rb @@ -0,0 +1,96 @@ +class Provider::EsIneCpi < Provider + Error = Class.new(Provider::Error) + MIN_CPI_YEAR = 1950 + + # Override via env when deploying, because INE endpoint can vary by dataset code. + # Trailing slash required so Faraday appends series_id as a path segment, not a replacement. + DEFAULT_BASE_URL = ENV["ES_INE_CPI_BASE_URL"].presence || "https://servicios.ine.es/wstempus/js/EN/DATOS_SERIE/".freeze + + def initialize(base_url: DEFAULT_BASE_URL, series_id: ENV["ES_INE_CPI_SERIES_ID"]) + # Ensure trailing slash for proper path segment appending + @base_url = base_url&.end_with?("/") ? base_url : "#{base_url}/" + @series_id = series_id + end + + # Returns monthly CPI YoY index values for the given year (e.g. 102.7 means +2.7% YoY). + # Expected payload supports records containing date + value pairs. + def fetch_cpi_yoy_for_year(year:) + with_provider_response do + raise Error, "Missing ES_INE_CPI_SERIES_ID configuration" if series_id.blank? + + from = Date.new(year.to_i, 1, 1) + to = Date.new(year.to_i, 12, 31) + rows = fetch_rows(from:, to:) + + year_rows = rows.filter_map do |row| + next unless row[:year] == year.to_i + + { month: row[:month], rate_yoy: row[:rate_yoy] } + end + + raise Error, "No ES INE CPI data returned for #{year}" if year_rows.empty? + + year_rows + end + end + + private + attr_reader :base_url, :series_id + + def client + @client ||= Faraday.new(url: base_url) do |faraday| + faraday.request(:retry, { + max: 4, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + retry_statuses: [ 429, 500, 502, 503, 504 ] + }) + faraday.options.timeout = 10 + faraday.options.open_timeout = 10 + faraday.response :raise_error + end + end + + def fetch_rows(from:, to:) + response = client.get(series_id) do |req| + req.params["tip"] = "AM" + req.params["date"] = from.strftime("%Y%m%d") + req.params["datef"] = to.strftime("%Y%m%d") + end + + parsed = JSON.parse(response.body) + records = parsed.is_a?(Array) ? parsed : Array(parsed["Data"] || parsed["data"]) + + records.filter_map do |record| + date = parse_date(record) + value = parse_value(record) + next if date.blank? || value.blank? + + { year: date.year, month: date.month, rate_yoy: value.to_d } + end + end + + def parse_date(record) + raw = record["Fecha"] || record["fecha"] || record["date"] + return nil if raw.blank? + + parsed = Date.parse(raw.to_s) + max_year = Date.current.year + 1 + return nil unless parsed.year.between?(MIN_CPI_YEAR, max_year) + + parsed + rescue ArgumentError, TypeError, Date::Error + nil + end + + def parse_value(record) + raw = record["Valor"] || record["valor"] || record["value"] + return nil if raw.blank? + + normalized = raw.to_s.tr(",", ".") + BigDecimal(normalized) + rescue ArgumentError, TypeError + nil + end +end diff --git a/app/models/provider/gus_sdp.rb b/app/models/provider/gus_sdp.rb new file mode 100644 index 000000000..253ea3e27 --- /dev/null +++ b/app/models/provider/gus_sdp.rb @@ -0,0 +1,66 @@ +class Provider::GusSdp < Provider + Error = Class.new(Provider::Error) + + DEFAULT_BASE_URL = "https://api-sdp.stat.gov.pl/api".freeze + # CPI (consumer prices) monthly indicator used by GUS SDP. + # 1832 = "analogiczny okres roku poprzedniego=100" (YoY index, id-sposob-prezentacji-miara=5). + # 639 = "okres poprzedni=100" (MoM index) — do NOT use for bond inflation calculations. + DEFAULT_CPI_INDICATOR_ID = 1832 + + # Optional GUS SDP client identifier (X-ClientId header). Not a secret — public API identifier. + # Set via ENV["GUS_SDP_API_KEY"] or the hosting settings page. + def initialize(client_id: nil, base_url: DEFAULT_BASE_URL, cpi_indicator_id: DEFAULT_CPI_INDICATOR_ID) + @client_id = client_id + @base_url = base_url + @cpi_indicator_id = cpi_indicator_id + end + + def fetch_cpi_yoy_for_year(year:) + with_provider_response do + response = client.get("indicators/indicator-data-indicator") do |req| + req.params["id-wskaznik"] = cpi_indicator_id + req.params["id-rok"] = year + req.params["lang"] = "pl" + end + + parsed = JSON.parse(response.body) + rows = if parsed.is_a?(Array) + parsed + elsif parsed.is_a?(Hash) + parsed.fetch("data", []) + else + [] + end + + rows.filter_map do |row| + period_id = row["id-okres"] || row["period_id"] + value = row["wartosc"] || row["value"] || row["rate_yoy"] + next if period_id.blank? || value.blank? + + { + period_id: period_id, + value: value + } + end + end + end + + private + attr_reader :client_id, :base_url, :cpi_indicator_id + + def client + @client ||= Faraday.new(url: base_url) do |faraday| + faraday.request(:retry, { + max: 4, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + retry_statuses: [ 429, 500, 502, 503, 504 ] + }) + faraday.options.timeout = 10 + faraday.options.open_timeout = 10 + faraday.response :raise_error + faraday.headers["X-ClientId"] = client_id if client_id.present? + end + end +end diff --git a/app/models/provider/us_bls_cpi.rb b/app/models/provider/us_bls_cpi.rb new file mode 100644 index 000000000..43b8f49f2 --- /dev/null +++ b/app/models/provider/us_bls_cpi.rb @@ -0,0 +1,75 @@ +class Provider::UsBlsCpi < Provider + Error = Class.new(Provider::Error) + + DEFAULT_BASE_URL = "https://api.bls.gov/publicAPI/v2".freeze + DEFAULT_SERIES_ID = "CUUR0000SA0".freeze + + def initialize(base_url: DEFAULT_BASE_URL, series_id: DEFAULT_SERIES_ID) + @base_url = base_url + @series_id = series_id + end + + # Returns monthly CPI YoY index values for the given year (e.g. 103.4 means +3.4% YoY). + def fetch_cpi_yoy_for_year(year:) + with_provider_response do + target_year = year.to_i + raw_rows = fetch_series_rows(start_year: target_year - 1, end_year: target_year) + index_by_month = raw_rows.each_with_object({}) do |row, memo| + memo[[ row[:year], row[:month] ]] = row[:value] + end + + (1..12).filter_map do |month| + current = index_by_month[[ target_year, month ]] + previous = index_by_month[[ target_year - 1, month ]] + next if current.blank? || previous.blank? || previous.zero? + + { month:, rate_yoy: ((current / previous) * 100).round(3) } + end + end + end + + private + attr_reader :base_url, :series_id + + def client + @client ||= Faraday.new(url: base_url) do |faraday| + faraday.request(:retry, { + max: 4, + interval: 0.5, + interval_randomness: 0.5, + backoff_factor: 2, + retry_statuses: [ 429, 500, 502, 503, 504 ] + }) + faraday.options.timeout = 10 + faraday.options.open_timeout = 10 + faraday.response :raise_error + end + end + + def fetch_series_rows(start_year:, end_year:) + response = client.post("timeseries/data/") do |req| + req.headers["Content-Type"] = "application/json" + req.body = { + seriesid: [ series_id ], + startyear: start_year.to_s, + endyear: end_year.to_s + }.to_json + end + + parsed = JSON.parse(response.body) + status = parsed["status"] + raise Error.new("BLS API request failed with status #{status}") unless status == "REQUEST_SUCCEEDED" + + rows = parsed.dig("Results", "series", 0, "data") || [] + rows.filter_map do |row| + period = row["period"].to_s + next unless period.match?(/^M\d{2}$/) + + { + year: row["year"].to_i, + month: period.delete_prefix("M").to_i, + value: row["value"].to_d + } + end + end +end diff --git a/app/models/purchase_holding_presenter.rb b/app/models/purchase_holding_presenter.rb new file mode 100644 index 000000000..cd7151d76 --- /dev/null +++ b/app/models/purchase_holding_presenter.rb @@ -0,0 +1,120 @@ +class PurchaseHoldingPresenter + attr_reader :lot, :account, :view + + def initialize(lot:, account:, view:) + @lot = lot + @account = account + @view = view + end + + def label + Bond.long_subtype_label_for(lot.subtype) || t("bonds.purchase_holding.unknown") + end + + def weight + total_balance = account.balance.to_d + return 0 if total_balance.zero? + + current_value = lot.estimated_current_value(allow_import: false) + (current_value.to_d / total_balance) * 100 + end + + def total_return_amount + @total_return_amount ||= projected_total_return? ? lot.projected_total_return_amount(allow_import: false) : lot.total_return_amount(allow_import: false) + end + + def total_return_percent + @total_return_percent ||= projected_total_return? ? lot.projected_total_return_percent(allow_import: false) : lot.total_return_percent(allow_import: false) + end + + def return_label + projected_total_return? ? t("bonds.purchase_holding.projected_to_maturity") : t("bonds.purchase_holding.since_purchase") + end + + def total_return_class + total_return_amount.negative? ? "text-destructive" : "text-success" + end + + def rate_text + if lot.inflation_linked? + return t("bonds.purchase_holding.update_needed") if lot.requires_rate_review? + + current_rate = lot.current_rate_percent(allow_import: false) + return view.number_to_percentage(current_rate, precision: 3) if current_rate.present? + + t("bonds.purchase_holding.unknown") + else + lot.interest_rate.present? ? view.number_to_percentage(lot.interest_rate, precision: 3) : t("bonds.purchase_holding.unknown") + end + end + + def rate_meta + lot.inflation_linked? ? inflation_meta : fixed_meta + end + + private + def t(key, **options) + view.t(key, **options) + end + + def projected_total_return? + return @projected_total_return if defined?(@projected_total_return) + + @projected_total_return = begin + current = lot.total_return_amount(allow_import: false) + projected = lot.projected_total_return_amount(allow_import: false) + current.abs < 0.01.to_d && projected.positive? + end + end + + def inflation_meta + return t("bonds.purchase_holding.pending_review") if lot.requires_rate_review? + + inflation_component = lot.current_inflation_component_percent(allow_import: false) + margin_component = lot.current_margin_percent(allow_import: false) + + if inflation_component.nil? || margin_component.nil? + if lot.in_first_rate_period? + return t("bonds.purchase_holding.first_period_fixed_rate") + end + + reference_on = lot.current_cpi_reference_on + reference = reference_on ? view.l(reference_on, format: t("bonds.purchase_holding.month_year_format")) : t("bonds.purchase_holding.unknown") + return t("bonds.purchase_holding.inflation_data_unavailable", reference:) + end + + inflation_text = view.number_to_percentage(inflation_component.to_d, precision: 3) + margin_text = view.number_to_percentage(margin_component.to_d, precision: 3) + inflation_source = lot.current_inflation_source(allow_import: false) + + if inflation_source == "gus_sdp" + t("bonds.purchase_holding.inflation_meta_gus", inflation: inflation_text, margin: margin_text, indicator: lot.current_inflation_indicator_id) + elsif inflation_source == "manual" || inflation_source.blank? + t("bonds.purchase_holding.inflation_meta_manual", inflation: inflation_text, margin: margin_text) + else + provider = t("bonds.purchase_holding.inflation_providers.#{inflation_source}", default: inflation_source.to_s.humanize) + t("bonds.purchase_holding.inflation_meta_provider", inflation: inflation_text, margin: margin_text, provider:) + end + end + + def fixed_meta + rate_type = if lot.rate_type.present? + t("bond_lots.form.rate_types.#{lot.rate_type}", default: t("bonds.purchase_holding.unknown")) + else + t("bonds.purchase_holding.unknown") + end + + coupon = if lot.coupon_frequency.present? + t("bond_lots.form.coupon_frequencies.#{lot.coupon_frequency}", default: t("bonds.purchase_holding.unknown")) + else + t("bonds.purchase_holding.unknown") + end + + coupon_amount = lot.coupon_amount_per_period + if coupon_amount.present? + t("bonds.purchase_holding.bond_meta_with_coupon_amount", rate_type:, coupon:, coupon_amount: view.format_money(coupon_amount)) + else + t("bonds.purchase_holding.bond_meta", rate_type:, coupon:) + end + end +end diff --git a/app/models/setting.rb b/app/models/setting.rb index c5aa08d7e..1f563946f 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -6,6 +6,17 @@ class ValidationError < StandardError; end # Third-party API keys field :twelve_data_api_key, type: :string, default: ENV["TWELVE_DATA_API_KEY"] + field :gus_sdp_api_key, type: :string, default: ENV["GUS_SDP_API_KEY"] + field :gus_inflation_import_enabled, type: :boolean, default: ActiveModel::Type::Boolean.new.cast(ENV["GUS_INFLATION_IMPORT_ENABLED"]) + field :us_bls_cpi_base_url, type: :string, default: ENV["US_BLS_CPI_BASE_URL"] + field :us_bls_cpi_series_id, type: :string, default: ENV["US_BLS_CPI_SERIES_ID"] + field :es_ine_cpi_base_url, type: :string, default: ENV["ES_INE_CPI_BASE_URL"] + field :es_ine_cpi_series_id, type: :string, default: ENV["ES_INE_CPI_SERIES_ID"] + field :gus_inflation_last_import_at, type: :datetime + field :gus_inflation_last_import_count, type: :integer + field :gus_inflation_last_import_range, type: :string + field :gus_inflation_last_import_error, type: :string + field :inflation_last_import_details, type: :string field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"] field :openai_uri_base, type: :string, default: ENV["OPENAI_URI_BASE"] field :openai_model, type: :string, default: ENV["OPENAI_MODEL"] @@ -149,6 +160,14 @@ def self.valid_auto_sync_timezone?(timezone_str) ActiveSupport::TimeZone[timezone_str].present? end + def inflation_import_enabled + self.class.inflation_import_enabled + end + + def inflation_import_enabled=(value) + self.class.inflation_import_enabled = value + end + # Dynamic fields are now stored as individual entries with "dynamic:" prefix # This prevents race conditions and ensures each field is independently managed @@ -175,6 +194,16 @@ class << self alias_method :raw_onboarding_state=, :onboarding_state= alias_method :raw_openai_model, :openai_model alias_method :raw_openai_model=, :openai_model= + alias_method :raw_gus_inflation_import_enabled, :gus_inflation_import_enabled + alias_method :raw_gus_inflation_import_enabled=, :gus_inflation_import_enabled= + alias_method :raw_gus_inflation_last_import_at, :gus_inflation_last_import_at + alias_method :raw_gus_inflation_last_import_at=, :gus_inflation_last_import_at= + alias_method :raw_gus_inflation_last_import_count, :gus_inflation_last_import_count + alias_method :raw_gus_inflation_last_import_count=, :gus_inflation_last_import_count= + alias_method :raw_gus_inflation_last_import_range, :gus_inflation_last_import_range + alias_method :raw_gus_inflation_last_import_range=, :gus_inflation_last_import_range= + alias_method :raw_gus_inflation_last_import_error, :gus_inflation_last_import_error + alias_method :raw_gus_inflation_last_import_error=, :gus_inflation_last_import_error= def onboarding_state value = raw_onboarding_state @@ -201,6 +230,57 @@ def openai_model=(value) end end + def inflation_import_enabled + raw_gus_inflation_import_enabled + end + + def inflation_import_enabled=(value) + self.raw_gus_inflation_import_enabled = value + end + + def inflation_import_enabled_effective + env_value = ENV["INFLATION_IMPORT_ENABLED"].presence || ENV["GUS_INFLATION_IMPORT_ENABLED"].presence + return ActiveModel::Type::Boolean.new.cast(env_value) if env_value.present? + + inflation_import_enabled + end + + def gus_inflation_import_enabled_effective + inflation_import_enabled_effective + end + + def inflation_last_import_at + raw_gus_inflation_last_import_at + end + + def inflation_last_import_at=(value) + self.raw_gus_inflation_last_import_at = value + end + + def inflation_last_import_count + raw_gus_inflation_last_import_count + end + + def inflation_last_import_count=(value) + self.raw_gus_inflation_last_import_count = value + end + + def inflation_last_import_range + raw_gus_inflation_last_import_range + end + + def inflation_last_import_range=(value) + self.raw_gus_inflation_last_import_range = value + end + + def inflation_last_import_error + raw_gus_inflation_last_import_error + end + + def inflation_last_import_error=(value) + self.raw_gus_inflation_last_import_error = value + end + # Support dynamic field access via bracket notation # First checks if it's a declared field, then falls back to individual dynamic entries def [](key) diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 01b02a575..298e07e0d 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -9,6 +9,7 @@ <%= render "account_type", accountable: Crypto.new %> <%= render "account_type", accountable: Property.new %> <%= render "account_type", accountable: Vehicle.new %> + <%= render "account_type", accountable: Bond.new %> <% end %> <% unless params[:classification] == "asset" %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 76de75102..1b11237fe 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -4,8 +4,8 @@
<%= tag.h2 t(".title"), class: "font-medium text-lg" %> - <% unless @account.linked? %> - <% if @account.permission_for(Current.user).in?([ :owner, :full_control ]) %> + <% unless account.linked? %> + <% if account.permission_for(Current.user).in?([ :owner, :full_control ]) %> <%= render DS::Menu.new(variant: "button") do |menu| %> <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %> @@ -13,22 +13,29 @@ variant: "link", text: "New balance", icon: "circle-dollar-sign", - href: new_valuation_path(account_id: @account.id), + href: new_valuation_path(account_id: account.id), data: { turbo_frame: :modal }) %> - <% if @account.supports_trades? %> + <% if account.investment? %> <% menu.with_item( variant: "link", - text: t(".new_trade"), - icon: "credit-card", - href: new_trade_path(account_id: @account.id), + text: t(".new_activity", default: "New activity"), + icon: "arrow-left-right", + href: new_trade_path(account_id: account.id), + data: { turbo_frame: :modal }) %> + <% elsif account.bond? %> + <% menu.with_item( + variant: "link", + text: t(".new_activity", default: "New activity"), + icon: "arrow-left-right", + href: new_bond_lot_path(account_id: account.id), data: { turbo_frame: :modal }) %> - <% elsif !@account.crypto? %> + <% elsif !account.crypto? %> <% menu.with_item( variant: "link", text: t(".new_transaction"), icon: "credit-card", - href: new_transaction_path(account_id: @account.id), + href: new_transaction_path(account_id: account.id), data: { turbo_frame: :modal }) %> <% end %> <% end %> @@ -46,7 +53,7 @@
<%= 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? %> +
+
+ + + + + + + + + + + + <% top_lots.each_with_index do |(account, lot), index| %> + <%= render UI::Dashboard::BondSummaryRow.new(account:, lot:, show_border: index < top_lots.size - 1) %> + <% end %> + +
<%= t(".bond") %><%= t(".rate") %><%= t(".principal") %><%= t(".maturity") %><%= t(".total_return") %>
+
+
+ <% 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