Add exchange rate feature with multi-currency transactions and transfers support (#1099)

Co-authored-by: Pedro J. Aramburu <pedro@joakin.dev>
This commit is contained in:
Pedro J. Aramburu
2026-04-08 16:05:58 -03:00
committed by GitHub
parent 8e81e967fc
commit f699660479
48 changed files with 1886 additions and 73 deletions

View File

@@ -8,7 +8,8 @@
value: @selected_value,
data: {
"form-dropdown-target": "input",
"auto-submit-target": "auto"
"auto-submit-target": "auto",
**(options.dig(:html_options, :data) || {})
} %>
<button type="button"
class="form-field__input w-full"

View File

@@ -55,7 +55,11 @@ class UI::Account::Chart < ApplicationComponent
def converted_balance_money
return nil unless foreign_currency?
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
begin
account.balance_money.exchange_to(account.family.currency)
rescue Money::ConversionError
nil
end
end
def view

View File

@@ -0,0 +1,36 @@
class ExchangeRatesController < ApplicationController
def show
# Pure currency-to-currency exchange rate lookup
unless params[:from].present? && params[:to].present?
return render json: { error: "from and to currencies are required" }, status: :bad_request
end
from_currency = params[:from].upcase
to_currency = params[:to].upcase
# Same currency returns 1.0
if from_currency == to_currency
return render json: { rate: 1.0, same_currency: true }
end
# Parse date
begin
date = params[:date].present? ? Date.parse(params[:date]) : Date.current
rescue ArgumentError, TypeError
return render json: { error: "Invalid date format" }, status: :bad_request
end
begin
rate_obj = ExchangeRate.find_or_fetch_rate(from: from_currency, to: to_currency, date: date)
rescue StandardError
return render json: { error: "Failed to fetch exchange rate" }, status: :bad_request
end
if rate_obj.nil?
return render json: { error: "Exchange rate not found" }, status: :not_found
end
rate_value = rate_obj.is_a?(Numeric) ? rate_obj : rate_obj.rate
render json: { rate: rate_value.to_f }
end
end

View File

@@ -388,7 +388,11 @@ class ReportsController < ApplicationController
# Helper to process an entry (transaction or trade)
process_entry = ->(category, entry, is_trade) do
type = entry.amount > 0 ? "expense" : "income"
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
begin
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount
rescue Money::ConversionError
converted_amount = entry.amount.abs
end
if category.nil?
# Uncategorized or Other Investments (for trades)
@@ -679,7 +683,11 @@ class ReportsController < ApplicationController
month_key = entry.date.beginning_of_month
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
begin
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency).amount
rescue Money::ConversionError
converted_amount = entry.amount.abs
end
key = [ category_name, type ]
breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 }

View File

@@ -353,6 +353,30 @@ class TransactionsController < ApplicationController
head :unprocessable_entity
end
def exchange_rate
account = Current.family.accounts.find(params[:account_id])
currency_from = params[:currency]
date = params[:date]&.to_date || Date.current
if account.currency == currency_from
render json: { same_currency: true, rate: 1.0 }
else
rate_obj = ExchangeRate.find_or_fetch_rate(
from: currency_from,
to: account.currency,
date: date
)
if rate_obj.nil?
return render json: { error: "Exchange rate not found" }, status: :not_found
end
rate_value = rate_obj.is_a?(Numeric) ? rate_obj : rate_obj.rate
render json: { rate: rate_value.to_f, account_currency: account.currency }
end
end
private
def accessible_transactions
Current.family.transactions
@@ -409,7 +433,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, :exchange_rate, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)

View File

