export type EntryType = "expense" | "income"; export const currencies = { CNY: { name: "人民币", symbol: "¥", minorUnits: 2 }, USD: { name: "美元", symbol: "$", minorUnits: 2 }, EUR: { name: "欧元", symbol: "€", minorUnits: 2 }, JPY: { name: "日元", symbol: "¥", minorUnits: 0 }, THB: { name: "泰铢", symbol: "฿", minorUnits: 2 }, HKD: { name: "港币", symbol: "HK$", minorUnits: 2 }, } as const; export type CurrencyCode = keyof typeof currencies; export type ExchangeRateSource = "manual" | "system"; export type ConversionStatus = "exact" | "fallback" | "pending"; export function currencyMinorUnits(currency: CurrencyCode) { return currencies[currency].minorUnits; } export function formatCurrencyAmount(amount: number, currency: CurrencyCode, includeSymbol = true) { const minorUnits = currencyMinorUnits(currency); return new Intl.NumberFormat("zh-CN", { style: includeSymbol ? "currency" : "decimal", currency: includeSymbol ? currency : undefined, currencyDisplay: "narrowSymbol", minimumFractionDigits: minorUnits, maximumFractionDigits: minorUnits, }).format(amount / 10 ** minorUnits); } function decimalParts(value: string) { if (!/^\d+(?:\.\d+)?$/.test(value)) throw new Error("Invalid decimal rate"); const [integer, fraction = ""] = value.split("."); return { scaled: BigInt(`${integer}${fraction}`), scale: 10n ** BigInt(fraction.length), }; } export function calculateCnyAmount(amount: number, currency: CurrencyCode, cnyPerUnit: string) { if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("Invalid amount"); const { scaled, scale } = decimalParts(cnyPerUnit); const divisor = (10n ** BigInt(currencyMinorUnits(currency))) * scale; const numerator = BigInt(amount) * scaled * 100n; const result = (numerator + divisor / 2n) / divisor; const numeric = Number(result); if (!Number.isSafeInteger(numeric) || numeric <= 0 || numeric > 2_147_483_647) { throw new Error("Converted amount is out of range"); } return numeric; } export const ledgerThemeAccents = { jade: "#087f72", ocean: "#3478e5", coral: "#c94f45", berry: "#a84378", meadow: "#2f8060", graphite: "#526a78", sunrise: "#b45f4a", lagoon: "#177f8f", iris: "#7061b5", sky: "#327ba0", olive: "#64784a", midnight: "#47577f", } as const; export type LedgerThemeId = keyof typeof ledgerThemeAccents; export type LedgerEntry = { id: string; ownerId: string; ledgerIds: string[]; type: EntryType; amount: number; currency: CurrencyCode; baseCurrency: CurrencyCode; baseAmount: number | null; exchangeRate: string | null; exchangeRateDate: string; exchangeRateEffectiveDate: string | null; exchangeRateSource: ExchangeRateSource; conversionStatus: ConversionStatus; categoryId: string; note: string; occurredAt: string; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string; deletedAt: string | null; version: number; }; export type SyncOperationAction = "create" | "update" | "delete"; export type SyncOperation = { id: string; userId?: string; ledgerIds: string[]; entity: "entry"; entityId: string; action: SyncOperationAction; payload: LedgerEntry; createdAt: string; syncedAt: string | null; };