cents/packages/domain/src/index.ts

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;
};