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 @@
<%= 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| %> ++ <%= t("bond_lots.form.subtype_derived_hint") %> +
+ +<%= t("bond_lots.form.auto_close_on_maturity") %>
+<%= t("bond_lots.form.auto_close_on_maturity_hint") %>
+<%= 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") %>
++ <%= 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] == "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 %> +<%= t(".no_history") %>
+ <% end %> +<%= t(".delete_subtitle") %>
+<%= t("bonds.form.auto_buy_new_issues") %>
+<%= t("bonds.form.auto_buy_new_issues_hint") %>
+