@@ -2,17 +2,11 @@ class TransfersController < ApplicationController
include StreamExtensions
before_action :set_transfer, only: %i[show destroy update]
before_action :set_accounts, only: %i[new create]
def new
@transfer = Transfer.new
@from_account_id = params[:from_account_id]
@accounts = accessible_accounts
.alphabetically
.includes(
:account_providers,
logo_attachment: :blob
)
end
def show
@@ -31,8 +25,9 @@ class TransfersController < ApplicationController
family: Current.family,
source_account_id: source_account.id,
destination_account_id: destination_account.id,
date: Date.parse(transfer_params[:date]),
amount: transfer_params[:amount].to_d
date: transfer_params[:date].present? ? Date.parse(transfer_params[:date]) : Date.current,
amount: transfer_params[:amount].to_d,
exchange_rate: transfer_params[:exchange_rate].presence&.to_d
).create
if @transfer.persisted?
@@ -45,6 +40,14 @@ class TransfersController < ApplicationController
@from_account_id = transfer_params[:from_account_id]
render :new, status: :unprocessable_entity
end
rescue Money::ConversionError
@transfer ||= Transfer.new
@transfer.errors.add(:base, "Exchange rate unavailable for selected currencies and date")
render :new, status: :unprocessable_entity
rescue ArgumentError
@transfer ||= Transfer.new
@transfer.errors.add(:date, "is invalid")
render :new, status: :unprocessable_entity
end
def update
@@ -85,7 +88,16 @@ class TransfersController < ApplicationController
end
def transfer_params
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded, :exchange_rate)
end
def set_accounts
@accounts = accessible_accounts
.alphabetically
.includes(
:account_providers,
logo_attachment: :blob
)
end
def transfer_update_params

View File

