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:
111
.github/scripts/update_currencies.py
vendored
111
.github/scripts/update_currencies.py
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user