109 lines
3.2 KiB
TypeScript
109 lines
3.2 KiB
TypeScript
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;
|
|
};
|