mirror of
https://github.com/we-promise/sure.git
synced 2026-04-11 00:04:47 +00: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
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user