@@ -0,0 +1,298 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"amount",
"destinationAmount",
"date",
"exchangeRateContainer",
"exchangeRateField",
"convertDestinationDisplay",
"calculateRateDisplay"
];
static values = {
exchangeRateUrl: String,
accountCurrencies: Object
};
connect() {
this.sourceCurrency = null;
this.destinationCurrency = null;
this.activeTab = "convert";
if (!this.hasRequiredExchangeRateTargets()) {
return;
}
this.checkCurrencyDifference();
}
hasRequiredExchangeRateTargets() {
return this.hasDateTarget;
}
checkCurrencyDifference() {
const context = this.getExchangeRateContext();
if (!context) {
this.hideExchangeRateField();
return;
}
const { fromCurrency, toCurrency, date } = context;
if (!fromCurrency || !toCurrency) {
this.hideExchangeRateField();
return;
}
this.sourceCurrency = fromCurrency;
this.destinationCurrency = toCurrency;
if (fromCurrency === toCurrency) {
this.hideExchangeRateField();
return;
}
this.fetchExchangeRate(fromCurrency, toCurrency, date);
}
onExchangeRateTabClick(event) {
const btn = event.target.closest("button[data-id]");
if (!btn) {
return;
}
const nextTab = btn.dataset.id;
if (nextTab === this.activeTab) {
return;
}
this.activeTab = nextTab;
if (this.activeTab === "convert") {
this.clearCalculateRateFields();
} else if (this.activeTab === "calculateRate") {
this.clearConvertFields();
}
}
onAmountChange() {
this.onAmountInputChange();
}
onSourceAmountChange() {
this.onAmountInputChange();
}
onAmountInputChange() {
if (!this.hasAmountTarget) {
return;
}
if (this.activeTab === "convert") {
this.calculateConvertDestination();
} else {
this.calculateRateFromAmounts();
}
}
onConvertSourceAmountChange() {
this.calculateConvertDestination();
}
onConvertExchangeRateChange() {
this.calculateConvertDestination();
}
calculateConvertDestination() {
if (!this.hasAmountTarget || !this.hasExchangeRateFieldTarget || !this.hasConvertDestinationDisplayTarget) {
return;
}
const amount = Number.parseFloat(this.amountTarget.value);
const rate = Number.parseFloat(this.exchangeRateFieldTarget.value);
if (amount && rate && rate !== 0) {
const destAmount = (amount * rate).toFixed(2);
this.convertDestinationDisplayTarget.textContent = this.destinationCurrency ? `${destAmount} ${this.destinationCurrency}` : destAmount;
} else {
this.convertDestinationDisplayTarget.textContent = "-";
}
}
onCalculateRateSourceAmountChange() {
this.calculateRateFromAmounts();
}
onCalculateRateDestinationAmountChange() {
this.calculateRateFromAmounts();
}
calculateRateFromAmounts() {
if (!this.hasAmountTarget || !this.hasDestinationAmountTarget || !this.hasCalculateRateDisplayTarget || !this.hasExchangeRateFieldTarget) {
return;
}
const amount = Number.parseFloat(this.amountTarget.value);
const destAmount = Number.parseFloat(this.destinationAmountTarget.value);
if (amount && destAmount && amount !== 0) {
const rate = destAmount / amount;
const formattedRate = this.formatExchangeRate(rate);
this.calculateRateDisplayTarget.textContent = formattedRate;
this.exchangeRateFieldTarget.value = rate.toFixed(14);
} else {
this.calculateRateDisplayTarget.textContent = "-";
this.exchangeRateFieldTarget.value = "";
}
}
formatExchangeRate(rate) {
let formattedRate = rate.toFixed(14);
formattedRate = formattedRate.replace(/(\.\d{2}\d*?)0+$/, "$1");
if (!formattedRate.includes(".")) {
formattedRate += ".00";
} else if (formattedRate.match(/\.\d$/)) {
formattedRate += "0";
}
return formattedRate;
}
clearConvertFields() {
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasConvertDestinationDisplayTarget) {
this.convertDestinationDisplayTarget.textContent = "-";
}
}
clearCalculateRateFields() {
if (this.hasDestinationAmountTarget) {
this.destinationAmountTarget.value = "";
}
if (this.hasCalculateRateDisplayTarget) {
this.calculateRateDisplayTarget.textContent = "-";
}
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
}
async fetchExchangeRate(fromCurrency, toCurrency, date) {
if (this.exchangeRateAbortController) {
this.exchangeRateAbortController.abort();
}
this.exchangeRateAbortController = new AbortController();
const signal = this.exchangeRateAbortController.signal;
try {
const url = new URL(this.exchangeRateUrlValue, window.location.origin);
url.searchParams.set("from", fromCurrency);
url.searchParams.set("to", toCurrency);
if (date) {
url.searchParams.set("date", date);
}
const response = await fetch(url, { signal });
const data = await response.json();
if (!this.isCurrentExchangeRateState(fromCurrency, toCurrency, date)) {
return;
}
if (!response.ok) {
if (this.shouldShowManualExchangeRate(data)) {
this.showManualExchangeRateField();
} else {
this.hideExchangeRateField();
}
return;
}
if (data.same_currency) {
this.hideExchangeRateField();
} else {
this.sourceCurrency = fromCurrency;
this.destinationCurrency = toCurrency;
this.showExchangeRateField(data.rate);
}
} catch (error) {
if (error.name === "AbortError") {
return;
}
console.error("Error fetching exchange rate:", error);
this.hideExchangeRateField();
}
}
showExchangeRateField(rate) {
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = this.formatExchangeRate(rate);
}
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.remove("hidden");
}
this.calculateConvertDestination();
}
showManualExchangeRateField() {
const context = this.getExchangeRateContext();
this.sourceCurrency = context?.fromCurrency || null;
this.destinationCurrency = context?.toCurrency || null;
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.remove("hidden");
}
this.calculateConvertDestination();
}
shouldShowManualExchangeRate(data) {
if (!data || typeof data.error !== "string") {
return false;
}
return data.error === "Exchange rate not found" || data.error === "Exchange rate unavailable";
}
hideExchangeRateField() {
if (this.hasExchangeRateContainerTarget) {
this.exchangeRateContainerTarget.classList.add("hidden");
}
if (this.hasExchangeRateFieldTarget) {
this.exchangeRateFieldTarget.value = "";
}
if (this.hasConvertDestinationDisplayTarget) {
this.convertDestinationDisplayTarget.textContent = "-";
}
if (this.hasCalculateRateDisplayTarget) {
this.calculateRateDisplayTarget.textContent = "-";
}
if (this.hasDestinationAmountTarget) {
this.destinationAmountTarget.value = "";
}
this.sourceCurrency = null;
this.destinationCurrency = null;
}
getExchangeRateContext() {
throw new Error("Subclasses must implement getExchangeRateContext()");
}
isCurrentExchangeRateState(_fromCurrency, _toCurrency, _date) {
throw new Error("Subclasses must implement isCurrentExchangeRateState()");
}
}

View File

