diff --git a/apps/desktop-tauri/src-tauri/src/commands/currency.rs b/apps/desktop-tauri/src-tauri/src/commands/currency.rs index 5eae3cd246..753a544bac 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/currency.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/currency.rs @@ -1,8 +1,7 @@ use std::{collections::HashMap, path::PathBuf}; use codexbar::currency::{ - SUPPORTED_CURRENCY_CODES, convert_amount, fallback_rates, fetch_exchange_rates, - normalize_preferred_currency, + CURRENCIES, convert_amount, fallback_rates, fetch_exchange_rates, normalize_preferred_currency, }; use serde::{Deserialize, Serialize}; use tauri::State; @@ -102,12 +101,12 @@ fn cache_path() -> Option { fn clean_rates(rates: HashMap) -> HashMap { let mut clean = HashMap::new(); - for code in SUPPORTED_CURRENCY_CODES { - if let Some(rate) = rates.get(*code).copied() + for currency in CURRENCIES { + if let Some(rate) = rates.get(currency.code).copied() && rate.is_finite() && rate > 0.0 { - clean.insert((*code).to_string(), rate); + clean.insert(currency.code.to_string(), rate); } } if clean @@ -154,16 +153,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 = 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) ); } diff --git a/apps/desktop-tauri/src/lib/currency.test.ts b/apps/desktop-tauri/src/lib/currency.test.ts index 96a05d5879..3a6e8de056 100644 --- a/apps/desktop-tauri/src/lib/currency.test.ts +++ b/apps/desktop-tauri/src/lib/currency.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + CURRENCY_PICKER_OPTIONS, FALLBACK_CURRENCY_RATES, + SUPPORTED_CURRENCIES, convertCurrencyAmount, formatDisplayCurrency, mergeValidCurrencyRates, @@ -23,6 +25,38 @@ describe("preferred currency display", () => { expect(display).toMatch(/485/); }); + it("lists the twelve added currencies after TRY with upstream fallback rates", () => { + const added = ["NZD", "SEK", "NOK", "DKK", "PLN", "BRL", "MXN", "ZAR", "THB", "IDR", "VND", "UAH"]; + const tryIndex = SUPPORTED_CURRENCIES.indexOf("TRY"); + expect(SUPPORTED_CURRENCIES.slice(tryIndex + 1)).toEqual(added); + expect(CURRENCY_PICKER_OPTIONS.slice(tryIndex + 1).map((option) => option.label)).toEqual([ + "NZD ($)", "SEK (kr)", "NOK (kr)", "DKK (kr)", "PLN (zł)", "BRL (R$)", + "MXN ($)", "ZAR (R)", "THB (฿)", "IDR (Rp)", "VND (₫)", "UAH (₴)", + ]); + expect(FALLBACK_CURRENCY_RATES).toMatchObject({ + 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, + }); + expect(Object.keys(FALLBACK_CURRENCY_RATES)).toEqual([...SUPPORTED_CURRENCIES]); + for (const code of added) expect(normalizePreferredCurrency(code.toLowerCase())).toBe(code); + }); + + 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 = FALLBACK_CURRENCY_RATES[code]; + expect(convertCurrencyAmount(10, "USD", code, FALLBACK_CURRENCY_RATES)).toBeCloseTo(10 * rate, 8); + expect(convertCurrencyAmount(10 * rate, code, "USD", FALLBACK_CURRENCY_RATES)).toBeCloseTo(10, 8); + } + expect(convertCurrencyAmount(10, "EUR", "SEK", FALLBACK_CURRENCY_RATES)).toBeCloseTo((10 / 0.92) * 9.908, 8); + }); + + it("formats zero-decimal currencies without fractional units", () => { + const vnd = formatDisplayCurrency(0.01, "USD", "VND", FALLBACK_CURRENCY_RATES); + expect(vnd).toMatch(/260/); + expect(vnd).not.toMatch(/[.,]\d{1,2}\D*$/); + expect(formatDisplayCurrency(1, "USD", "JPY", FALLBACK_CURRENCY_RATES)).toMatch(/154(?![.,]\d)/); + }); + it("keeps AUTO, credits, unknown units, and missing-rate values in source units", () => { expect(formatDisplayCurrency(4.25, "USD", "AUTO", FALLBACK_CURRENCY_RATES)).toMatch(/4\.25/); expect(formatDisplayCurrency(4.25, "Credits", "TRY", FALLBACK_CURRENCY_RATES)).toBe("4.25 Credits"); diff --git a/apps/desktop-tauri/src/lib/currency.ts b/apps/desktop-tauri/src/lib/currency.ts index fb83245eed..110744de74 100644 --- a/apps/desktop-tauri/src/lib/currency.ts +++ b/apps/desktop-tauri/src/lib/currency.ts @@ -1,32 +1,18 @@ -export const SUPPORTED_CURRENCIES = [ - "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", - "INR", "CHF", "AED", "TRY", -] as const; +import { CURRENCY_CATALOG } from "./currencyCatalog.generated"; -export const FALLBACK_CURRENCY_RATES: Record = { - USD: 1, - GBP: 0.79, - EUR: 0.92, - CZK: 21, - CNY: 7.27, - JPY: 154, - KRW: 1428.9, - CAD: 1.38, - AUD: 1.55, - HKD: 7.8, - TWD: 32.3, - SGD: 1.34, - INR: 84.5, - CHF: 0.8, - AED: 3.6725, - TRY: 48.5, -}; +// 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 = 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 as (typeof SUPPORTED_CURRENCIES)[number]) - ? code - : "AUTO"; + return code === "AUTO" || SUPPORTED_CURRENCIES.includes(code) ? code : "AUTO"; } export function convertCurrencyAmount( @@ -38,8 +24,7 @@ export function convertCurrencyAmount( if (!Number.isFinite(amount)) return null; const source = sourceCode.trim().toUpperCase(); const target = targetCode.trim().toUpperCase(); - if (!SUPPORTED_CURRENCIES.includes(source as (typeof SUPPORTED_CURRENCIES)[number]) || - !SUPPORTED_CURRENCIES.includes(target as (typeof SUPPORTED_CURRENCIES)[number])) return null; + 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]; @@ -77,7 +62,6 @@ export function formatDisplayCurrency( return new Intl.NumberFormat(undefined, { style: "currency", currency: preferred, - maximumFractionDigits: 2, }).format(converted); } catch { return `${converted.toFixed(2)} ${preferred}`; diff --git a/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts new file mode 100644 index 0000000000..8ca3e7089f --- /dev/null +++ b/apps/desktop-tauri/src/lib/currencyCatalog.generated.ts @@ -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; diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index d17c651de3..37b77c7d1b 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -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(); 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", () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 765c9934dc..80391eac54 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -14,7 +14,7 @@ import type { UsageThresholdOverride, } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; -import { SUPPORTED_CURRENCIES } from "../../../lib/currency"; +import { CURRENCY_PICKER_OPTIONS } from "../../../lib/currency"; import type { TabProps } from "../settingsTabs"; const FALLBACK_LANGUAGE_OPTIONS: LanguageOption[] = [ @@ -274,10 +274,7 @@ export default function GeneralTab({ ariaLabel={t("PreferredCurrencyLabel")} options={[ { value: "AUTO", label: "AUTO" }, - ...SUPPORTED_CURRENCIES.map((code) => ({ - value: code, - label: code === "TRY" ? "TRY (₺)" : code, - })), + ...CURRENCY_PICKER_OPTIONS, ]} onChange={(value) => set({ preferredCurrencyCode: value })} /> diff --git a/rust/src/currency.rs b/rust/src/currency.rs index 8040d9c92b..2670016b90 100644 --- a/rust/src/currency.rs +++ b/rust/src/currency.rs @@ -3,33 +3,87 @@ use std::collections::HashMap; use std::time::Duration; -pub const SUPPORTED_CURRENCY_CODES: &[&str] = &[ - "USD", "GBP", "EUR", "CZK", "CNY", "JPY", "KRW", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", - "CHF", "AED", "TRY", -]; +/// One entry of the preferred-display-currency catalog. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CurrencyInfo { + pub code: &'static str, + pub symbol: &'static str, + /// Offline USD-pivot rate used until live rates are available. + pub fallback_rate: f64, +} + +const fn currency(code: &'static str, symbol: &'static str, fallback_rate: f64) -> CurrencyInfo { + CurrencyInfo { + code, + symbol, + fallback_rate, + } +} -pub const FALLBACK_RATES: &[(&str, f64)] = &[ - ("USD", 1.0), - ("GBP", 0.79), - ("EUR", 0.92), - ("CZK", 21.0), - ("CNY", 7.27), - ("JPY", 154.0), - ("KRW", 1428.90), - ("CAD", 1.38), - ("AUD", 1.55), - ("HKD", 7.80), - ("TWD", 32.30), - ("SGD", 1.34), - ("INR", 84.50), - ("CHF", 0.80), - ("AED", 3.6725), - ("TRY", 48.5), +/// Single source of truth for picker order, picker symbols, and offline rates. +/// The frontend copy (`apps/desktop-tauri/src/lib/currencyCatalog.generated.ts`) +/// is checked against this table by the catalog sync test. +pub const CURRENCIES: &[CurrencyInfo] = &[ + currency("USD", "$", 1.0), + currency("GBP", "£", 0.79), + currency("EUR", "€", 0.92), + currency("CZK", "Kč", 21.0), + currency("CNY", "¥", 7.27), + currency("JPY", "¥", 154.0), + currency("KRW", "₩", 1428.90), + currency("CAD", "$", 1.38), + currency("AUD", "$", 1.55), + currency("HKD", "$", 7.80), + currency("TWD", "NT$", 32.30), + currency("SGD", "$", 1.34), + currency("INR", "₹", 84.50), + currency("CHF", "Fr.", 0.80), + currency("AED", "د.إ", 3.6725), + currency("TRY", "₺", 48.5), + // Rates below are from open.er-api.com on 2026-09-24. + currency("NZD", "$", 1.761), + currency("SEK", "kr", 9.908), + currency("NOK", "kr", 9.480), + currency("DKK", "kr", 6.554), + currency("PLN", "zł", 3.838), + currency("BRL", "R$", 5.117), + currency("MXN", "$", 17.47), + currency("ZAR", "R", 16.36), + currency("THB", "฿", 33.37), + currency("IDR", "Rp", 17836.0), + currency("VND", "₫", 25962.0), + currency("UAH", "₴", 44.86), ]; +pub fn is_supported_currency(code: &str) -> bool { + CURRENCIES.iter().any(|currency| currency.code == code) +} + +/// Renders the catalog as the TypeScript module the settings picker and +/// offline converter import, so both sides share this table. This is only +/// needed by the sync test and the opt-in regeneration path. +#[cfg(test)] +fn render_typescript_catalog() -> String { + let mut out = String::from( + "// Generated from rust/src/currency.rs (CURRENCIES). Do not edit by hand.\n\ + // Regenerate (PowerShell): $env:UPDATE_CURRENCY_CATALOG = '1'; cargo test -p codexbar currency_catalog\n\ + export const CURRENCY_CATALOG = [\n", + ); + for currency in CURRENCIES { + let code = serde_json::to_string(currency.code).expect("currency codes serialize"); + let symbol = serde_json::to_string(currency.symbol).expect("currency symbols serialize"); + out.push_str(&format!( + " {{ code: {code}, symbol: {symbol}, fallbackRate: {} }},\n", + currency.fallback_rate + )); + } + out.push_str("] as const;\n"); + out +} + pub fn normalize_preferred_currency(value: &str) -> String { let code = value.trim().to_ascii_uppercase(); - if code == "AUTO" || SUPPORTED_CURRENCY_CODES.contains(&code.as_str()) { + if code == "AUTO" || is_supported_currency(&code) { code } else { "AUTO".to_string() @@ -37,9 +91,9 @@ pub fn normalize_preferred_currency(value: &str) -> String { } pub fn fallback_rates() -> HashMap { - FALLBACK_RATES + CURRENCIES .iter() - .map(|(code, rate)| ((*code).to_string(), *rate)) + .map(|currency| (currency.code.to_string(), currency.fallback_rate)) .collect() } @@ -52,12 +106,12 @@ pub fn parse_exchange_rates(payload: &[u8]) -> Option> { } let rates = value.get("rates")?.as_object()?; let mut parsed = HashMap::new(); - for code in SUPPORTED_CURRENCY_CODES { - let Some(rate) = rates.get(*code).and_then(serde_json::Value::as_f64) else { + for currency in CURRENCIES { + let Some(rate) = rates.get(currency.code).and_then(serde_json::Value::as_f64) else { continue; }; if rate.is_finite() && rate > 0.0 { - parsed.insert((*code).to_string(), rate); + parsed.insert(currency.code.to_string(), rate); } } if parsed @@ -82,9 +136,7 @@ pub fn convert_amount( } let source = source_code.trim().to_ascii_uppercase(); let target = target_code.trim().to_ascii_uppercase(); - if !SUPPORTED_CURRENCY_CODES.contains(&source.as_str()) - || !SUPPORTED_CURRENCY_CODES.contains(&target.as_str()) - { + if !is_supported_currency(&source) || !is_supported_currency(&target) { return None; } if source == target { @@ -184,4 +236,76 @@ mod tests { malformed.insert("TRY".into(), f64::NAN); assert_eq!(convert_amount(12.0, "USD", "TRY", &malformed), None); } + + const UPSTREAM_ADDED: &[(&str, &str, f64)] = &[ + ("NZD", "$", 1.761), + ("SEK", "kr", 9.908), + ("NOK", "kr", 9.480), + ("DKK", "kr", 6.554), + ("PLN", "zł", 3.838), + ("BRL", "R$", 5.117), + ("MXN", "$", 17.47), + ("ZAR", "R", 16.36), + ("THB", "฿", 33.37), + ("IDR", "Rp", 17836.0), + ("VND", "₫", 25962.0), + ("UAH", "₴", 44.86), + ]; + + #[test] + fn added_currencies_follow_try_in_upstream_order_with_upstream_rates() { + let codes: Vec<&str> = CURRENCIES.iter().map(|currency| currency.code).collect(); + let try_index = codes.iter().position(|code| *code == "TRY").unwrap(); + assert_eq!(codes.len(), try_index + 1 + UPSTREAM_ADDED.len()); + for (offset, (code, symbol, rate)) in UPSTREAM_ADDED.iter().enumerate() { + let entry = &CURRENCIES[try_index + 1 + offset]; + assert_eq!( + (entry.code, entry.symbol, entry.fallback_rate), + (*code, *symbol, *rate) + ); + } + } + + #[test] + fn added_currencies_convert_through_the_usd_pivot_and_normalize() { + let rates = fallback_rates(); + assert_eq!(rates.len(), CURRENCIES.len()); + for (code, _, rate) in UPSTREAM_ADDED { + assert_eq!(normalize_preferred_currency(&code.to_lowercase()), *code); + let from_usd = convert_amount(10.0, "USD", code, &rates).unwrap(); + assert!((from_usd - 10.0 * rate).abs() < 1e-9, "{code}"); + let back = convert_amount(from_usd, code, "USD", &rates).unwrap(); + assert!((back - 10.0).abs() < 1e-9, "{code}"); + } + let cross = convert_amount(10.0, "EUR", "SEK", &rates).unwrap(); + assert!((cross - (10.0 / 0.92) * 9.908).abs() < 1e-9); + } + + #[test] + fn live_rate_parsing_keeps_added_currencies() { + let rates = parse_exchange_rates( + br#"{"result":"success","base_code":"USD","rates":{"USD":1,"VND":26000,"IDR":17900,"UAH":45,"XXX":3}}"#, + ) + .unwrap(); + assert_eq!(rates.get("VND"), Some(&26000.0)); + assert_eq!(rates.get("IDR"), Some(&17900.0)); + assert_eq!(rates.get("UAH"), Some(&45.0)); + assert!(!rates.contains_key("XXX")); + } + + #[test] + fn currency_catalog_typescript_matches_the_checked_in_module() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../apps/desktop-tauri/src/lib/currencyCatalog.generated.ts"); + let expected = render_typescript_catalog(); + if std::env::var_os("UPDATE_CURRENCY_CATALOG").is_some() { + std::fs::write(&path, &expected).unwrap(); + } + let actual = std::fs::read_to_string(&path).unwrap_or_default(); + assert_eq!( + actual.replace("\r\n", "\n"), + expected, + "set UPDATE_CURRENCY_CATALOG=1, then rerun `cargo test -p codexbar currency_catalog`" + ); + } }