Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 21 additions & 16 deletions app/components/UI/account/activity_feed.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
120 changes: 120 additions & 0 deletions app/controllers/bond_lots_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
Comment thread
UberDudePL marked this conversation as resolved.
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
10 changes: 10 additions & 0 deletions app/controllers/bonds_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class BondsController < ApplicationController
include AccountableResource

permitted_accountable_attributes(
:id,
:initial_balance,
:tax_wrapper,
:auto_buy_new_issues
)
end
24 changes: 24 additions & 0 deletions app/javascript/controllers/bond_account_form_controller.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
});
}
}
66 changes: 66 additions & 0 deletions app/javascript/controllers/bond_lot_form_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
Comment on lines +1 to +3
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")
}
}
91 changes: 91 additions & 0 deletions app/javascript/controllers/bond_lot_inflation_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
Comment on lines +1 to +3
static targets = [
"inflationFields",
"otherFields",
"inflationInput",
"otherRequiredInput",
"manualInflationField",
"manualInflationInput",
"subtypeInput",
"purchasedOnInput",
"issueDateInput"
]
Comment thread
UberDudePL marked this conversation as resolved.

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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

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() {
return this.hasSubtypeInputTarget ? `${this.subtypeInputTarget.value || ""}` : ""
}

#firstPeriodRateRequired() {
const purchasedOn = this.#parseDate(this.hasPurchasedOnInputTarget ? this.purchasedOnInputTarget.value : null)
const issueDate = this.#parseDate(this.hasIssueDateInputTarget ? this.issueDateInputTarget.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 parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? null : parsed
Comment thread
UberDudePL marked this conversation as resolved.
Outdated
}

}
22 changes: 22 additions & 0 deletions app/jobs/settle_matured_bond_lots_job.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading