mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
committed by
GitHub
parent
8e81e967fc
commit
f699660479
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
36
app/controllers/exchange_rates_controller.rb
Normal file
36
app/controllers/exchange_rates_controller.rb
Normal 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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
298
app/javascript/controllers/exchange_rate_form_controller.js
Normal file
298
app/javascript/controllers/exchange_rate_form_controller.js
Normal 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()");
|
||||
}
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
60
app/javascript/controllers/transaction_form_controller.js
Normal file
60
app/javascript/controllers/transaction_form_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
59
app/javascript/controllers/transfer_form_controller.js
Normal file
59
app/javascript/controllers/transfer_form_controller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
39
app/views/shared/_exchange_rate_tabs.html.erb
Normal file
39
app/views/shared/_exchange_rate_tabs.html.erb
Normal 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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
---
|
||||
nb:
|
||||
shared:
|
||||
confirm_modal:
|
||||
accept: Bekreft
|
||||
body_html: "<p>Du vil ikke kunne angre denne beslutningen</p>"
|
||||
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: "<p>Du vil ikke kunne angre denne beslutningen</p>"
|
||||
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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
20
lib/money.rb
20
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
|
||||
|
||||
@@ -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 <select> — so
|
||||
# Capybara's built-in `select(value, from:)` does not work with it.
|
||||
def select_ds(label_text, record)
|
||||
field_label = find("label", exact_text: label_text)
|
||||
container = field_label.ancestor("div.relative")
|
||||
container.find("button").click
|
||||
if container.has_selector?("input[type='search']", visible: true)
|
||||
container.find("input[type='search']", visible: true).set(record.name)
|
||||
end
|
||||
listbox = container.find("[role='listbox']", visible: true)
|
||||
listbox.find("[role='option'][data-value='#{record.id}']", visible: true).click
|
||||
end
|
||||
end
|
||||
|
||||
173
test/controllers/exchange_rates_controller_test.rb
Normal file
173
test/controllers/exchange_rates_controller_test.rb
Normal file
@@ -0,0 +1,173 @@
|
||||
require "test_helper"
|
||||
|
||||
class ExchangeRatesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
test "returns rate for different currencies" do
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: Date.current
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.2, json_response["rate"]
|
||||
end
|
||||
|
||||
test "returns same_currency flag for matching currencies" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "USD",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["same_currency"]
|
||||
assert_equal 1.0, json_response["rate"]
|
||||
end
|
||||
|
||||
test "uses provided date for rate lookup" do
|
||||
custom_date = 3.days.ago.to_date
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: custom_date,
|
||||
rate: 1.25
|
||||
)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: custom_date
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.25, json_response["rate"]
|
||||
end
|
||||
|
||||
test "defaults to current date when not provided" do
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.2, json_response["rate"]
|
||||
end
|
||||
|
||||
test "returns 400 when from currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "returns 400 when to currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "returns 400 on invalid date format" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: "not-a-date"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Invalid date format", json_response["error"]
|
||||
end
|
||||
|
||||
test "returns 404 when rate not found" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: Date.current
|
||||
}
|
||||
|
||||
assert_response :not_found
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Exchange rate not found", json_response["error"]
|
||||
end
|
||||
|
||||
test "handles uppercase and lowercase currency codes" do
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "eur",
|
||||
to: "usd"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.2, json_response["rate"]
|
||||
end
|
||||
|
||||
test "returns numeric rate even when object has rate method" do
|
||||
# Create mock object that returns a rate
|
||||
rate_obj = OpenStruct.new(rate: 1.2)
|
||||
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "EUR", to: "USD", date: Date.current)
|
||||
.returns(rate_obj)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.2, json_response["rate"]
|
||||
assert_instance_of Float, json_response["rate"]
|
||||
end
|
||||
|
||||
test "returns error when find_or_fetch_rate raises exception" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "EUR", to: "USD", date: Date.current)
|
||||
.raises(StandardError, "Rate fetch failed")
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Failed to fetch exchange rate", json_response["error"]
|
||||
end
|
||||
end
|
||||
@@ -390,4 +390,157 @@ end
|
||||
assert_not entry.import_locked?
|
||||
assert_not entry.protected_from_sync?
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns rate for different currencies" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "EUR", to: "USD", date: Date.current)
|
||||
.returns(1.2)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: Date.current
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.2, json_response["rate"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns same_currency for matching currencies" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "USD",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["same_currency"]
|
||||
assert_equal 1.0, json_response["rate"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint uses provided date" do
|
||||
custom_date = 3.days.ago.to_date
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "EUR", to: "USD", date: custom_date)
|
||||
.returns(1.25)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: custom_date
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 1.25, json_response["rate"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 when from currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 when to currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 on invalid date format" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: "not-a-date"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Invalid date format", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 404 when rate not found" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "EUR", to: "USD", date: Date.current)
|
||||
.returns(nil)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :not_found
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Exchange rate not found", json_response["error"]
|
||||
end
|
||||
|
||||
test "creates transaction with custom exchange rate" do
|
||||
account = @user.family.accounts.create!(
|
||||
name: "USD Account",
|
||||
currency: "USD",
|
||||
balance: 1000,
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
post transactions_url, params: {
|
||||
entry: {
|
||||
account_id: account.id,
|
||||
name: "EUR transaction with custom rate",
|
||||
date: Date.current,
|
||||
currency: "EUR",
|
||||
amount: 100,
|
||||
nature: "outflow",
|
||||
entryable_type: "Transaction",
|
||||
entryable_attributes: {
|
||||
category_id: Category.first.id,
|
||||
exchange_rate: "1.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_entry = Entry.order(:created_at).last
|
||||
assert_equal "EUR", created_entry.currency
|
||||
assert_equal 100, created_entry.amount
|
||||
assert_equal 1.5, created_entry.transaction.extra["exchange_rate"]
|
||||
end
|
||||
|
||||
test "creates transaction without custom exchange rate" do
|
||||
account = @user.family.accounts.create!(
|
||||
name: "USD Account",
|
||||
currency: "USD",
|
||||
balance: 1000,
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
post transactions_url, params: {
|
||||
entry: {
|
||||
account_id: account.id,
|
||||
name: "EUR transaction without custom rate",
|
||||
date: Date.current,
|
||||
currency: "EUR",
|
||||
amount: 100,
|
||||
nature: "outflow",
|
||||
entryable_type: "Transaction",
|
||||
entryable_attributes: {
|
||||
category_id: Category.first.id
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_entry = Entry.order(:created_at).last
|
||||
assert_nil created_entry.transaction.extra["exchange_rate"]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,143 @@ class TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "can create transfer with custom exchange rate" do
|
||||
usd_account = accounts(:depository)
|
||||
eur_account = users(:family_admin).family.accounts.create!(
|
||||
name: "EUR Account",
|
||||
balance: 1000,
|
||||
currency: "EUR",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
assert_equal "USD", usd_account.currency
|
||||
assert_equal "EUR", eur_account.currency
|
||||
|
||||
assert_difference "Transfer.count", 1 do
|
||||
post transfers_url, params: {
|
||||
transfer: {
|
||||
from_account_id: usd_account.id,
|
||||
to_account_id: eur_account.id,
|
||||
date: Date.current,
|
||||
amount: 100,
|
||||
exchange_rate: 0.92
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
transfer = Transfer.where(
|
||||
"outflow_transaction_id IN (?) AND inflow_transaction_id IN (?)",
|
||||
usd_account.transactions.pluck(:id),
|
||||
eur_account.transactions.pluck(:id)
|
||||
).last
|
||||
assert_not_nil transfer
|
||||
assert_equal "USD", transfer.outflow_transaction.entry.currency
|
||||
assert_equal "EUR", transfer.inflow_transaction.entry.currency
|
||||
assert_equal 100, transfer.outflow_transaction.entry.amount
|
||||
assert_in_delta(-92, transfer.inflow_transaction.entry.amount, 0.01)
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 when from currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 when to currency is missing" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "from and to currencies are required", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns 400 on invalid date format" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "EUR",
|
||||
to: "USD",
|
||||
date: "not-a-date"
|
||||
}
|
||||
|
||||
assert_response :bad_request
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Invalid date format", json_response["error"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns rate for different currencies" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "USD", to: "EUR", date: Date.current)
|
||||
.returns(OpenStruct.new(rate: 0.92))
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
date: Date.current.to_s
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal 0.92, json_response["rate"]
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns error when exchange rate unavailable" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "USD", to: "EUR", date: Date.current)
|
||||
.returns(nil)
|
||||
|
||||
get exchange_rate_url, params: {
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
date: Date.current.to_s
|
||||
}
|
||||
|
||||
assert_response :not_found
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal "Exchange rate not found", json_response["error"]
|
||||
end
|
||||
|
||||
test "cannot create transfer when exchange rate unavailable and no custom rate provided" do
|
||||
usd_account = accounts(:depository)
|
||||
eur_account = users(:family_admin).family.accounts.create!(
|
||||
name: "EUR Account",
|
||||
balance: 1000,
|
||||
currency: "EUR",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
ExchangeRate.stubs(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
assert_no_difference "Transfer.count" do
|
||||
post transfers_url, params: {
|
||||
transfer: {
|
||||
from_account_id: usd_account.id,
|
||||
to_account_id: eur_account.id,
|
||||
date: Date.current,
|
||||
amount: 100
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "exchange_rate endpoint returns same_currency for matching currencies" do
|
||||
get exchange_rate_url, params: {
|
||||
from: "USD",
|
||||
to: "USD"
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert_equal true, json_response["same_currency"]
|
||||
assert_equal 1.0, json_response["rate"]
|
||||
end
|
||||
|
||||
test "soft deletes transfer" do
|
||||
assert_difference -> { Transfer.count }, -1 do
|
||||
delete transfer_url(transfers(:one))
|
||||
|
||||
@@ -180,18 +180,27 @@ class MoneyTest < ActiveSupport::TestCase
|
||||
assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur)
|
||||
end
|
||||
|
||||
test "raises when no conversion rate available and no fallback rate provided" do
|
||||
test "raises when no conversion rate available and no fallback provided" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
|
||||
|
||||
assert_raises Money::ConversionError do
|
||||
assert_raises(Money::ConversionError) do
|
||||
Money.new(1000).exchange_to(:jpy)
|
||||
end
|
||||
end
|
||||
|
||||
test "converts currency with a fallback rate" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate).returns(nil).twice
|
||||
test "uses custom rate when provided" do
|
||||
ExchangeRate.expects(:find_or_fetch_rate).never
|
||||
|
||||
assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0)
|
||||
assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1)
|
||||
assert_equal Money.new(1250, :jpy), Money.new(1000, :usd).exchange_to(:jpy, custom_rate: 1.25)
|
||||
end
|
||||
|
||||
test "raises error when custom rate is invalid" do
|
||||
assert_raises(Money::ConversionError) do
|
||||
Money.new(1000).exchange_to(:jpy, custom_rate: 0)
|
||||
end
|
||||
|
||||
assert_raises(Money::ConversionError) do
|
||||
Money.new(1000).exchange_to(:jpy, custom_rate: -1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -693,8 +693,8 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "multi-currency account falls back to full recalc so late exchange rate imports are picked up" do
|
||||
# Step 1: Create account with a EUR entry but NO exchange rate yet.
|
||||
# SyncCache will use fallback_rate: 1, so the €500 entry is treated as $500.
|
||||
# Step 1: Create account with a EUR entry and a stale exchange rate (1:1 EUR→USD).
|
||||
# This simulates an initial sync where an imprecise rate is available.
|
||||
account = create_account_with_ledger(
|
||||
account: { type: Depository, currency: "USD" },
|
||||
entries: [
|
||||
@@ -703,14 +703,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
{ type: "transaction", date: 2.days.ago.to_date, amount: -500, currency: "EUR" }
|
||||
]
|
||||
)
|
||||
ExchangeRate.create!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.0)
|
||||
|
||||
# First full sync — balances computed with fallback rate (1:1 EUR→USD).
|
||||
# First full sync — balances computed with stale rate (1:1 EUR→USD).
|
||||
# opening 100 + $100 txn + €500*1.0 = $700
|
||||
Balance::Materializer.new(account, strategy: :forward).materialize_balances
|
||||
stale_balance = account.balances.find_by(date: 2.days.ago.to_date)
|
||||
assert stale_balance, "Balance should exist after full sync"
|
||||
|
||||
# Step 2: Exchange rate arrives later (e.g. daily cron imports it).
|
||||
ExchangeRate.create!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2)
|
||||
# Step 2: Corrected exchange rate arrives later (e.g. daily cron imports it).
|
||||
ExchangeRate.find_by!(date: 2.days.ago.to_date, from_currency: "EUR", to_currency: "USD").update!(rate: 1.2)
|
||||
|
||||
# Step 3: Next sync requests incremental from today — but the guard should
|
||||
# force a full recalc because the account has multi-currency entries.
|
||||
@@ -725,7 +727,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
# The EUR entry on 2.days.ago is now converted at 1.2, so the balance
|
||||
# picks up the corrected rate: opening 100 + $100 txn + €500*1.2 = $800
|
||||
# (without the guard, incremental mode would have seeded from the stale
|
||||
# $700 balance computed with fallback_rate 1, and never corrected it).
|
||||
# $700 balance computed with rate 1.0, and never corrected it).
|
||||
corrected = result.find { |b| b.date == 2.days.ago.to_date }
|
||||
assert corrected
|
||||
assert_equal 800, corrected.balance,
|
||||
|
||||
156
test/models/balance/sync_cache_test.rb
Normal file
156
test/models/balance/sync_cache_test.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
require "test_helper"
|
||||
|
||||
class Balance::SyncCacheTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Account",
|
||||
accountable: Depository.new,
|
||||
currency: "USD",
|
||||
balance: 1000
|
||||
)
|
||||
end
|
||||
|
||||
test "uses custom exchange rate from transaction extra field when present" do
|
||||
# Create a transaction with EUR currency and custom exchange rate
|
||||
_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "Test Transaction",
|
||||
amount: 100, # €100
|
||||
currency: "EUR",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: { "exchange_rate" => "1.5" } # Custom rate: 1.5 (vs actual rate might be different)
|
||||
)
|
||||
)
|
||||
|
||||
sync_cache = Balance::SyncCache.new(@account)
|
||||
converted_entries = sync_cache.send(:converted_entries)
|
||||
|
||||
converted_entry = converted_entries.first
|
||||
assert_equal "USD", converted_entry.currency
|
||||
assert_equal 150.0, converted_entry.amount # 100 * 1.5 = 150
|
||||
end
|
||||
|
||||
test "uses standard exchange rate lookup when custom rate not present" do
|
||||
# Create an exchange rate in the database
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "Test Transaction",
|
||||
amount: 100, # €100
|
||||
currency: "EUR",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: {} # No custom exchange rate
|
||||
)
|
||||
)
|
||||
|
||||
sync_cache = Balance::SyncCache.new(@account)
|
||||
converted_entries = sync_cache.send(:converted_entries)
|
||||
|
||||
converted_entry = converted_entries.first
|
||||
assert_equal "USD", converted_entry.currency
|
||||
assert_equal 120.0, converted_entry.amount # 100 * 1.2 = 120
|
||||
end
|
||||
|
||||
test "converts multiple entries with correct rates" do
|
||||
# Create exchange rates
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
ExchangeRate.create!(
|
||||
from_currency: "GBP",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.27
|
||||
)
|
||||
|
||||
# Create multiple entries in different currencies
|
||||
_eur_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "EUR Transaction",
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: {}
|
||||
)
|
||||
)
|
||||
|
||||
_gbp_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "GBP Transaction",
|
||||
amount: 50,
|
||||
currency: "GBP",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: {}
|
||||
)
|
||||
)
|
||||
|
||||
_usd_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "USD Transaction",
|
||||
amount: 75,
|
||||
currency: "USD",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: {}
|
||||
)
|
||||
)
|
||||
|
||||
sync_cache = Balance::SyncCache.new(@account)
|
||||
converted_entries = sync_cache.send(:converted_entries)
|
||||
|
||||
assert_equal 3, converted_entries.length
|
||||
|
||||
# All should be in USD
|
||||
converted_entries.each { |e| assert_equal "USD", e.currency }
|
||||
|
||||
# Check converted amounts
|
||||
# Sort amounts to check regardless of order
|
||||
amounts = converted_entries.map(&:amount).sort
|
||||
assert_in_delta 63.5, amounts[0], 0.01 # 50 GBP * 1.27
|
||||
assert_in_delta 75.0, amounts[1], 0.01 # 75 USD * 1.0
|
||||
assert_in_delta 120.0, amounts[2], 0.01 # 100 EUR * 1.2
|
||||
end
|
||||
|
||||
test "prioritizes custom rate over fetched rate" do
|
||||
# Create fetched rate
|
||||
ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.2
|
||||
)
|
||||
|
||||
# Create entry with custom rate that differs from fetched
|
||||
_entry = @account.entries.create!(
|
||||
date: Date.current,
|
||||
name: "EUR Transaction with custom rate",
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
entryable: Transaction.new(
|
||||
category: @family.categories.first,
|
||||
extra: { "exchange_rate" => "1.5" }
|
||||
)
|
||||
)
|
||||
|
||||
sync_cache = Balance::SyncCache.new(@account)
|
||||
converted_entries = sync_cache.send(:converted_entries)
|
||||
|
||||
converted_entry = converted_entries.first
|
||||
# Should use custom rate (1.5), not fetched rate (1.2)
|
||||
assert_equal 150.0, converted_entry.amount # 100 * 1.5, not 100 * 1.2
|
||||
end
|
||||
end
|
||||
@@ -58,8 +58,9 @@ class HoldingTest < ActiveSupport::TestCase
|
||||
nvda_qty = BigDecimal("5") + BigDecimal("30")
|
||||
expected_nvda_usd = nvda_total_usd / nvda_qty
|
||||
|
||||
assert_equal Money.new(expected_amzn_usd, "CAD").exchange_to("USD", fallback_rate: 1), @amzn.avg_cost
|
||||
assert_equal Money.new(expected_nvda_usd, "CAD").exchange_to("USD", fallback_rate: 1), @nvda.avg_cost
|
||||
ExchangeRate.stubs(:find_or_fetch_rate).returns(OpenStruct.new(rate: 1))
|
||||
assert_equal Money.new(expected_amzn_usd, "CAD").exchange_to("USD"), @amzn.avg_cost
|
||||
assert_equal Money.new(expected_nvda_usd, "CAD").exchange_to("USD"), @nvda.avg_cost
|
||||
end
|
||||
|
||||
test "calculates total return trend" do
|
||||
|
||||
@@ -67,4 +67,64 @@ class TransactionTest < ActiveSupport::TestCase
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
|
||||
assert_includes Transaction::ACTIVITY_LABELS, "Other"
|
||||
end
|
||||
|
||||
test "exchange_rate getter returns nil when extra is nil" do
|
||||
transaction = Transaction.new
|
||||
assert_nil transaction.exchange_rate
|
||||
end
|
||||
|
||||
test "exchange_rate setter stores normalized numeric value" do
|
||||
transaction = Transaction.new
|
||||
transaction.exchange_rate = "1.5"
|
||||
|
||||
assert_equal 1.5, transaction.exchange_rate
|
||||
end
|
||||
|
||||
test "exchange_rate setter marks invalid input" do
|
||||
transaction = Transaction.new
|
||||
transaction.exchange_rate = "not a number"
|
||||
|
||||
assert_equal "not a number", transaction.extra["exchange_rate"]
|
||||
assert transaction.extra["exchange_rate_invalid"]
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects non-numeric input" do
|
||||
transaction = Transaction.new(
|
||||
category: categories(:income),
|
||||
extra: { "exchange_rate" => "invalid" }
|
||||
)
|
||||
transaction.exchange_rate = "not a number"
|
||||
|
||||
assert_not transaction.valid?
|
||||
assert_includes transaction.errors[:exchange_rate], "must be a number"
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects zero values" do
|
||||
transaction = Transaction.new(
|
||||
category: categories(:income)
|
||||
)
|
||||
transaction.exchange_rate = 0
|
||||
|
||||
assert_not transaction.valid?
|
||||
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
|
||||
end
|
||||
|
||||
test "exchange_rate validation rejects negative values" do
|
||||
transaction = Transaction.new(
|
||||
category: categories(:income)
|
||||
)
|
||||
transaction.exchange_rate = -1.5
|
||||
|
||||
assert_not transaction.valid?
|
||||
assert_includes transaction.errors[:exchange_rate], "must be greater than 0"
|
||||
end
|
||||
|
||||
test "exchange_rate validation allows positive values" do
|
||||
transaction = Transaction.new(
|
||||
category: categories(:income)
|
||||
)
|
||||
transaction.exchange_rate = 1.5
|
||||
|
||||
assert transaction.valid?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -218,4 +218,104 @@ class Transfer::CreatorTest < ActiveSupport::TestCase
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "creates transfer with custom exchange rate when provided" do
|
||||
# Create accounts with different currencies
|
||||
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new)
|
||||
|
||||
custom_rate = 0.92 # 1 USD = 0.92 EUR
|
||||
amount = 100
|
||||
|
||||
creator = Transfer::Creator.new(
|
||||
family: @family,
|
||||
source_account_id: usd_account.id,
|
||||
destination_account_id: eur_account.id,
|
||||
date: @date,
|
||||
amount: amount,
|
||||
exchange_rate: custom_rate
|
||||
)
|
||||
|
||||
transfer = creator.create
|
||||
|
||||
assert transfer.persisted?
|
||||
|
||||
# Verify outflow transaction is in source currency
|
||||
outflow = transfer.outflow_transaction
|
||||
assert_equal amount, outflow.entry.amount
|
||||
assert_equal "USD", outflow.entry.currency
|
||||
|
||||
# Verify inflow transaction uses custom exchange rate
|
||||
inflow = transfer.inflow_transaction
|
||||
expected_eur_amount = (amount * custom_rate * -1).round(2)
|
||||
assert_in_delta expected_eur_amount, inflow.entry.amount, 0.01
|
||||
assert_equal "EUR", inflow.entry.currency
|
||||
end
|
||||
|
||||
test "falls back to fetched exchange rate when custom rate not provided" do
|
||||
# Create accounts with different currencies
|
||||
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
gbp_account = @family.accounts.create!(name: "GBP Account", balance: 1000, currency: "GBP", accountable: Depository.new)
|
||||
|
||||
# Mock the exchange rate lookup
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "USD", to: "GBP", date: @date)
|
||||
.returns(OpenStruct.new(rate: 0.79))
|
||||
|
||||
creator = Transfer::Creator.new(
|
||||
family: @family,
|
||||
source_account_id: usd_account.id,
|
||||
destination_account_id: gbp_account.id,
|
||||
date: @date,
|
||||
amount: 100
|
||||
)
|
||||
|
||||
transfer = creator.create
|
||||
|
||||
assert transfer.persisted?
|
||||
assert_equal "USD", transfer.outflow_transaction.entry.currency
|
||||
assert_equal "GBP", transfer.inflow_transaction.entry.currency
|
||||
end
|
||||
|
||||
test "raises when no exchange rate available and none provided" do
|
||||
# Create accounts with different currencies
|
||||
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
jpy_account = @family.accounts.create!(name: "JPY Account", balance: 100000, currency: "JPY", accountable: Depository.new)
|
||||
|
||||
# Mock no exchange rate found
|
||||
ExchangeRate.expects(:find_or_fetch_rate)
|
||||
.with(from: "USD", to: "JPY", date: @date)
|
||||
.returns(nil)
|
||||
|
||||
creator = Transfer::Creator.new(
|
||||
family: @family,
|
||||
source_account_id: usd_account.id,
|
||||
destination_account_id: jpy_account.id,
|
||||
date: @date,
|
||||
amount: 100
|
||||
)
|
||||
|
||||
assert_raises(Money::ConversionError) do
|
||||
creator.create
|
||||
end
|
||||
end
|
||||
|
||||
test "custom exchange rate with very small value is valid" do
|
||||
usd_account = @family.accounts.create!(name: "USD Account", balance: 1000, currency: "USD", accountable: Depository.new)
|
||||
eur_account = @family.accounts.create!(name: "EUR Account", balance: 1000, currency: "EUR", accountable: Depository.new)
|
||||
|
||||
creator = Transfer::Creator.new(
|
||||
family: @family,
|
||||
source_account_id: usd_account.id,
|
||||
destination_account_id: eur_account.id,
|
||||
date: @date,
|
||||
amount: 100,
|
||||
exchange_rate: 0.000001
|
||||
)
|
||||
|
||||
transfer = creator.create
|
||||
|
||||
assert transfer.persisted?
|
||||
assert_in_delta(-0.0001, transfer.inflow_transaction.entry.amount, 0.0001)
|
||||
end
|
||||
end
|
||||
|
||||
146
test/system/transactions_form_exchange_rate_test.rb
Normal file
146
test/system/transactions_form_exchange_rate_test.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class TransactionsFormExchangeRateTest < ApplicationSystemTestCase
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@account_usd = accounts(:depository) # USD account
|
||||
sign_in @user
|
||||
|
||||
# Set up real exchange rates for testing
|
||||
@eur_usd_rate = ExchangeRate.create!(
|
||||
from_currency: "EUR",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.1
|
||||
)
|
||||
|
||||
@gbp_usd_rate = ExchangeRate.create!(
|
||||
from_currency: "GBP",
|
||||
to_currency: "USD",
|
||||
date: Date.current,
|
||||
rate: 1.27
|
||||
)
|
||||
end
|
||||
|
||||
test "changing amount currency to different currency shows exchange rate UI" do
|
||||
visit new_transaction_path
|
||||
|
||||
# Select USD account (which is in USD)
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
# Currency defaults to USD (same as account)
|
||||
# Change currency to EUR
|
||||
find("select[data-money-field-target='currency']").find("option[value='EUR']").select_option
|
||||
|
||||
# Exchange rate UI should appear
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
end
|
||||
|
||||
test "changing amount currency to same as account currency hides exchange rate UI" do
|
||||
visit new_transaction_path
|
||||
|
||||
# Select USD account
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
# Change to EUR first
|
||||
find("select[data-money-field-target='currency']").find("option[value='EUR']").select_option
|
||||
|
||||
# Verify exchange rate UI is shown
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
|
||||
# Change back to USD (same as account)
|
||||
find("select[data-money-field-target='currency']").find("option[value='USD']").select_option
|
||||
|
||||
# Exchange rate UI should hide
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: false
|
||||
end
|
||||
|
||||
test "exchange rate field is prefilled when rate is available" do
|
||||
visit new_transaction_path
|
||||
|
||||
# Select USD account
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
# Change to GBP (exchange rate is set up in fixtures)
|
||||
find("select[data-money-field-target='currency']").find("option[value='GBP']").select_option
|
||||
|
||||
# Wait for exchange rate container to become visible
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
|
||||
# Exchange rate field should be populated
|
||||
exchange_rate_field = find("[data-transaction-form-target='exchangeRateField']")
|
||||
assert_not_empty exchange_rate_field.value
|
||||
assert_equal "1.27", exchange_rate_field.value
|
||||
end
|
||||
|
||||
test "exchange rate field is empty when rate not found" do
|
||||
visit new_transaction_path
|
||||
|
||||
# Select USD account
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
# Change to CHF (Swiss Franc - no rate set up in fixtures)
|
||||
find("select[data-money-field-target='currency']").find("option[value='CHF']").select_option
|
||||
|
||||
# Wait for exchange rate container to become visible (manual rate entry mode)
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
|
||||
# Exchange rate section should be visible but field should be empty (manual entry)
|
||||
exchange_rate_field = find("[data-transaction-form-target='exchangeRateField']")
|
||||
assert_empty exchange_rate_field.value
|
||||
end
|
||||
|
||||
test "exchange rate is recalculated when currency changes" do
|
||||
visit new_transaction_path
|
||||
|
||||
# Select USD account
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
# Change to EUR
|
||||
find("select[data-money-field-target='currency']").find("option[value='EUR']").select_option
|
||||
|
||||
# Wait for EUR rate to load
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
first_rate = find("[data-transaction-form-target='exchangeRateField']").value
|
||||
assert_equal "1.10", first_rate
|
||||
|
||||
# Change to GBP
|
||||
find("select[data-money-field-target='currency']").find("option[value='GBP']").select_option
|
||||
|
||||
# Wait for GBP rate to be updated
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
second_rate = find("[data-transaction-form-target='exchangeRateField']").value
|
||||
assert_equal "1.27", second_rate
|
||||
|
||||
# Rates should be different
|
||||
assert_not_equal first_rate, second_rate
|
||||
end
|
||||
|
||||
test "changing account also recalculates exchange rate for current currency" do
|
||||
# Create a second account in EUR
|
||||
eur_account = @family.accounts.create!(
|
||||
name: "EUR Account",
|
||||
balance: 1000,
|
||||
currency: "EUR",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
visit new_transaction_path
|
||||
|
||||
# Start with USD account, then currency EUR
|
||||
select_ds("Account", @account_usd)
|
||||
|
||||
find("select[data-money-field-target='currency']").find("option[value='EUR']").select_option
|
||||
|
||||
# Exchange rate shown (both USD and EUR exist, they differ)
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: true
|
||||
|
||||
# Switch to EUR account
|
||||
select_ds("Account", eur_account)
|
||||
|
||||
# Now account is EUR and currency is EUR (same)
|
||||
# Exchange rate UI should hide
|
||||
assert_selector "[data-transaction-form-target='exchangeRateContainer']", visible: false
|
||||
end
|
||||
end
|
||||
@@ -27,6 +27,58 @@ class TransfersTest < ApplicationSystemTestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "shows exchange rate field for different currencies" do
|
||||
# Create an account with a different currency
|
||||
eur_account = @user.family.accounts.create!(
|
||||
name: "EUR Savings",
|
||||
balance: 1000,
|
||||
currency: "EUR",
|
||||
accountable: Depository.new
|
||||
)
|
||||
|
||||
# Set up exchange rate
|
||||
ExchangeRate.create!(
|
||||
from_currency: "USD",
|
||||
to_currency: "EUR",
|
||||
date: Date.current,
|
||||
rate: 0.92
|
||||
)
|
||||
|
||||
transfer_date = Date.current
|
||||
|
||||
click_on "New transaction"
|
||||
click_on "Transfer"
|
||||
assert_text "New transfer"
|
||||
|
||||
# Initially, exchange rate field should be hidden
|
||||
assert_selector "[data-transfer-form-target='exchangeRateContainer'].hidden", visible: :all
|
||||
|
||||
# Select accounts with different currencies
|
||||
select_ds("From", accounts(:depository))
|
||||
select_ds("To", eur_account)
|
||||
|
||||
# Exchange rate container should become visible
|
||||
assert_selector "[data-transfer-form-target='exchangeRateContainer']", visible: true
|
||||
|
||||
# Exchange rate field should be populated with fetched rate
|
||||
exchange_rate_field = find("[data-transfer-form-target='exchangeRateField']")
|
||||
assert_not_empty exchange_rate_field.value
|
||||
assert_equal "0.92", exchange_rate_field.value
|
||||
|
||||
# Fill in amount
|
||||
fill_in "transfer[amount]", with: 100
|
||||
fill_in "Date", with: transfer_date
|
||||
|
||||
# Submit form
|
||||
click_button "Create transfer"
|
||||
|
||||
# Should redirect and show transfer created
|
||||
assert_current_path transactions_url
|
||||
within "#entry-group-#{transfer_date}" do
|
||||
assert_text "Transfer to"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def select_ds(label_text, record)
|
||||
|
||||
Reference in New Issue
Block a user