Skip to content
14 changes: 6 additions & 8 deletions apps/desktop-tauri/src-tauri/src/commands/currency.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use std::{collections::HashMap, path::PathBuf};

use codexbar::currency::{
PreferredCurrency, SUPPORTED_CURRENCY_CODES, convert_amount, fetch_exchange_rates,
sanitize_rates,
CURRENCIES, PreferredCurrency, convert_amount, fetch_exchange_rates, sanitize_rates,
};
use serde::{Deserialize, Serialize};
use tauri::State;
Expand Down Expand Up @@ -91,7 +90,7 @@ pub async fn get_currency_rates(

Ok(CurrencyRatesSnapshot {
rates: state.rates.clone(),
supported_codes: SUPPORTED_CURRENCY_CODES.to_vec(),
supported_codes: CURRENCIES.iter().map(|c| c.code).collect(),
preferred_code: preferred.raw(),
})
}
Expand Down Expand Up @@ -184,16 +183,15 @@ fn persist_rates(cached: &PersistedRates) {
#[cfg(test)]
mod tests {
use super::*;
use codexbar::currency::FALLBACK_RATES;

#[test]
fn fallback_table_covers_every_preferred_currency() {
let rates = codexbar::currency::fallback_rates();
assert_eq!(rates.len(), FALLBACK_RATES.len());
for code in SUPPORTED_CURRENCY_CODES {
assert_eq!(rates.len(), CURRENCIES.len());
for currency in CURRENCIES {
assert!(
rates
.get(*code)
.get(currency.code)
.is_some_and(|rate| rate.is_finite() && *rate > 0.0)
);
}
Expand Down Expand Up @@ -222,7 +220,7 @@ mod tests {
let rates = merged_rates(Some(&cached));
assert_eq!(rates.get("TRY"), Some(&40.0));
// Unsupported codes in the cache never widen the table.
assert_eq!(rates.len(), FALLBACK_RATES.len());
assert_eq!(rates.len(), CURRENCIES.len());
}

#[test]
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop-tauri/src/lib/currency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ const RATES: Record<string, number> = {
GBP: 0.79,
EUR: 0.92,
TRY: 48.5,
// The twelve 0.67.0 additions (upstream fallback rates, 2026-09-24).
NZD: 1.761,
SEK: 9.908,
NOK: 9.48,
DKK: 6.554,
PLN: 3.838,
BRL: 5.117,
MXN: 17.47,
ZAR: 16.36,
THB: 33.37,
IDR: 17836,
VND: 25962,
UAH: 44.86,
};

describe("preferred currency display", () => {
Expand All @@ -17,6 +30,29 @@ describe("preferred currency display", () => {
expect(display).toMatch(/485/);
});

it("converts each added currency through the USD pivot", () => {
for (const code of ["NZD", "SEK", "NOK", "DKK", "PLN", "BRL", "MXN", "ZAR", "THB", "IDR", "VND", "UAH"]) {
const rate = RATES[code];
// The rendered string carries the Intl symbol prefix (e.g. "NZ$17.61",
// "17 618 Rp"), so assert on the exact converted digits.
const expected = new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
}).format(10 * rate);
expect(formatDisplayCurrency(10, "USD", code, RATES)).toBe(expected);
// Round trip back through the pivot.
expect(formatDisplayCurrency(10 * rate, code, "USD", RATES)).toMatch(/10(\.00)?$/);
}
expect(formatDisplayCurrency(10, "EUR", "SEK", RATES)).toMatch(/107\.7/);
});

it("formats zero-decimal currencies without fractional units", () => {
const vnd = formatDisplayCurrency(0.01, "USD", "VND", RATES);
expect(vnd).toMatch(/260/);
expect(vnd).not.toMatch(/[.,]\d{1,2}\D*$/);
expect(formatDisplayCurrency(1, "USD", "JPY", { ...RATES, JPY: 154 })).toMatch(/154(?![.,]\d)/);
});

it("keeps AUTO, credits, unknown units, and missing-rate values in source units", () => {
expect(formatDisplayCurrency(4.25, "USD", "AUTO", RATES)).toMatch(/4\.25/);
expect(formatDisplayCurrency(4.25, "Credits", "TRY", RATES)).toBe("4.25 Credits");
Expand Down Expand Up @@ -54,4 +90,20 @@ describe("preferred currency display", () => {
expect(result.included).toBe(1);
expect(result.total).toBe(10);
});

it("sums a row in each added currency into the TRY total", () => {
const result = sumDisplayCurrencyAmounts(
[
{ amount: 10, currency: "NZD" },
{ amount: 10, currency: "SEK" },
{ amount: 10, currency: "UAH" },
],
"TRY",
RATES,
);
expect(result.included).toBe(3);
expect(result.considered).toBe(3);
const expected = ((10 / 1.761) + (10 / 9.908) + (10 / 44.86)) * 48.5;
expect(result.total).toBeCloseTo(expected, 6);
});
});
81 changes: 48 additions & 33 deletions apps/desktop-tauri/src/lib/currency.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
/**
* Preferred display-currency formatting for the web UI.
*
* Rust owns the currency model (supported codes, fallback rates, conversion,
* rate sanitizing — see `codexbar::currency`); `get_currency_rates` returns
* the merged, sanitized table plus the normalized preference, so this module
* only renders strings through `Intl`.
*/
import { CURRENCY_CATALOG } from "./currencyCatalog.generated";

export interface CurrencyRatesSnapshot {
rates: Record<string, number>;
supportedCodes: readonly string[];
preferredCode: string;
// The catalog (order, symbols, offline rates) is generated from rust/src/currency.rs.
export const SUPPORTED_CURRENCIES: readonly string[] = CURRENCY_CATALOG.map((entry) => entry.code);

export const FALLBACK_CURRENCY_RATES: Record<string, number> = Object.fromEntries(
CURRENCY_CATALOG.map((entry) => [entry.code, entry.fallbackRate]),
);

export const CURRENCY_PICKER_OPTIONS: ReadonlyArray<{ value: string; label: string }> =
CURRENCY_CATALOG.map((entry) => ({ value: entry.code, label: `${entry.code} (${entry.symbol})` }));

export function normalizePreferredCurrency(value: string | null | undefined): string {
const code = value?.trim().toUpperCase() || "AUTO";
return code === "AUTO" || SUPPORTED_CURRENCIES.includes(code) ? code : "AUTO";
}

export function convertCurrencyAmount(
amount: number,
sourceCode: string,
targetCode: string,
rates: Record<string, number>,
): number | null {
if (!Number.isFinite(amount)) return null;
const source = sourceCode.trim().toUpperCase();
const target = targetCode.trim().toUpperCase();
if (!SUPPORTED_CURRENCIES.includes(source) || !SUPPORTED_CURRENCIES.includes(target)) return null;
if (source === target) return amount;
const sourceRate = source === "USD" ? 1 : rates[source];
const targetRate = target === "USD" ? 1 : rates[target];
if (!Number.isFinite(sourceRate) || sourceRate <= 0 || !Number.isFinite(targetRate) || targetRate <= 0) return null;
const result = (amount / sourceRate) * targetRate;
return Number.isFinite(result) ? result : null;
}

function formatOriginal(amount: number, code: string, symbol?: string | null): string {
Expand All @@ -34,35 +54,37 @@ export function formatDisplayCurrency(
const trimmedSource = sourceCode.trim();
const source = trimmedSource.toUpperCase();
const sourceLabel = /^[A-Za-z]{3}$/.test(trimmedSource) ? source : trimmedSource;
const preferred = preferredCode.trim().toUpperCase() || "AUTO";
const preferred = normalizePreferredCurrency(preferredCode);
if (preferred === "AUTO") return formatOriginal(amount, sourceLabel, sourceSymbol);
if (preferred === source) return formatOriginal(amount, sourceLabel, sourceSymbol);
const sourceRate = source === "USD" ? 1 : rates[source];
const targetRate = preferred === "USD" ? 1 : rates[preferred];
const usable =
Number.isFinite(amount) &&
Number.isFinite(sourceRate) && sourceRate! > 0 &&
Number.isFinite(targetRate) && targetRate! > 0;
if (!usable) return formatOriginal(amount, sourceLabel, sourceSymbol);
const converted = (amount / sourceRate!) * targetRate!;
if (!Number.isFinite(converted)) return formatOriginal(amount, sourceLabel, sourceSymbol);
const converted = convertCurrencyAmount(amount, source, preferred, rates);
if (converted == null) return formatOriginal(amount, sourceLabel, sourceSymbol);
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: preferred,
maximumFractionDigits: 2,
}).format(converted);
} catch {
return `${converted.toFixed(2)} ${preferred}`;
}
}

export function mergeValidCurrencyRates(input: Record<string, number>): Record<string, number> {
const rates = { ...FALLBACK_CURRENCY_RATES };
for (const code of SUPPORTED_CURRENCIES) {
const value = input[code];
if (Number.isFinite(value) && value > 0 && (code !== "USD" || Math.abs(value - 1) <= Number.EPSILON)) {
rates[code] = value;
}
}
return rates;
}

export function sumDisplayCurrencyAmounts(
rows: Array<{ amount: number | null | undefined; currency: string }>,
preferredCode: string,
rates: Record<string, number>,
): { total: number | null; included: number; considered: number } {
const target = preferredCode.trim().toUpperCase() || "AUTO";
const target = normalizePreferredCurrency(preferredCode);
let total = 0;
let included = 0;
for (const row of rows) {
Expand All @@ -71,14 +93,7 @@ export function sumDisplayCurrencyAmounts(
if (target === "AUTO") {
amount = row.currency.trim().toUpperCase() === "USD" ? row.amount : null;
} else {
const source = row.currency.trim().toUpperCase() || "USD";
const sourceRate = source === "USD" ? 1 : rates[source];
const targetRate = target === "USD" ? 1 : rates[target];
const usable =
Number.isFinite(sourceRate) && sourceRate! > 0 &&
Number.isFinite(targetRate) && targetRate! > 0;
amount = usable ? (row.amount / sourceRate!) * targetRate! : null;
if (amount != null && !Number.isFinite(amount)) amount = null;
amount = convertCurrencyAmount(row.amount, row.currency || "USD", target, rates);
}
if (amount == null) continue;
total += amount;
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop-tauri/src/lib/currencyCatalog.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Generated from rust/src/currency.rs (CURRENCIES). Do not edit by hand.
// Regenerate (PowerShell): $env:UPDATE_CURRENCY_CATALOG = '1'; cargo test -p codexbar currency_catalog
export const CURRENCY_CATALOG = [
{ code: "USD", symbol: "$", fallbackRate: 1 },
{ code: "GBP", symbol: "£", fallbackRate: 0.79 },
{ code: "EUR", symbol: "€", fallbackRate: 0.92 },
{ code: "CZK", symbol: "Kč", fallbackRate: 21 },
{ code: "CNY", symbol: "¥", fallbackRate: 7.27 },
{ code: "JPY", symbol: "¥", fallbackRate: 154 },
{ code: "KRW", symbol: "₩", fallbackRate: 1428.9 },
{ code: "CAD", symbol: "$", fallbackRate: 1.38 },
{ code: "AUD", symbol: "$", fallbackRate: 1.55 },
{ code: "HKD", symbol: "$", fallbackRate: 7.8 },
{ code: "TWD", symbol: "NT$", fallbackRate: 32.3 },
{ code: "SGD", symbol: "$", fallbackRate: 1.34 },
{ code: "INR", symbol: "₹", fallbackRate: 84.5 },
{ code: "CHF", symbol: "Fr.", fallbackRate: 0.8 },
{ code: "AED", symbol: "د.إ", fallbackRate: 3.6725 },
{ code: "TRY", symbol: "₺", fallbackRate: 48.5 },
{ code: "NZD", symbol: "$", fallbackRate: 1.761 },
{ code: "SEK", symbol: "kr", fallbackRate: 9.908 },
{ code: "NOK", symbol: "kr", fallbackRate: 9.48 },
{ code: "DKK", symbol: "kr", fallbackRate: 6.554 },
{ code: "PLN", symbol: "zł", fallbackRate: 3.838 },
{ code: "BRL", symbol: "R$", fallbackRate: 5.117 },
{ code: "MXN", symbol: "$", fallbackRate: 17.47 },
{ code: "ZAR", symbol: "R", fallbackRate: 16.36 },
{ code: "THB", symbol: "฿", fallbackRate: 33.37 },
{ code: "IDR", symbol: "Rp", fallbackRate: 17836 },
{ code: "VND", symbol: "₫", fallbackRate: 25962 },
{ code: "UAH", symbol: "₴", fallbackRate: 44.86 },
] as const;
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,16 @@ describe("GeneralTab language picker", () => {
});

describe("GeneralTab preferred currency picker", () => {
it("offers AUTO and every supported currency with the TRY symbol", () => {
it("offers AUTO then every catalog currency as CODE (symbol) in catalog order", () => {
render(<GeneralTab settings={settings} set={vi.fn()} saving={false} />);
const select = screen.getByLabelText("PreferredCurrencyLabel");
const options = Array.from(select.querySelectorAll("option"), (option) => option.textContent);
expect(options).toEqual(["AUTO", "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", "CHF", "AED", "TRY (₺)"]);
expect(options).toEqual([
"AUTO", "USD ($)", "GBP (£)", "EUR (€)", "CZK (Kč)", "CNY (¥)", "JPY (¥)", "KRW (₩)",
"CAD ($)", "AUD ($)", "HKD ($)", "TWD (NT$)", "SGD ($)", "INR (₹)", "CHF (Fr.)",
"AED (د.إ)", "TRY (₺)", "NZD ($)", "SEK (kr)", "NOK (kr)", "DKK (kr)", "PLN (zł)",
"BRL (R$)", "MXN ($)", "ZAR (R)", "THB (฿)", "IDR (Rp)", "VND (₫)", "UAH (₴)",
]);
});

it("persists an explicitly selected preferred currency", () => {
Expand Down
17 changes: 2 additions & 15 deletions apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
UsageThresholdOverride,
} from "../../../types/bridge";
import type { LocaleKey } from "../../../i18n/keys";
import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency";
import type { TabProps } from "../settingsTabs";

const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [
Expand All @@ -27,13 +28,6 @@ const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [
{ value: "turkish", display: "Türkçe" },
];

// Mirror of the Rust `SUPPORTED_CURRENCY_CODES` list for the first render;
// the backend list stays the source of truth (`get_currency_rates` returns it).
const SUPPORTED_CURRENCY_CODES_FALLBACK: string[] = [
"USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD",
"INR", "CHF", "AED", "TRY",
];

const REFRESH_CADENCE_OPTIONS: { value: string; labelKey: LocaleKey }[] = [
{ value: "0", labelKey: "RefreshIntervalManual" },
{ value: "adaptive", labelKey: "RefreshIntervalAdaptive" },
Expand Down Expand Up @@ -199,10 +193,6 @@ export default function GeneralTab({
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>(
FALLBACK_LANGUAGE_OPTIONS,
);
// The picker lists the backend-supported codes; this static mirror only
// covers the first render before `getCurrencyRates` resolves (the Rust
// `SUPPORTED_CURRENCY_CODES` list stays the source of truth).
const [currencyOptions] = useState<string[]>(SUPPORTED_CURRENCY_CODES_FALLBACK);

useEffect(() => {
invoke<LanguageOption[]>("get_available_languages")
Expand Down Expand Up @@ -284,10 +274,7 @@ export default function GeneralTab({
ariaLabel={t("PreferredCurrencyLabel")}
options={[
{ value: "AUTO", label: "AUTO" },
...currencyOptions.map((code) => ({
value: code,
label: code === "TRY" ? "TRY (₺)" : code,
})),
...CURRENCY_PICKER_OPTIONS,
]}
onChange={(value) => set({ preferredCurrencyCode: value })}
/>
Expand Down
Loading