-target="exchangeRateContainer">
+
<%= help_text %>
+
+ <%= 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 %>
+
+ <%= convert_input %>
+
+
+ <%= destination_amount_label %>
+ -target="convertDestinationDisplay">-
+
+
+ <% end %>
+
+ <%= tabs.with_panel(tab_id: "calculateRate") do %>
+
+
+
+
+ <%= exchange_rate_label %>
+ -target="calculateRateDisplay">-
+
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb
index 41776c28f..8ef25151f 100644
--- a/app/views/shared/_money_field.html.erb
+++ b/app/views/shared/_money_field.html.erb
@@ -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] %>
<% unless options[:hide_currency] %>
+ <% 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
} %>
<% end %>
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb
index 2ff28e05e..947f5444f 100644
--- a/app/views/transactions/_form.html.erb
+++ b/app/views/transactions/_form.html.erb
@@ -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 %>
<%= render DS::Disclosure.new(title: t(".details")) do %>
diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb
index 39684a9cd..a7696692a 100644
--- a/app/views/transfers/_form.html.erb
+++ b/app/views/transfers/_form.html.erb
@@ -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? %>
<%= icon "circle-alert", size: "sm" %>
@@ -27,10 +28,49 @@
<% end %>
- <%= 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 %>
diff --git a/config/locales/views/shared/ca.yml b/config/locales/views/shared/ca.yml
index 39c098d23..7dcbd7a99 100644
--- a/config/locales/views/shared/ca.yml
+++ b/config/locales/views/shared/ca.yml
@@ -8,6 +8,12 @@ ca:
title: Segur que vols continuar?
money_field:
label: Import
+ exchange_rate_tabs:
+ calculate_rate_tab: Calcular la taxa FX
+ convert_tab: Convertir amb la taxa FX
+ destination_amount: Quantitat de destinació
+ exchange_rate: Taxa de canvi
+ exchange_rate_help: Trieu com introduir la quantitat.
syncing_notice:
syncing: S'estan sincronitzant les dades dels comptes...
transaction_tabs:
diff --git a/config/locales/views/shared/de.yml b/config/locales/views/shared/de.yml
index 1b0cfb1bf..f0af087e2 100644
--- a/config/locales/views/shared/de.yml
+++ b/config/locales/views/shared/de.yml
@@ -8,6 +8,12 @@ de:
title: Bist du sicher
money_field:
label: Betrag
+ exchange_rate_tabs:
+ calculate_rate_tab: FX-Kurs berechnen
+ convert_tab: Mit FX-Kurs konvertieren
+ destination_amount: Zielbetrag
+ exchange_rate: Wechselkurs
+ exchange_rate_help: Wählen Sie, wie Sie den Betrag eingeben möchten.
syncing_notice:
syncing: Kontodaten werden synchronisiert...
trend_change:
diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml
index f94797c77..98a6e479a 100644
--- a/config/locales/views/shared/en.yml
+++ b/config/locales/views/shared/en.yml
@@ -8,6 +8,12 @@ en:
title: Are you sure?
money_field:
label: Amount
+ exchange_rate_tabs:
+ calculate_rate_tab: Calculate FX rate
+ convert_tab: Convert with FX rate
+ destination_amount: Destination amount
+ exchange_rate: Exchange rate
+ exchange_rate_help: Choose how to enter the amount.
syncing_notice:
syncing: Syncing accounts data...
require_admin: "Only admins can perform this action"
diff --git a/config/locales/views/shared/es.yml b/config/locales/views/shared/es.yml
index db6ceb073..4b3f5914f 100644
--- a/config/locales/views/shared/es.yml
+++ b/config/locales/views/shared/es.yml
@@ -8,6 +8,12 @@ es:
title: ¿Estás seguro?
money_field:
label: Importe
+ exchange_rate_tabs:
+ calculate_rate_tab: Calcular tasa
+ convert_tab: Convertir con tasa
+ destination_amount: Importe de destino
+ exchange_rate: Tasa de cambio
+ exchange_rate_help: Elige cómo introducir el importe.
syncing_notice:
syncing: Sincronizando datos de cuentas...
trend_change:
diff --git a/config/locales/views/shared/fr.yml b/config/locales/views/shared/fr.yml
index 0ecdb2d32..8e95f5bbf 100644
--- a/config/locales/views/shared/fr.yml
+++ b/config/locales/views/shared/fr.yml
@@ -20,6 +20,12 @@ fr:
title: Êtes-vous sûr ?
money_field:
label: Montant
+ exchange_rate_tabs:
+ calculate_rate_tab: Calculer le taux FX
+ convert_tab: Convertir avec le taux FX
+ destination_amount: Montant de destination
+ exchange_rate: Taux de change
+ exchange_rate_help: Choisissez comment entrer le montant.
syncing_notice:
syncing: Synchronisation des données de compte...
trend_change:
diff --git a/config/locales/views/shared/nb.yml b/config/locales/views/shared/nb.yml
index a96b72952..c4e6eb8b0 100644
--- a/config/locales/views/shared/nb.yml
+++ b/config/locales/views/shared/nb.yml
@@ -1,14 +1,20 @@
----
-nb:
- shared:
- confirm_modal:
- accept: Bekreft
- body_html: "Du vil ikke kunne angre denne beslutningen
"
- cancel: Avbryt
- title: Er du sikker?
- money_field:
- label: Beløp
- syncing_notice:
- syncing: Synkroniserer kontodata...
- trend_change:
+---
+nb:
+ shared:
+ confirm_modal:
+ accept: Bekreft
+ body_html: "Du vil ikke kunne angre denne beslutningen
"
+ cancel: Avbryt
+ title: Er du sikker?
+ money_field:
+ label: Beløp
+ exchange_rate_tabs:
+ calculate_rate_tab: Beregn FX-kurs
+ convert_tab: Konverter med FX-kurs
+ destination_amount: Beløp for destinasjon
+ exchange_rate: Vekslingskurs
+ exchange_rate_help: Velg hvordan du vil fylle inn beløpet.
+ syncing_notice:
+ syncing: Synkroniserer kontodata...
+ trend_change:
no_change: "ingen endring"
\ No newline at end of file
diff --git a/config/locales/views/shared/nl.yml b/config/locales/views/shared/nl.yml
index 569c00e0d..a63b576d0 100644
--- a/config/locales/views/shared/nl.yml
+++ b/config/locales/views/shared/nl.yml
@@ -8,6 +8,12 @@ nl:
title: Weet u het zeker?
money_field:
label: Bedrag
+ exchange_rate_tabs:
+ calculate_rate_tab: FX-tarief berekenen
+ convert_tab: Converteren met FX-tarief
+ destination_amount: Doelbedrag
+ exchange_rate: Wisselkoers
+ exchange_rate_help: Kies hoe u het bedrag wilt invoeren.
syncing_notice:
syncing: Accountsgegevens synchroniseren...
trend_change:
diff --git a/config/locales/views/shared/pt-BR.yml b/config/locales/views/shared/pt-BR.yml
index cd14facf5..73fc47727 100644
--- a/config/locales/views/shared/pt-BR.yml
+++ b/config/locales/views/shared/pt-BR.yml
@@ -8,6 +8,12 @@ pt-BR:
title: Tem certeza?
money_field:
label: Valor
+ exchange_rate_tabs:
+ calculate_rate_tab: Calcular taxa de câmbio
+ convert_tab: Converter com taxa de câmbio
+ destination_amount: Valor de destino
+ exchange_rate: Taxa de câmbio
+ exchange_rate_help: Escolha como inserir o valor.
syncing_notice:
syncing: Sincronizando dados das contas...
trend_change:
diff --git a/config/locales/views/shared/ro.yml b/config/locales/views/shared/ro.yml
index b97866bd3..9cb591e89 100644
--- a/config/locales/views/shared/ro.yml
+++ b/config/locales/views/shared/ro.yml
@@ -8,6 +8,12 @@ ro:
title: Ești sigur?
money_field:
label: Sumă
+ exchange_rate_tabs:
+ calculate_rate_tab: Calculați rata FX
+ convert_tab: Convertire cu rata FX
+ destination_amount: Suma de destinație
+ exchange_rate: Rata de schimb
+ exchange_rate_help: Alege cum dorești să introduci suma.
syncing_notice:
syncing: Se sincronizează datele conturilor...
trend_change:
diff --git a/config/locales/views/shared/tr.yml b/config/locales/views/shared/tr.yml
index 8970dc097..045e211c2 100644
--- a/config/locales/views/shared/tr.yml
+++ b/config/locales/views/shared/tr.yml
@@ -8,6 +8,12 @@ tr:
title: Emin misiniz?
money_field:
label: Tutar
+ exchange_rate_tabs:
+ calculate_rate_tab: Döviz Kuru Hesapla
+ convert_tab: Döviz Kuru ile Dönüştür
+ destination_amount: Hedef Tutar
+ exchange_rate: Döviz Kuru
+ exchange_rate_help: Tutar girişiniz için yöntemi seçin.
syncing_notice:
syncing: Hesap verileri senkronize ediliyor...
trend_change:
diff --git a/config/locales/views/shared/zh-CN.yml b/config/locales/views/shared/zh-CN.yml
index d37cc3c3f..a84a48379 100644
--- a/config/locales/views/shared/zh-CN.yml
+++ b/config/locales/views/shared/zh-CN.yml
@@ -8,6 +8,12 @@ zh-CN:
title: 确定要执行此操作?
money_field:
label: 金额
+ exchange_rate_tabs:
+ calculate_rate_tab: 计算汇率
+ convert_tab: 用汇率转换
+ destination_amount: 目标金额
+ exchange_rate: 汇率
+ exchange_rate_help: 选择如何输入金额。
syncing_notice:
syncing: 账户数据同步中...
transaction_tabs:
diff --git a/config/locales/views/shared/zh-TW.yml b/config/locales/views/shared/zh-TW.yml
index a29edfe67..b1e193f22 100644
--- a/config/locales/views/shared/zh-TW.yml
+++ b/config/locales/views/shared/zh-TW.yml
@@ -8,6 +8,12 @@ zh-TW:
title: 您確定嗎?
money_field:
label: 金額
+ exchange_rate_tabs:
+ calculate_rate_tab: 計算匯率
+ convert_tab: 用匯率轉換
+ destination_amount: 目標金額
+ exchange_rate: 匯率
+ exchange_rate_help: 選擇如何輸入金額。
syncing_notice:
syncing: 正在同步帳戶資料...
trend_change:
diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml
index 669bae2d3..9116f4f6b 100644
--- a/config/locales/views/transfers/en.yml
+++ b/config/locales/views/transfers/en.yml
@@ -7,11 +7,19 @@ en:
success: Transfer removed
form:
amount: Amount
+ calculate_rate_tab: Calculate FX rate
+ convert_tab: Convert with FX rate
date: Date
+ destination_amount: Destination amount
+ destination_amount_display: "Destination amount: %{amount}"
+ exchange_rate: Exchange rate
+ exchange_rate_display: "Exchange rate: %{rate}"
+ exchange_rate_help: Choose how to enter the transfer amount.
expense: Expense
from: From
income: Income
select_account: Select account
+ source_amount: Source amount
submit: Create transfer
to: To
transfer: Transfer
diff --git a/config/locales/views/transfers/es.yml b/config/locales/views/transfers/es.yml
index f16061fa3..419c9b719 100644
--- a/config/locales/views/transfers/es.yml
+++ b/config/locales/views/transfers/es.yml
@@ -7,11 +7,19 @@ es:
success: Transferencia eliminada
form:
amount: Importe
+ calculate_rate_tab: Calcular tasa
+ convert_tab: Convertir con tasa
date: Fecha
+ destination_amount: Importe de destino
+ destination_amount_display: "Importe de destino: %{amount}"
+ exchange_rate: Tasa de cambio
+ exchange_rate_display: "Tasa de cambio: %{rate}"
+ exchange_rate_help: Elige cómo introducir el importe de la transferencia.
expense: Gasto
from: Desde
income: Ingreso
select_account: Seleccionar cuenta
+ source_amount: Importe de origen
submit: Crear transferencia
to: Hacia
transfer: Transferencia
diff --git a/config/routes.rb b/config/routes.rb
index 8082c1efe..5f723eed0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -249,6 +249,8 @@ Rails.application.routes.draw do
end
end
+ get :exchange_rate, to: "exchange_rates#show"
+
resources :transfers, only: %i[new create destroy show update]
resources :imports, only: %i[index new show create update destroy] do
diff --git a/lib/money.rb b/lib/money.rb
index ed1e00d48..0d1e73a88 100644
--- a/lib/money.rb
+++ b/lib/money.rb
@@ -39,16 +39,30 @@ class Money
validate!
end
- def exchange_to(other_currency, date: Date.current, fallback_rate: nil)
+ # Exchange money to another currency
+ # Params:
+ # other_currency: target currency code (e.g. "USD")
+ # date: date for historical rates (default: Date.current)
+ # custom_rate: explicit exchange rate to use (skips lookup if provided, including nil check)
+ # Priority:
+ # 1. Use custom_rate if explicitly provided (not nil)
+ # 2. Look up rate via store.find_or_fetch_rate
+ # 3. Raise ConversionError if no valid rate available
+ def exchange_to(other_currency, date: Date.current, custom_rate: nil)
iso_code = currency.iso_code
other_iso_code = Money::Currency.new(other_currency).iso_code
if iso_code == other_iso_code
self
else
- exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate
+ # Use custom rate if provided, otherwise look it up
+ if custom_rate.present?
+ exchange_rate = custom_rate.to_d
+ else
+ exchange_rate = store.find_or_fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate
+ end
- raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate
+ raise ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate && exchange_rate > 0
Money.new(amount * exchange_rate, other_iso_code)
end
diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb
index 69724cda0..313c12efe 100644
--- a/test/application_system_test_case.rb
+++ b/test/application_system_test_case.rb
@@ -65,4 +65,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
yield
end
end
+
+ # Interact with DS::Select custom dropdown components.
+ # DS::Select renders as a button + listbox — not a native