mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
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:
117
.github/scripts/update_currencies.py
vendored
117
.github/scripts/update_currencies.py
vendored
@@ -1,16 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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'
|
||||
# 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')
|
||||
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():
|
||||
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():
|
||||
# First, fetch ISO 4217 data for decimal places
|
||||
iso_data = fetch_iso_4217_data()
|
||||
|
||||
session = requests.Session()
|
||||
retries = Retry(
|
||||
total=3,
|
||||
@@ -46,11 +149,15 @@ def fetch_currencies():
|
||||
for country in countries:
|
||||
country_name = country.get('name', {}).get('common') or "Unknown"
|
||||
for code, info in country.get('currencies', {}).items():
|
||||
# Get decimal places using the helper function
|
||||
decimals = get_currency_decimals(code, iso_data)
|
||||
|
||||
results.append({
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', '')
|
||||
'code': code,
|
||||
'local': country_name,
|
||||
'symbol': info.get('symbol', ''),
|
||||
'name': info.get('name', ''),
|
||||
'decimals': decimals
|
||||
})
|
||||
|
||||
# sort by country name for consistency
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,6 +16,24 @@ import (
|
||||
//go:embed currencies.json
|
||||
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)
|
||||
|
||||
func CollectJSON(reader io.Reader) CollectorFunc {
|
||||
@@ -25,6 +44,11 @@ func CollectJSON(reader io.Reader) CollectorFunc {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -48,10 +72,11 @@ func CollectionCurrencies(collectors ...CollectorFunc) ([]Currency, error) {
|
||||
}
|
||||
|
||||
type Currency struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Local string `json:"local"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Local string `json:"local"`
|
||||
Symbol string `json:"symbol"`
|
||||
Decimals int `json:"decimals"`
|
||||
}
|
||||
|
||||
type CurrencyRegistry struct {
|
||||
@@ -62,7 +87,10 @@ type CurrencyRegistry struct {
|
||||
func NewCurrencyService(currencies []Currency) *CurrencyRegistry {
|
||||
registry := make(map[string]Currency, len(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{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { format, formatDistance } from "date-fns";
|
||||
/* eslint import/namespace: ['error', { allowComputed: true }] */
|
||||
import * as Locales from "date-fns/locale";
|
||||
import { fmtCurrency, fmtCurrencyAsync } from "./utils";
|
||||
|
||||
const cache = {
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -25,19 +25,126 @@ export function validDate(dt: Date | string | null | undefined): boolean {
|
||||
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 {
|
||||
if (typeof value === "string") {
|
||||
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, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
currency: safeCurrency,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
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 = {
|
||||
isUrl: boolean;
|
||||
url: string;
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface CurrenciesCurrency {
|
||||
local: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface EntAttachment {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiAccountMultiple from "~icons/mdi/account-multiple";
|
||||
import { getLocaleCode } from "~/composables/use-formatters";
|
||||
import { fmtCurrencyAsync } from "~/composables/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
@@ -67,6 +68,7 @@
|
||||
name: "United States Dollar",
|
||||
local: "en-US",
|
||||
symbol: "$",
|
||||
decimals: 2,
|
||||
});
|
||||
watch(currency, () => {
|
||||
if (group.value) {
|
||||
@@ -74,9 +76,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
const currencyExample = computed(() => {
|
||||
return fmtCurrency(1000, currency.value?.code ?? "USD", getLocaleCode());
|
||||
});
|
||||
const currencyExample = ref("$1,000.00");
|
||||
|
||||
// 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 } = await api.group.get();
|
||||
|
||||
Reference in New Issue
Block a user