@@ -9,6 +9,9 @@ export default class extends Controller {
const inputEvent = new Event("input", { bubbles: true })
this.inputTarget.dispatchEvent(inputEvent)
const changeEvent = new Event("change", { bubbles: true })
this.inputTarget.dispatchEvent(changeEvent)
const form = this.element.closest("form")
const controllers = (form?.dataset.controller || "").split(/\s+/)
if (form && controllers.includes("auto-submit-form")) {

View File

@@ -0,0 +1,60 @@
import ExchangeRateFormController from "controllers/exchange_rate_form_controller";
// Connects to data-controller="transaction-form"
export default class extends ExchangeRateFormController {
static targets = [
...ExchangeRateFormController.targets,
"account",
"currency"
];
hasRequiredExchangeRateTargets() {
if (!this.hasAccountTarget || !this.hasCurrencyTarget || !this.hasDateTarget) {
return false;
}
return true;
}
getExchangeRateContext() {
if (!this.hasRequiredExchangeRateTargets()) {
return null;
}
const accountId = this.accountTarget.value;
const currency = this.currencyTarget.value;
const date = this.dateTarget.value;
if (!accountId || !currency) {
return null;
}
const accountCurrency = this.accountCurrenciesValue[accountId];
if (!accountCurrency) {
return null;
}
return {
fromCurrency: currency,
toCurrency: accountCurrency,
date
};
}
isCurrentExchangeRateState(fromCurrency, toCurrency, date) {
if (!this.hasRequiredExchangeRateTargets()) {
return false;
}
const currentAccountId = this.accountTarget.value;
const currentCurrency = this.currencyTarget.value;
const currentDate = this.dateTarget.value;
const currentAccountCurrency = this.accountCurrenciesValue[currentAccountId];
return fromCurrency === currentCurrency && toCurrency === currentAccountCurrency && date === currentDate;
}
onCurrencyChange() {
this.checkCurrencyDifference();
}
}

View File

@@ -0,0 +1,59 @@
import ExchangeRateFormController from "controllers/exchange_rate_form_controller";
// Connects to data-controller="transfer-form"
export default class extends ExchangeRateFormController {
static targets = [
...ExchangeRateFormController.targets,
"fromAccount",
"toAccount"
];
hasRequiredExchangeRateTargets() {
if (!this.hasFromAccountTarget || !this.hasToAccountTarget || !this.hasDateTarget) {
return false;
}
return true;
}
getExchangeRateContext() {
if (!this.hasRequiredExchangeRateTargets()) {
return null;
}
const fromAccountId = this.fromAccountTarget.value;
const toAccountId = this.toAccountTarget.value;
const date = this.dateTarget.value;
if (!fromAccountId || !toAccountId) {
return null;
}
const fromCurrency = this.accountCurrenciesValue[fromAccountId];
const toCurrency = this.accountCurrenciesValue[toAccountId];
if (!fromCurrency || !toCurrency) {
return null;
}
return {
fromCurrency,
toCurrency,
date
};
}
isCurrentExchangeRateState(fromCurrency, toCurrency, date) {
if (!this.hasRequiredExchangeRateTargets()) {
return false;
}
const currentFromAccountId = this.fromAccountTarget.value;
const currentToAccountId = this.toAccountTarget.value;
const currentFromCurrency = this.accountCurrenciesValue[currentFromAccountId];
const currentToCurrency = this.accountCurrenciesValue[currentToAccountId];
const currentDate = this.dateTarget.value;
return fromCurrency === currentFromCurrency && toCurrency === currentToCurrency && date === currentDate;
}
}

View File

@@ -27,13 +27,21 @@ class Balance::SyncCache
end
def converted_entries
@converted_entries ||= account.entries.excluding_split_parents.order(:date).to_a.map do |e|
@converted_entries ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e|
converted_entry = e.dup
# Extract custom exchange rate if present on Transaction
custom_rate = if e.entryable.is_a?(Transaction)
e.entryable.extra&.dig("exchange_rate")
end
# Use Money#exchange_to with custom rate if available, standard lookup otherwise
converted_entry.amount = converted_entry.amount_money.exchange_to(
account.currency,
date: e.date,
fallback_rate: 1
custom_rate: custom_rate
).amount
converted_entry.currency = account.currency
converted_entry
end
@@ -44,8 +52,7 @@ class Balance::SyncCache
converted_holding = h.dup
converted_holding.amount = converted_holding.amount_money.exchange_to(
account.currency,
date: h.date,
fallback_rate: 1
date: h.date
).amount
converted_holding.currency = account.currency
converted_holding

View File

@@ -89,7 +89,11 @@ class Holding::ForwardCalculator
# Convert trade price to account currency if needed
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
begin
converted_price = trade_price.exchange_to(account.currency).amount
rescue Money::ConversionError
converted_price = trade.price
end
tracker[:total_cost] += converted_price * trade.qty
tracker[:total_qty] += trade.qty

View File

@@ -36,7 +36,11 @@ class Holding::PortfolioCache
price_money = Money.new(price.price, price.currency)
converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
begin
converted_amount = price_money.exchange_to(account.currency).amount
rescue Money::ConversionError
converted_amount = price.price
end
Security::Price.new(
security_id: security_id,

View File

@@ -97,7 +97,11 @@ class Holding::ReverseCalculator
if trade.qty > 0 # Only track buys
security_id = trade.security_id
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
begin
converted_price = trade_price.exchange_to(account.currency).amount
rescue Money::ConversionError
converted_price = trade.price
end
tracker[security_id][:total_cost] += converted_price * trade.qty
tracker[security_id][:total_qty] += trade.qty

View File

@@ -28,6 +28,43 @@ class Transaction < ApplicationRecord
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
# Accessors for exchange_rate stored in extra jsonb field
def exchange_rate
extra&.dig("exchange_rate")
end
def exchange_rate=(value)
if value.blank?
self.extra = (extra || {}).merge("exchange_rate" => nil)
else
begin
normalized_value = Float(value)
self.extra = (extra || {}).merge("exchange_rate" => normalized_value)
rescue ArgumentError, TypeError
# Store the raw value for validation error reporting
self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true)
end
end
end
validate :exchange_rate_must_be_valid
private
def exchange_rate_must_be_valid
if extra&.dig("exchange_rate_invalid")
errors.add(:exchange_rate, "must be a number")
elsif exchange_rate.present?
# Convert to float for comparison
numeric_rate = exchange_rate.to_d rescue nil
if numeric_rate.nil? || numeric_rate <= 0
errors.add(:exchange_rate, "must be greater than 0")
end
end
end
public
enum :kind, {
standard: "standard", # A regular transaction, included in budget analytics
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics

View File

@@ -1,10 +1,18 @@
class Transfer::Creator
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:)
def initialize(family:, source_account_id:, destination_account_id:, date:, amount:, exchange_rate: nil)
@family = family
@source_account = family.accounts.find(source_account_id) # early throw if not found
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
@date = date
@amount = amount.to_d
if exchange_rate.present?
rate_value = exchange_rate.to_d
raise ArgumentError, "exchange_rate must be greater than 0" unless rate_value > 0
@exchange_rate = rate_value
else
@exchange_rate = nil
end
end
def create
@@ -23,7 +31,7 @@ class Transfer::Creator
end
private
attr_reader :family, :source_account, :destination_account, :date, :amount
attr_reader :family, :source_account, :destination_account, :date, :amount, :exchange_rate
def outflow_transaction
name = "#{name_prefix} to #{destination_account.name}"
@@ -62,13 +70,13 @@ class Transfer::Creator
end
# If destination account has different currency, its transaction should show up as converted
# Future improvement: instead of a 1:1 conversion fallback, add a UI/UX flow for missing rates
# Uses user-provided exchange rate if available, otherwise requires a provider rate
def inflow_converted_money
Money.new(amount.abs, source_account.currency)
.exchange_to(
destination_account.currency,
date: date,
fallback_rate: 1.0
custom_rate: exchange_rate
)
end

View File

@@ -0,0 +1,39 @@
<%# locals: (controller_id:, controller_key:, help_text:, convert_tab_label:, calculate_rate_tab_label:, destination_amount_label:, exchange_rate_label:, convert_input:, destination_input:) %>
<div class="hidden px-1 pt-2" data-action="click-><%= controller_id %>#onExchangeRateTabClick" data-<%= controller_id %>-target="exchangeRateContainer">
<p class="text-xs text-secondary mb-2"><%= help_text %></p>
<%= render DS::Tabs.new(active_tab: "convert") do |tabs| %>
<%= tabs.with_nav do |nav| %>
<%= nav.with_btn(id: "convert", label: convert_tab_label) %>
<%= nav.with_btn(id: "calculateRate", label: calculate_rate_tab_label) %>
<% end %>
<%= tabs.with_panel(tab_id: "convert") do %>
<div class="space-y-2 mb-3">
<%= convert_input %>
<div class="flex items-center justify-between p-2 bg-container-inset rounded text-sm">
<span class="text-secondary"><%= destination_amount_label %></span>
<span class="font-medium text-primary" data-<%= controller_id %>-target="convertDestinationDisplay">-</span>
</div>
</div>
<% end %>
<%= tabs.with_panel(tab_id: "calculateRate") do %>
<div class="space-y-2 mb-3">
<div class="form-field">
<div class="form-field__body">
<label class="form-field__label" for="<%= "#{controller_key}_destination_amount" %>"><%= destination_amount_label %></label>
<%= destination_input %>
</div>
</div>
<div class="flex items-center justify-between p-2 bg-container-inset rounded text-sm">
<span class="text-secondary"><%= exchange_rate_label %></span>
<span class="font-medium text-primary" data-<%= controller_id %>-target="calculateRateDisplay">-</span>
</div>
</div>
<% end %>
<% end %>
</div>

View File

@@ -60,26 +60,30 @@
max: options[:max] || 99999999999999,
step: options[:step] || currency.step,
disabled: options[:disabled],
data: {
data: (options[:amount_data] || {}).merge({
"money-field-target": "amount",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact,
}.compact),
required: options[:required] %>
</div>
<% unless options[:hide_currency] %>
<div>
<% currency_data = (options[:currency_data] || {}).merge({
"money-field-target": "currency",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact)
# Preserve any existing action and append money-field handler
existing_action = currency_data.delete("action")
currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ")
%>
<%= form.select currency_method,
Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
disabled: options[:disable_currency],
data: {
"money-field-target": "currency",
action: "change->money-field#handleCurrencyChange",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact
data: currency_data
} %>
</div>
<% end %>

View File

@@ -1,6 +1,7 @@
<%# locals: (entry:, categories:) %>
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4" do |f| %>
<% account_currencies = Current.family.accounts.map { |a| [a.id, a.currency] }.to_h.to_json %>
<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4", data: { controller: "transaction-form", transaction_form_exchange_rate_url_value: exchange_rate_path, transaction_form_account_currencies_value: account_currencies } do |f| %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
@@ -16,16 +17,69 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<%= f.hidden_field :account_id, data: { transaction_form_target: "account" } %>
<% else %>
<%= f.collection_select :account_id, accessible_accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis" %>
<%= f.collection_select :account_id, accessible_accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis", data: { transaction_form_target: "account", action: "change->transaction-form#checkCurrencyDifference" } %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= f.money_field :amount,
label: t(".amount"),
required: true,
container_class: "money-field-wrapper",
amount_data: { transaction_form_target: "amount", action: "input->transaction-form#onAmountChange" },
currency_data: { transaction_form_target: "currency", action: "change->transaction-form#onCurrencyChange" } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>
<%= f.date_field :date,
label: t(".date"),
required: true,
min: Entry.min_supported_date,
max: Date.current,
value: f.object.date || Date.current,
data: { transaction_form_target: "date", action: "change->transaction-form#checkCurrencyDifference" } %>
<% convert_input = capture do %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :exchange_rate,
label: t("shared.exchange_rate_tabs.exchange_rate"),
min: "0.00000000000001",
step: "0.00000000000001",
placeholder: "1.0",
class: "form-field__input",
data: {
transaction_form_target: "exchangeRateField",
action: "input->transaction-form#onConvertExchangeRateChange"
} %>
<% end %>
<% end %>
<% destination_input = capture do %>
<%= number_field_tag :destination_amount,
nil,
id: "transaction_form_destination_amount",
class: "form-field__input",
min: "0",
step: "0.00000001",
placeholder: "92",
data: {
transaction_form_target: "destinationAmount",
action: "input->transaction-form#onCalculateRateDestinationAmountChange"
} %>
<% end %>
<%= render "shared/exchange_rate_tabs",
controller_id: "transaction-form",
controller_key: "transaction_form",
help_text: t("shared.exchange_rate_tabs.exchange_rate_help"),
convert_tab_label: t("shared.exchange_rate_tabs.convert_tab"),
calculate_rate_tab_label: t("shared.exchange_rate_tabs.calculate_rate_tab"),
destination_amount_label: t("shared.exchange_rate_tabs.destination_amount"),
exchange_rate_label: t("shared.exchange_rate_tabs.exchange_rate"),
convert_input: convert_input,
destination_input: destination_input %>
</section>
<%= render DS::Disclosure.new(title: t(".details")) do %>

View File

@@ -1,4 +1,5 @@
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top", controller: "transfer-form" } do |f| %>
<% account_currencies = Current.family.accounts.map { |a| [a.id, a.currency] }.to_h.to_json %>
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top", controller: "transfer-form", transfer_form_exchange_rate_url_value: exchange_rate_path, transfer_form_account_currencies_value: account_currencies } do |f| %>
<% if transfer.errors.present? %>
<div class="text-destructive flex items-center gap-2">
<%= icon "circle-alert", size: "sm" %>
@@ -27,10 +28,49 @@
<% end %>
<section class="space-y-2">
<%= f.collection_select :from_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id, variant: :logo }, required: true %>
<%= f.collection_select :to_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".to"), variant: :logo }, required: true %>
<%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %>
<%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current %>
<%= f.collection_select :from_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".from"), selected: @from_account_id, variant: :logo }, { required: true, data: { transfer_form_target: "fromAccount", action: "change->transfer-form#checkCurrencyDifference" } } %>
<%= f.collection_select :to_account_id, @accounts, :id, :name, { prompt: t(".select_account"), label: t(".to"), variant: :logo }, { required: true, data: { transfer_form_target: "toAccount", action: "change->transfer-form#checkCurrencyDifference" } } %>
<%= f.date_field :date, value: transfer.inflow_transaction&.entry&.date || Date.current, label: t(".date"), required: true, max: Date.current, data: { transfer_form_target: "date", action: "change->transfer-form#checkCurrencyDifference" } %>
<%= f.number_field :amount, label: t(".source_amount"), required: true, min: 0, placeholder: "100", step: 0.00000001, data: { transfer_form_target: "amount", action: "input->transfer-form#onSourceAmountChange" } %>
<% convert_input = capture do %>
<%= f.number_field :exchange_rate,
label: t("shared.exchange_rate_tabs.exchange_rate"),
min: "0.00000000000001",
step: "0.00000000000001",
placeholder: "1.0",
class: "form-field__input",
data: {
transfer_form_target: "exchangeRateField",
action: "input->transfer-form#onConvertExchangeRateChange"
} %>
<% end %>
<% destination_input = capture do %>
<%= tag.input type: "number",
id: "transfer_form_destination_amount",
class: "form-field__input",
min: "0",
step: "0.00000001",
placeholder: "92",
data: {
transfer_form_target: "destinationAmount",
action: "input->transfer-form#onCalculateRateDestinationAmountChange"
} %>
<% end %>
<%= render "shared/exchange_rate_tabs",
controller_id: "transfer-form",
controller_key: "transfer_form",
help_text: t("shared.exchange_rate_tabs.exchange_rate_help"),
convert_tab_label: t("shared.exchange_rate_tabs.convert_tab"),
calculate_rate_tab_label: t("shared.exchange_rate_tabs.calculate_rate_tab"),
destination_amount_label: t("shared.exchange_rate_tabs.destination_amount"),
exchange_rate_label: t("shared.exchange_rate_tabs.exchange_rate"),
convert_input: convert_input,
destination_input: destination_input %>
</section>
<section>