Fea: add decimal support to currency system with ISO 4217 data integration (#976)

* feat: add decimal support to currency system with ISO 4217 data integration

* Harden currency formatting: add decimal bounds, input validation, and robust error handling

* Fixed issues raised by coderrabitai

* Fixed linting issue
This commit is contained in:
Choong Jun Jin
2025-09-14 00:51:54 +09:00
committed by GitHub
parent 3183b38114
commit 8f8dbf4a3a
7 changed files with 816 additions and 283 deletions

View File

@@ -1,16 +1,33 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import csv
import io
import json import json
import logging import logging
import os
import sys import sys
from pathlib import Path from pathlib import Path
import requests import requests
from requests.adapters import HTTPAdapter, Retry from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies' API_URL = 'https://restcountries.com/v3.1/all?fields=name,common,currencies'
# Default to a pinned commit for supply-chain security
DEFAULT_ISO_4217_URL = 'https://raw.githubusercontent.com/datasets/currency-codes/052b3088938ba32028a14e75040c286c5e142145/data/codes-all.csv'
ISO_4217_URL = os.environ.get('ISO_4217_URL', DEFAULT_ISO_4217_URL)
SAVE_PATH = Path('backend/internal/core/currencies/currencies.json') SAVE_PATH = Path('backend/internal/core/currencies/currencies.json')
TIMEOUT = 10 # seconds TIMEOUT = 10 # seconds
# Known currency decimal overrides
CURRENCY_DECIMAL_OVERRIDES = {
"BTC": 8, # Bitcoin uses 8 decimal places
"JPY": 0, # Japanese Yen has no decimal places
"BHD": 3, # Bahraini Dinar uses 3 decimal places
}
DEFAULT_DECIMALS = 2
MIN_DECIMALS = 0
MAX_DECIMALS = 6
def setup_logging(): def setup_logging():
logging.basicConfig( logging.basicConfig(
@@ -19,7 +36,93 @@ def setup_logging():
) )
def get_currency_decimals(code, iso_data):
"""
Get the decimal places for a currency code.
Checks overrides first, then ISO data, then uses default.
Clamps result to safe range [MIN_DECIMALS, MAX_DECIMALS].
"""
# Normalize the input code
normalized_code = (code or "").strip().upper()
# First check overrides
if normalized_code in CURRENCY_DECIMAL_OVERRIDES:
decimals = CURRENCY_DECIMAL_OVERRIDES[normalized_code]
# Then check ISO data
elif normalized_code in iso_data:
decimals = iso_data[normalized_code]
# Finally use default
else:
decimals = DEFAULT_DECIMALS
# Ensure it's an integer and clamp to safe range
try:
decimals = int(decimals)
except (ValueError, TypeError):
decimals = DEFAULT_DECIMALS
return max(MIN_DECIMALS, min(MAX_DECIMALS, decimals))
def fetch_iso_4217_data():
"""
Fetch ISO 4217 currency data to get minor units (decimal places).
Returns a dict mapping currency code to minor units.
"""
# Log the resolved URL for transparency
logging.info("Fetching ISO 4217 data from: %s", ISO_4217_URL)
if not ISO_4217_URL.lower().startswith("https://"):
logging.error("Refusing non-HTTPS ISO_4217_URL: %s", ISO_4217_URL)
return {}
session = requests.Session()
retries = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=frozenset(['GET'])
)
session.mount('https://', HTTPAdapter(max_retries=retries))
try:
# Add Accept header for CSV content
headers = {'Accept': 'text/csv'}
resp = session.get(ISO_4217_URL, timeout=TIMEOUT, headers=headers)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
logging.error("Failed to fetch ISO 4217 data: %s", e)
return {}
# Parse CSV data
iso_data = {}
try:
# Decode with utf-8-sig to strip BOM if present
csv_content = resp.content.decode('utf-8-sig')
csv_reader = csv.DictReader(io.StringIO(csv_content))
for row in csv_reader:
code = row.get('AlphabeticCode', '').strip()
minor_unit = row.get('MinorUnit', '').strip()
if code and minor_unit != 'N.A.':
try:
# Convert minor unit to int (decimal places)
iso_data[code] = int(minor_unit) if minor_unit.isdigit() else 2
except (ValueError, TypeError):
iso_data[code] = 2 # Default to 2 if parsing fails
logging.info("Successfully loaded decimal data for %d currencies from ISO 4217", len(iso_data))
return iso_data
except Exception as e:
logging.error("Failed to parse ISO 4217 CSV data: %s", e)
return {}
def fetch_currencies(): def fetch_currencies():
# First, fetch ISO 4217 data for decimal places
iso_data = fetch_iso_4217_data()
session = requests.Session() session = requests.Session()
retries = Retry( retries = Retry(
total=3, total=3,
@@ -46,11 +149,15 @@ def fetch_currencies():
for country in countries: for country in countries:
country_name = country.get('name', {}).get('common') or "Unknown" country_name = country.get('name', {}).get('common') or "Unknown"
for code, info in country.get('currencies', {}).items(): for code, info in country.get('currencies', {}).items():
# Get decimal places using the helper function
decimals = get_currency_decimals(code, iso_data)
results.append({ results.append({
'code': code, 'code': code,
'local': country_name, 'local': country_name,
'symbol': info.get('symbol', ''), 'symbol': info.get('symbol', ''),
'name': info.get('name', '') 'name': info.get('name', ''),
'decimals': decimals
}) })
# sort by country name for consistency # sort by country name for consistency

View File

@@ -7,6 +7,7 @@ import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"io" "io"
"log"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -15,6 +16,24 @@ import (
//go:embed currencies.json //go:embed currencies.json
var defaults []byte var defaults []byte
const (
MinDecimals = 0
MaxDecimals = 18
)
// clampDecimals ensures the decimals value is within a safe range [0, 18]
func clampDecimals(decimals int, code string) int {
original := decimals
if decimals < MinDecimals {
decimals = MinDecimals
log.Printf("WARNING: Currency %s had negative decimals (%d), normalized to %d", code, original, decimals)
} else if decimals > MaxDecimals {
decimals = MaxDecimals
log.Printf("WARNING: Currency %s had excessive decimals (%d), normalized to %d", code, original, decimals)
}
return decimals
}
type CollectorFunc func() ([]Currency, error) type CollectorFunc func() ([]Currency, error)
func CollectJSON(reader io.Reader) CollectorFunc { func CollectJSON(reader io.Reader) CollectorFunc {
@@ -25,6 +44,11 @@ func CollectJSON(reader io.Reader) CollectorFunc {
return nil, err return nil, err
} }
// Clamp decimals during collection to ensure early normalization
for i := range currencies {
currencies[i].Decimals = clampDecimals(currencies[i].Decimals, currencies[i].Code)
}
return currencies, nil return currencies, nil
} }
} }
@@ -52,6 +76,7 @@ type Currency struct {
Code string `json:"code"` Code string `json:"code"`
Local string `json:"local"` Local string `json:"local"`
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
} }
type CurrencyRegistry struct { type CurrencyRegistry struct {
@@ -62,7 +87,10 @@ type CurrencyRegistry struct {
func NewCurrencyService(currencies []Currency) *CurrencyRegistry { func NewCurrencyService(currencies []Currency) *CurrencyRegistry {
registry := make(map[string]Currency, len(currencies)) registry := make(map[string]Currency, len(currencies))
for i := range currencies { for i := range currencies {
registry[currencies[i].Code] = currencies[i] // Clamp decimals to safe range before adding to registry
currency := currencies[i]
currency.Decimals = clampDecimals(currency.Decimals, currency.Code)
registry[currency.Code] = currency
} }
return &CurrencyRegistry{ return &CurrencyRegistry{

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
/* eslint import/namespace: ['error', { allowComputed: true }] */ /* eslint import/namespace: ['error', { allowComputed: true }] */
import * as Locales from "date-fns/locale"; import * as Locales from "date-fns/locale";
import { fmtCurrency, fmtCurrencyAsync } from "./utils";
const cache = { const cache = {
currency: "", currency: "",
@@ -21,6 +22,16 @@ export async function useFormatCurrency() {
} }
} }
// Pre-load currency decimals for better formatting (optional, non-blocking)
if (cache.currency && cache.currency.trim() !== "") {
try {
await fmtCurrencyAsync(0, cache.currency, getLocaleCode());
} catch (error) {
// Silently swallow preload errors - formatter will still work, just without pre-cached decimals
console.debug("Currency preload failed (non-fatal):", error);
}
}
return (value: number | string) => fmtCurrency(value, cache.currency, getLocaleCode()); return (value: number | string) => fmtCurrency(value, cache.currency, getLocaleCode());
} }

View File

@@ -25,19 +25,126 @@ export function validDate(dt: Date | string | null | undefined): boolean {
return true; return true;
} }
// Currency cache to store decimal places information
export const currencyDecimalsCache: Record<string, number> = {};
// Promise to track in-flight loading to coalesce concurrent calls
let currencyLoadingPromise: Promise<void> | null = null;
// Safe range for server-provided decimals
const SAFE_MIN_DECIMALS = 0;
const SAFE_MAX_DECIMALS = 4;
// Helper function to clamp decimal places to safe range
function clampDecimals(currency: string, decimals: number): number {
const truncated = Math.trunc(decimals);
return Math.max(SAFE_MIN_DECIMALS, Math.min(SAFE_MAX_DECIMALS, truncated));
}
// Type guard to validate currency response shape with strict validation
function isValidCurrencyItem(item: any): item is { code: string; decimals: number } {
if (
typeof item !== "object" ||
item === null ||
typeof item.code !== "string" ||
item.code.trim() === "" ||
typeof item.decimals !== "number" ||
!Number.isFinite(item.decimals)
) {
return false;
}
// Truncate decimals to integer and check range
const truncatedDecimals = Math.trunc(item.decimals);
return truncatedDecimals >= SAFE_MIN_DECIMALS && truncatedDecimals <= SAFE_MAX_DECIMALS;
}
// Function to load currency decimals from API
function loadCurrencyDecimals(): Promise<void> {
// Check environment variable to see if remote decimals are disabled
if (process.env.USE_REMOTE_DECIMALS === "false") {
return Promise.resolve();
}
// Return early if already loaded
if (Object.keys(currencyDecimalsCache).length > 0) {
return Promise.resolve();
}
// Coalesce concurrent calls - return existing promise if loading
if (currencyLoadingPromise) {
return currencyLoadingPromise;
}
// Create new loading promise
currencyLoadingPromise = (async () => {
try {
const api = useUserApi();
const { data, error } = await api.group.currencies();
if (!error && data) {
// Validate that data is an array
if (!Array.isArray(data)) {
// Log generic message without server details
console.warn("Currency API returned invalid data format");
return;
}
// Process and validate each currency item
for (const currency of data) {
// Strict validation: only process items that pass all checks
if (!isValidCurrencyItem(currency)) {
// Skip invalid items without caching - no clamping for out-of-range values
continue;
}
// Only cache strictly validated items with truncated and clamped decimals
const code = currency.code.trim().toUpperCase();
const truncatedDecimals = Math.trunc(currency.decimals);
const clampedDecimals = Math.max(SAFE_MIN_DECIMALS, Math.min(SAFE_MAX_DECIMALS, truncatedDecimals));
currencyDecimalsCache[code] = clampedDecimals;
}
} else if (error) {
// Generic error logging without exposing server error details
console.warn("Currency API request failed, using default formatting");
}
} catch (e) {
// Generic error without sensitive details - no raw error logging
console.warn("Currency data loading failed, using default formatting");
} finally {
// Clear loading promise when done (success or failure)
currencyLoadingPromise = null;
}
})();
return currencyLoadingPromise;
}
export function fmtCurrency(value: number | string, currency = "USD", locale = "en-Us"): string { export function fmtCurrency(value: number | string, currency = "USD", locale = "en-Us"): string {
if (typeof value === "string") { if (typeof value === "string") {
value = parseFloat(value); value = parseFloat(value);
} }
// Normalize and validate currency code
const normalizedCurrency = String(currency).toUpperCase();
const safeCurrency = /^[A-Z]{3}$/.test(normalizedCurrency) ? normalizedCurrency : "USD";
// Derive fraction digits using the same clamp helper
const fractionDigits = clampDecimals(safeCurrency, currencyDecimalsCache[safeCurrency] ?? 2);
const formatter = new Intl.NumberFormat(locale, { const formatter = new Intl.NumberFormat(locale, {
style: "currency", style: "currency",
currency, currency: safeCurrency,
minimumFractionDigits: 2, minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}); });
return formatter.format(value); return formatter.format(value);
} }
export async function fmtCurrencyAsync(value: number | string, currency = "USD", locale = "en-Us"): Promise<string> {
await loadCurrencyDecimals();
return fmtCurrency(value, currency, locale);
}
export type MaybeUrlResult = { export type MaybeUrlResult = {
isUrl: boolean; isUrl: boolean;
url: string; url: string;

View File

@@ -57,6 +57,7 @@ export interface CurrenciesCurrency {
local: string; local: string;
name: string; name: string;
symbol: string; symbol: string;
decimals: number;
} }
export interface EntAttachment { export interface EntAttachment {

View File

@@ -11,6 +11,7 @@
import MdiPencil from "~icons/mdi/pencil"; import MdiPencil from "~icons/mdi/pencil";
import MdiAccountMultiple from "~icons/mdi/account-multiple"; import MdiAccountMultiple from "~icons/mdi/account-multiple";
import { getLocaleCode } from "~/composables/use-formatters"; import { getLocaleCode } from "~/composables/use-formatters";
import { fmtCurrencyAsync } from "~/composables/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useDialog } from "@/components/ui/dialog-provider"; import { useDialog } from "@/components/ui/dialog-provider";
@@ -67,6 +68,7 @@
name: "United States Dollar", name: "United States Dollar",
local: "en-US", local: "en-US",
symbol: "$", symbol: "$",
decimals: 2,
}); });
watch(currency, () => { watch(currency, () => {
if (group.value) { if (group.value) {
@@ -74,9 +76,18 @@
} }
}); });
const currencyExample = computed(() => { const currencyExample = ref("$1,000.00");
return fmtCurrency(1000, currency.value?.code ?? "USD", getLocaleCode());
}); // Update currency example when currency changes
watch(
currency,
async () => {
if (currency.value) {
currencyExample.value = await fmtCurrencyAsync(1000, currency.value.code, getLocaleCode());
}
},
{ immediate: true }
);
const { data: group } = useAsyncData(async () => { const { data: group } = useAsyncData(async () => {
const { data } = await api.group.get(); const { data } = await api.group.get();