diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a97e19f33e0..f389de783fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,4 +51,4 @@ Every pull request to the `main` branch automatically runs a Pipelock security s - Leaked secrets (API keys, tokens, credentials) - Agent security risks (misconfigurations, exposed credentials, missing controls) -The scan runs as part of the CI pipeline and typically completes in ~30 seconds. If security issues are found, the CI check will fail. You don't need to configure anything—the security scanning is automatic and zero-configuration. +The scan runs as part of the CI pipeline and typically completes in ~30 seconds. If security issues are found, the CI check will fail. You don't need to configure anything—the security scanning is automatic and zero-configuration. \ No newline at end of file diff --git a/app/components/UI/account/activity_feed.html.erb b/app/components/UI/account/activity_feed.html.erb index 00adaf3fd54..76b67faf73f 100644 --- a/app/components/UI/account/activity_feed.html.erb +++ b/app/components/UI/account/activity_feed.html.erb @@ -18,22 +18,27 @@ data: { turbo_frame: :modal }) %> <% end %> - <% unless account.crypto? %> - <% if account.investment? %> - <% menu.with_item( - variant: "link", - text: t("accounts.show.activity.new_activity"), - icon: "arrow-left-right", - href: new_trade_path(account_id: account.id), - data: { turbo_frame: :modal }) %> - <% else %> - <% menu.with_item( - variant: "link", - text: t("accounts.show.activity.new_transaction"), - icon: "credit-card", - href: new_transaction_path(account_id: account.id), - data: { turbo_frame: :modal }) %> - <% end %> + <% if account.supports_trades? %> + <% menu.with_item( + variant: "link", + text: t("accounts.show.activity.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("accounts.show.activity.new_activity"), + icon: "arrow-left-right", + href: new_bond_lot_path(account_id: account.id), + data: { turbo_frame: :modal }) %> + <% elsif !account.crypto? %> + <% menu.with_item( + variant: "link", + text: t("accounts.show.activity.new_transaction"), + icon: "credit-card", + href: new_transaction_path(account_id: account.id), + data: { turbo_frame: :modal }) %> <% end %> <% menu.with_item( diff --git a/app/controllers/bond_lots_controller.rb b/app/controllers/bond_lots_controller.rb new file mode 100644 index 00000000000..d0250c695d1 --- /dev/null +++ b/app/controllers/bond_lots_controller.rb @@ -0,0 +1,120 @@ +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 + 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) + + if @bond_lot.closed_on.present? + redirect_back_or_to account_path(@bond_lot.account), alert: t("bond_lots.destroy.settled_error") + return + end + + 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_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 00000000000..590f7c30414 --- /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/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 13f5798d092..dd9be2f9309 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -37,6 +37,8 @@ def destroy @entry.sync_account_later redirect_back_or_to account_path(@entry.account), notice: t("account.entries.destroy.success") + rescue ActiveRecord::RecordNotDestroyed + redirect_back_or_to account_path(@entry.account), alert: @entry.errors.full_messages.to_sentence end private diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb index 2cf1fc2dfb5..531a1227139 100644 --- a/app/controllers/transactions/bulk_deletions_controller.rb +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -6,9 +6,18 @@ def create entries_scope = Current.family.entries .where(account_id: writable_account_ids) .where(parent_entry_id: nil) - destroyed = entries_scope.destroy_by(id: bulk_delete_params[:entry_ids]) + requested_ids = Array(bulk_delete_params[:entry_ids]) + attempted = entries_scope.destroy_by(id: requested_ids) + destroyed = attempted.select(&:destroyed?) + destroyed_count = destroyed.count + skipped_count = requested_ids.size - destroyed_count + destroyed.map(&:account).uniq.each(&:sync_later) - redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted" + + notice = t("transactions.bulk_deletions.destroy.deleted", count: destroyed_count) + notice += " #{t("transactions.bulk_deletions.destroy.skipped", count: skipped_count)}" if skipped_count > 0 + + redirect_back_or_to transactions_url, notice: notice end private 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 00000000000..a8c86099aac --- /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 00000000000..e2966f177f5 --- /dev/null +++ b/app/javascript/controllers/bond_lot_form_controller.js @@ -0,0 +1,66 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "productCodeSelect", + "subtypeSelect", + "subtypeDerivedHint", + "purchasedOnInput", + "issueDateInput", + "termInput" + ] + 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() { + if (!this.hasPurchasedOnInputTarget || !this.hasIssueDateInputTarget) return + + if (!this.issueDateInputTarget.value && this.purchasedOnInputTarget.value) { + this.issueDateInputTarget.value = this.purchasedOnInputTarget.value + } + } + + #syncTermWithProduct(productCode) { + if (!this.hasTermInputTarget) return + + const mappedTerm = this.productTermMapValue?.[productCode] + const termDerived = mappedTerm !== undefined && mappedTerm !== null && `${mappedTerm}` !== "" + + if (termDerived) { + this.termInputTarget.value = mappedTerm + } + + this.termInputTarget.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 00000000000..695e6955e9f --- /dev/null +++ b/app/javascript/controllers/bond_lot_inflation_controller.js @@ -0,0 +1,93 @@ +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 === "true") { + input.required = inflationLinked && firstPeriodRateRequired + } else { + input.required = inflationLinked && !(input.dataset.optional === "true") + } + }) + + this.otherRequiredInputTargets.forEach((input) => { + input.disabled = inflationLinked + input.required = !inflationLinked + }) + + this.#toggleManualInflationField() + } + + recalculate() { + this.toggleSubtypeFields() + } + + #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 el = this.element.querySelector("[data-subtype-field]") + return el ? `${el.value || ""}` : "" + } + + #firstPeriodRateRequired() { + const poEl = this.element.querySelector("[data-purchased-on-field]") + const idEl = this.element.querySelector("[data-issue-date-field]") + const purchasedOn = this.#parseDate(poEl ? poEl.value : null) + const issueDate = this.#parseDate(idEl ? idEl.value : null) + + 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 match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return null + const parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + 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 00000000000..1e47a8380ac --- /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 8a31a8d5e50..8fdb698000a 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 a1e43d99e4f..67069e632c3 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 582774c3826..915ed485df4 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 00000000000..38d664cd1b8 --- /dev/null +++ b/app/models/bond.rb @@ -0,0 +1,211 @@ +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 + }, + "us_i_bond" => { + subtype: "inflation_linked", + term_months: 120, + rate_type: "variable", + coupon_frequency: "at_maturity", + cpi_lag_months: 6 + }, + "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_lot.rb b/app/models/bond_lot.rb new file mode 100644 index 00000000000..f2a76cb9d2c --- /dev/null +++ b/app/models/bond_lot.rb @@ -0,0 +1,813 @@ +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 + + 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 + + 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 :apply_inflation_linked_defaults + before_validation :assign_maturity_date_from_term + before_validation :derive_amount_from_units + 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 :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 :rate_type, :coupon_frequency, presence: 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: :requires_rate_review? + end + + with_options unless: :inflation_linked? do + validates :interest_rate, presence: true, unless: -> { requires_rate_review? || inflation_linked_selection? } + 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 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 + end + + def apply_inflation_linked_defaults + return unless product_code.blank? && inflation_linked_selection? + + self.rate_type ||= "variable" + self.coupon_frequency ||= "annual" + 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, + cpi_lag_months: cpi_lag_months, + 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 settlement and rate-review logic 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 9324e34abef..92cf8bb84dc 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 7f3b6a5405e..eab33cee776 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 @@ -102,7 +104,7 @@ def start_authorization(aspsp_name:, redirect_url:, state: nil, psu_type: "perso authorization_id: result[:authorization_id], aspsp_name: aspsp_name } - attributes[:psu_type] = validated_psu_type if validated_psu_type.present? + attributes[:psu_type] = validated_psu_type if validated_psu_type.present? && has_attribute?(:psu_type) update!(attributes) diff --git a/app/models/entry.rb b/app/models/entry.rb index 48b216f36e5..0fe3ff13f5b 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 @@ -24,6 +25,7 @@ class Entry < ApplicationRecord validate :split_child_date_matches_parent before_destroy :prevent_individual_child_deletion, if: :split_child? + before_destroy :prevent_deletion_when_linked_bond_lot_settled, prepend: true scope :visible, -> { joins(:account).where(accounts: { status: [ "draft", "active" ] }) @@ -508,4 +510,15 @@ def prevent_individual_child_deletion throw :abort end + + def prevent_deletion_when_linked_bond_lot_settled + return unless bond_lot&.closed_on.present? + + errors.add(:base, settled_bond_lot_deletion_error_message) + throw :abort + end + + def settled_bond_lot_deletion_error_message + I18n.t("entries.destroy.blocked_settled_bond_lot") + end end diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index 01b02a57595..298e07e0dd7 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 76de7510288..f8e5bd94396 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.supports_trades? %> <% menu.with_item( variant: "link", - text: t(".new_trade"), - icon: "credit-card", - href: new_trade_path(account_id: @account.id), + text: t(".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"), + 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 00000000000..e4c6c517609 --- /dev/null +++ b/app/views/bond_lots/_form.html.erb @@ -0,0 +1,181 @@ +<%# 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 change->bond-lot-inflation#recalculate", + bond_lot_form_target: "purchasedOnInput", + "purchased-on-field": true + } %> + +
+ <%= form.date_field :issue_date, + label: t("bond_lots.form.issue_date"), + data: { + action: "change->bond-lot-inflation#recalculate", + bond_lot_form_target: "issueDateInput", + bond_lot_inflation_target: "inflationInput", + "issue-date-field": true + } %> +
+ + <%= 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, + data: { bond_lot_form_target: "termInput" } %> + +
+ <%= 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", + "subtype-field": true + } + } %> +

+ <%= 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.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 00000000000..418ea2c6969 --- /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 00000000000..fd711b5732d --- /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 00000000000..cb11f8e8265 --- /dev/null +++ b/app/views/bond_lots/show.html.erb @@ -0,0 +1,96 @@ +<%= render DS::Dialog.new(frame: "drawer", responsive: true) do |dialog| %> + <% dialog.with_header(custom_header: true) do %> +
+
+ <% canonical_subtype = Bond::LEGACY_SUBTYPE_ALIASES[@bond_lot.subtype] || @bond_lot.subtype %> + <%= tag.h3(Bond.long_subtype_label_for(canonical_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? && @account.permission_for(Current.user).in?([:owner, :full_control]) %> +
+ <%= 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? %> +

    + <% if event[:inflation_source] == "first_period" %> + <%= t(".history_inflation_first_period") %> + <% else %> + <% 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) %> + <%= t(".history_inflation_manual", inflation: inflation_text, margin: margin_text) %> + <% end %> +

    + <% end %> +
  • + <% end %> +
+ <% else %> +

<%= t(".no_history") %>

+ <% end %> +
+ <% end %> + + <% if @bond_lot.open? && @account.permission_for(Current.user).in?([:owner, :full_control]) %> + <% 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-destructive text-sm font-medium border border-secondary", + data: { turbo_confirm: t(".delete_confirm") } %> +
+
+ <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/bonds/_form.html.erb b/app/views/bonds/_form.html.erb new file mode 100644 index 00000000000..ecfd72b02ec --- /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/edit.html.erb b/app/views/bonds/edit.html.erb new file mode 100644 index 00000000000..315e094f9ab --- /dev/null +++ b/app/views/bonds/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, 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 00000000000..156986c403b --- /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/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index a8691be1116..a5141d12a7c 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 00000000000..87917668378 --- /dev/null +++ b/config/locales/views/bonds/en.yml @@ -0,0 +1,174 @@ +--- +en: + bonds: + edit: + title: 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 + settled_error: Cannot delete a settled bond lot + 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 c4af3268241..5c4f41fc552 100644 --- a/config/locales/views/bonds/pl.yml +++ b/config/locales/views/bonds/pl.yml @@ -2,13 +2,29 @@ pl: bonds: edit: - edit: Edytuj %{account} + title: Edytuj %{account} form: initial_balance: Początkowe saldo obligacji tax_wrapper: Otoczka podatkowa 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}" @@ -102,22 +128,28 @@ pl: success: Zaktualizowano zakup obligacji destroy: success: Usunięto zakup obligacji + settled_error: Nie można usunąć rozliczonego zakupu obligacji form: 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. Pobieranie inflacji automatycznie 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/entries/en.yml b/config/locales/views/entries/en.yml index f99eb3e6df3..9ac4010efb3 100644 --- a/config/locales/views/entries/en.yml +++ b/config/locales/views/entries/en.yml @@ -5,6 +5,7 @@ en: success: Entry created destroy: success: Entry deleted + blocked_settled_bond_lot: Cannot delete entry linked to a settled bond lot empty: description: Try adding an entry, editing filters or refining your search title: No entries found diff --git a/config/locales/views/entries/pl.yml b/config/locales/views/entries/pl.yml index fc2f0a03b2d..8c0904937b2 100644 --- a/config/locales/views/entries/pl.yml +++ b/config/locales/views/entries/pl.yml @@ -5,6 +5,7 @@ pl: success: Wpis został utworzony destroy: success: Wpis został usunięty + blocked_settled_bond_lot: Nie można usunąć wpisu powiązanego z rozliczoną lokatą obligacji empty: description: Spróbuj dodać wpis, zmienić filtry lub doprecyzować wyszukiwanie title: Nie znaleziono wpisów diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index cc577564514..7e8201bc8e6 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -277,3 +277,11 @@ en: other: "Files (%{count})" browse_to_add: "Browse to add files" max_reached: "Maximum file limit reached (%{count}/%{max}). Delete an existing file to upload another." + bulk_deletions: + destroy: + deleted: + one: "%{count} transaction deleted" + other: "%{count} transactions deleted" + skipped: + one: "%{count} transaction could not be deleted" + other: "%{count} transactions could not be deleted" diff --git a/config/locales/views/transactions/pl.yml b/config/locales/views/transactions/pl.yml index 9ae58f25e64..87a3802a143 100644 --- a/config/locales/views/transactions/pl.yml +++ b/config/locales/views/transactions/pl.yml @@ -240,3 +240,15 @@ pl: other: Plików (%{count}) browse_to_add: Przeglądaj, aby dodać pliki max_reached: Osiągnięto maksymalny limit plików (%{count}/%{max}). Usuń istniejący plik, aby przesłać kolejny. + bulk_deletions: + destroy: + deleted: + one: "Usunięto %{count} transakcję" + few: "Usunięto %{count} transakcje" + many: "Usunięto %{count} transakcji" + other: "Usunięto %{count} transakcji" + skipped: + one: "%{count} transakcja nie mogła zostać usunięta" + few: "%{count} transakcje nie mogły zostać usunięte" + many: "%{count} transakcji nie mogło zostać usuniętych" + other: "%{count} transakcji nie mogło zostać usuniętych" diff --git a/config/routes.rb b/config/routes.rb index 544d1c8f05a..3277fe203cd 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/db/migrate/20260330233052_create_bonds.rb b/db/migrate/20260330233052_create_bonds.rb new file mode 100644 index 00000000000..50b16e91878 --- /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 00000000000..76b70ba2974 --- /dev/null +++ b/db/migrate/20260331120000_create_bond_lots.rb @@ -0,0 +1,70 @@ +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_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.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, + %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/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 00000000000..6e5f91f6d5f --- /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 00000000000..7c9491511ff --- /dev/null +++ b/db/migrate/20260407162000_add_holdings_snapshot_columns_to_accounts.rb @@ -0,0 +1,14 @@ +# These columns are reserved for Phase 2 bond holdings snapshot caching. +# No application code uses them yet — they are scaffolded here so the schema +# is ready when the snapshot feature is implemented. +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 00000000000..b73c5657cc0 --- /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 00000000000..1bd62e31646 --- /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/migrate/20260420100000_reconcile_bond_core_schema_and_constraints.rb b/db/migrate/20260420100000_reconcile_bond_core_schema_and_constraints.rb new file mode 100644 index 00000000000..27f353e39aa --- /dev/null +++ b/db/migrate/20260420100000_reconcile_bond_core_schema_and_constraints.rb @@ -0,0 +1,94 @@ +class ReconcileBondCoreSchemaAndConstraints < ActiveRecord::Migration[7.2] + def up + ensure_enable_banking_psu_type_column + drop_legacy_inflation_tables + add_bonds_check_constraints + add_bond_lots_financial_constraints + end + + def down + raise ActiveRecord::IrreversibleMigration, "This migration drops legacy inflation tables and cannot be automatically reversed" + end + + private + + def ensure_enable_banking_psu_type_column + return unless table_exists?(:enable_banking_items) + return if column_exists?(:enable_banking_items, :psu_type) + + add_column :enable_banking_items, :psu_type, :string + end + + def drop_legacy_inflation_tables + drop_table :gus_inflation_rates, if_exists: true + drop_table :inflation_rates, if_exists: true + end + + def add_bonds_check_constraints + return unless table_exists?(:bonds) + + unless check_constraint_exists?(:bonds, name: "check_bonds_tax_wrapper_valid") + add_check_constraint :bonds, + "tax_wrapper IN ('none','ike','ikze')", + name: "check_bonds_tax_wrapper_valid" + end + + unless check_constraint_exists?(:bonds, name: "check_bonds_subtype_valid") + add_check_constraint :bonds, + "subtype IS NULL OR subtype IN ('zero_coupon','fixed_coupon','inflation_linked','savings','other')", + name: "check_bonds_subtype_valid" + end + + unless check_constraint_exists?(:bonds, name: "check_bonds_rate_type_valid") + add_check_constraint :bonds, + "rate_type IS NULL OR rate_type IN ('fixed','variable')", + name: "check_bonds_rate_type_valid" + end + + unless check_constraint_exists?(:bonds, name: "check_bonds_coupon_frequency_valid") + add_check_constraint :bonds, + "coupon_frequency IS NULL OR coupon_frequency IN ('monthly','quarterly','semi_annual','annual','at_maturity')", + name: "check_bonds_coupon_frequency_valid" + end + end + + def add_bond_lots_financial_constraints + return unless table_exists?(:bond_lots) + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_tax_rate_range") + add_check_constraint :bond_lots, + "tax_rate >= 0 AND tax_rate <= 100", + name: "check_bond_lots_tax_rate_range" + end + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_non_negative_early_redemption_fee") + add_check_constraint :bond_lots, + "early_redemption_fee IS NULL OR early_redemption_fee >= 0", + name: "check_bond_lots_non_negative_early_redemption_fee" + end + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_non_negative_settlement_amount") + add_check_constraint :bond_lots, + "settlement_amount IS NULL OR settlement_amount >= 0", + name: "check_bond_lots_non_negative_settlement_amount" + end + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_non_negative_tax_withheld") + add_check_constraint :bond_lots, + "tax_withheld IS NULL OR tax_withheld >= 0", + name: "check_bond_lots_non_negative_tax_withheld" + end + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_positive_units") + add_check_constraint :bond_lots, + "units IS NULL OR units > 0", + name: "check_bond_lots_positive_units" + end + + unless check_constraint_exists?(:bond_lots, name: "check_bond_lots_positive_nominal_per_unit") + add_check_constraint :bond_lots, + "nominal_per_unit IS NULL OR nominal_per_unit > 0", + name: "check_bond_lots_positive_nominal_per_unit" + end + end +end diff --git a/db/migrate/20260427120000_change_bond_lots_entries_fk_to_restrict.rb b/db/migrate/20260427120000_change_bond_lots_entries_fk_to_restrict.rb new file mode 100644 index 00000000000..e66b20930fc --- /dev/null +++ b/db/migrate/20260427120000_change_bond_lots_entries_fk_to_restrict.rb @@ -0,0 +1,11 @@ +class ChangeBondLotsEntriesFkToRestrict < ActiveRecord::Migration[7.2] + def up + remove_foreign_key "bond_lots", "entries" + add_foreign_key "bond_lots", "entries", on_delete: :restrict + end + + def down + remove_foreign_key "bond_lots", "entries" + add_foreign_key "bond_lots", "entries", on_delete: :cascade + end +end diff --git a/db/migrate/20260427140000_relax_sophtron_items_credentials_and_drop_duplicate_index.rb b/db/migrate/20260427140000_relax_sophtron_items_credentials_and_drop_duplicate_index.rb new file mode 100644 index 00000000000..c7b21bebff2 --- /dev/null +++ b/db/migrate/20260427140000_relax_sophtron_items_credentials_and_drop_duplicate_index.rb @@ -0,0 +1,26 @@ +class RelaxSophtronItemsCredentialsAndDropDuplicateIndex < ActiveRecord::Migration[7.2] + def up + # user_id and access_key are optional credentials filled later during OAuth/setup flow. + # The original schema on main incorrectly showed them as null: false; relax here + # so migrated DBs match the intended schema. + change_column_null :sophtron_items, :user_id, true + change_column_null :sophtron_items, :access_key, true + + # The unique composite index was present on main but was never added via an explicit + # migration — it was a schema artefact. Drop it so migrated and schema-loaded DBs agree. + remove_index :sophtron_accounts, + name: :idx_unique_sophtron_accounts_per_item, + if_exists: true + end + + def down + change_column_null :sophtron_items, :user_id, false + change_column_null :sophtron_items, :access_key, false + + add_index :sophtron_accounts, + %i[sophtron_item_id account_id], + name: :idx_unique_sophtron_accounts_per_item, + unique: true, + if_not_exists: true + end +end diff --git a/db/migrate/20260429100000_remove_legacy_bond_lot_inflation_source_columns.rb b/db/migrate/20260429100000_remove_legacy_bond_lot_inflation_source_columns.rb new file mode 100644 index 00000000000..ecf739da39a --- /dev/null +++ b/db/migrate/20260429100000_remove_legacy_bond_lot_inflation_source_columns.rb @@ -0,0 +1,15 @@ +class RemoveLegacyBondLotInflationSourceColumns < ActiveRecord::Migration[7.2] + def up + remove_index :bond_lots, :inflation_provider, if_exists: true + + remove_column :bond_lots, :auto_fetch_inflation, :boolean, if_exists: true + remove_column :bond_lots, :inflation_provider, :string, if_exists: true + end + + def down + add_column :bond_lots, :auto_fetch_inflation, :boolean, null: false, default: true, if_not_exists: true + add_column :bond_lots, :inflation_provider, :string, if_not_exists: true + + add_index :bond_lots, :inflation_provider, if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1c7ecd69393..aa617c55c79 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_04_29_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -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,82 @@ 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_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.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 ["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 "early_redemption_fee IS NULL OR early_redemption_fee >= 0::numeric", name: "check_bond_lots_non_negative_early_redemption_fee" + t.check_constraint "maturity_date >= purchased_on", name: "check_bond_lots_maturity_after_purchase" + t.check_constraint "nominal_per_unit IS NULL OR nominal_per_unit > 0::numeric", name: "check_bond_lots_positive_nominal_per_unit" + 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 "settlement_amount IS NULL OR settlement_amount >= 0::numeric", name: "check_bond_lots_non_negative_settlement_amount" + 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_rate >= 0::numeric AND tax_rate <= 100::numeric", name: "check_bond_lots_tax_rate_range" + 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 "tax_withheld IS NULL OR tax_withheld >= 0::numeric", name: "check_bond_lots_non_negative_tax_withheld" + t.check_constraint "term_months > 0", name: "check_bond_lots_positive_term" + t.check_constraint "units IS NULL OR units > 0::numeric", name: "check_bond_lots_positive_units" + 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" + 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_bonds_coupon_frequency_valid" + t.check_constraint "rate_type IS NULL OR (rate_type::text = ANY (ARRAY['fixed'::character varying, 'variable'::character varying]::text[]))", name: "check_bonds_rate_type_valid" + t.check_constraint "subtype IS NULL OR (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_bonds_subtype_valid" + t.check_constraint "tax_wrapper::text = ANY (ARRAY['none'::character varying, 'ike'::character varying, 'ikze'::character varying]::text[])", name: "check_bonds_tax_wrapper_valid" + 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 @@ -1245,7 +1323,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 +1481,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 +1497,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 +1724,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: :restrict add_foreign_key "budget_categories", "budgets" add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index cda1e81e70e..d1988e9f287 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 00000000000..5a423ba98c4 --- /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 00000000000..1f16577b8a7 --- /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/transactions/bulk_deletions_controller_test.rb b/test/controllers/transactions/bulk_deletions_controller_test.rb index aa981d6c4b6..67c5dee6868 100644 --- a/test/controllers/transactions/bulk_deletions_controller_test.rb +++ b/test/controllers/transactions/bulk_deletions_controller_test.rb @@ -21,4 +21,78 @@ 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 + + test "bulk delete reports skipped entries when some deletions are blocked" do + bond_account = accounts(:bond) + + deletable_entry = bond_account.entries.create!( + name: "Deletable transaction", + date: Date.current, + amount: 150, + currency: bond_account.currency, + entryable: Transaction.new(kind: :funds_movement) + ) + + blocked_lot = BondLot.create!( + bond: 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 + ) + blocked_lot.create_purchase_entry! + blocked_lot.settle_if_matured!(on: Date.new(2025, 2, 1)) + blocked_entry = blocked_lot.entry + + assert_difference([ "Entry.count", "Transaction.count" ], -1) do + post transactions_bulk_deletion_url, params: { + bulk_delete: { + entry_ids: [ deletable_entry.id, blocked_entry.id ] + } + } + end + + assert_redirected_to transactions_url + assert_equal "1 transaction deleted 1 transaction could not be deleted", flash[:notice] + assert_not Entry.exists?(deletable_entry.id) + assert Entry.exists?(blocked_entry.id) + end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 546f58be01b..09bc53a5bbb 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -110,6 +110,32 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_select ".split-group > div.opacity-50 p.privacy-sensitive", count: 1 end + test "destroy shows alert when deleting settled bond lot purchase entry" do + bond_account = accounts(:bond) + lot = BondLot.create!( + bond: 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 + ) + lot.create_purchase_entry! + lot.settle_if_matured!(on: Date.new(2025, 2, 1)) + + assert_no_difference "Entry.count" do + delete entry_url(lot.entry) + end + + assert_redirected_to account_url(lot.entry.account) + assert_equal I18n.t("entries.destroy.blocked_settled_bond_lot"), flash[:alert] + end + test "can paginate" do family = families(:empty) sign_in users(:empty) diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index e2554e5dc6d..f2ef8ae26f0 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 00000000000..9cbff609b53 --- /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 00000000000..5e2e9b7b224 --- /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 00000000000..9de19b1aadd --- /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 c3ba12ba47e..e2c1dd8c997 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_lot_test.rb b/test/models/bond_lot_test.rb new file mode 100644 index 00000000000..664ea49af23 --- /dev/null +++ b/test/models/bond_lot_test.rb @@ -0,0 +1,867 @@ +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, + 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 "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, + 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 "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, + 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 "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, + 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, + 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, + 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 + 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, + 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 manual rate periods are unresolved" do + 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, + 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, + 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, + 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, + 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, + 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 + + test "applies safe defaults for rate_type and coupon_frequency for inflation-linked lots without preset" do + bond = bonds(:one) + bond.rate_type = nil + bond.coupon_frequency = nil + + lot = BondLot.new( + bond: bond, + purchased_on: Date.current, + amount: 1000, + subtype: "inflation_linked", + term_months: 24, + first_period_rate: 4.0, + inflation_margin: 1.0, + inflation_rate_assumption: 3.0, + cpi_lag_months: 2, + units: 10, + nominal_per_unit: 100, + issue_date: Date.current, + rate_type: nil, + coupon_frequency: nil + ) + + lot.valid? + assert_equal "variable", lot.rate_type + assert_equal "annual", lot.coupon_frequency + end + + test "destroying purchase entry does not remove settled bond lots" 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 + ) + lot.create_purchase_entry! + lot.settle_if_matured!(on: Date.new(2025, 2, 1)) + + assert_raises(ActiveRecord::RecordNotDestroyed) do + lot.entry.destroy! + end + + assert BondLot.exists?(lot.id) + assert Entry.exists?(lot.entry.id) + end +end diff --git a/test/models/bond_test.rb b/test/models/bond_test.rb new file mode 100644 index 00000000000..bb197cc62bf --- /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/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index a2266b679ab..bb1abf26125 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