以整数最小单位表示金额:ISO 货币、安全解析、舍入、无损分摊、.99 定价和格式化
代码4 个文件上下文约 423 个 token扫描通过
安装
$
genpm add @core/money你将获得
- 源代码位于 src/lib/money/,共 4 个文件。 (9.6 kB)
- AI 规则位于 src/lib/money/AGENTS.md,另附 IDE 规则文件。
- 自动为你解析 @core/contracts。
README
此包没有 README。
约 423 个 token→ src/lib/money/AGENTS.md→ .cursor/rules/genpm-core-money.mdc
这正是你的 AI 在 src/lib/money 中工作时读取的内容。不会向其上下文添加其他任何内容。
@core/money — rules for AI agents
Purpose
Money as { amount, currency } with amount an integer of minor units (cents) and an ISO 4217 code, so commerce
modules never use floats. Parsing from decimal strings, safe arithmetic that refuses mixed currencies, rounding,
percentages, lossless allocation (splitting a discount across lines), charm prices (.99, never below the input; …9 in zero-decimal currencies), formatting with Intl
and conversion with a rates table. Pure functions: no tables, no network.
Map
index.ts— public API:money,parseMoney,add,sub,sum,multiply,percent,allocate,charmPrice,format,toDecimalString,convert,currencyDecimals.money.ts— implementation. TheMoneytype comes from @core/contracts.
Integration
- Nothing to configure. Store money in two columns:
amount integer(minor units) andcurrency text. - User input:
parseMoney('19,99', 'EUR')(neverNumber(input) * 100). - Display:
format(price, locale); APIs/JSON-LD:toDecimalString(price). - Split amounts:
allocate(discount, lines.map((l) => l.subtotal.amount)). - Verify:
add(money(1, 'EUR'), money(1, 'USD'))throws.
Conventions
- All arithmetic through these helpers; JPY has 0 decimals and KWD 3, so never assume 100.
- Percentages round half-up by default; pass
'half-even'where your accounting requires it. - Exchange rates are inputs (store the rate used with each order); this module does not fetch them.
Don't
- Don't use floats for prices, totals or taxes, and don't add amounts of different currencies.
- Don't round intermediate results before the final step unless the business rule says so.
# @core/money — rules for AI agents
## Purpose
Money as `{ amount, currency }` with `amount` an integer of minor units (cents) and an ISO 4217 code, so commerce
modules never use floats. Parsing from decimal strings, safe arithmetic that refuses mixed currencies, rounding,
percentages, lossless allocation (splitting a discount across lines), charm prices (`.99`, never below the input; `…9` in zero-decimal currencies), formatting with `Intl`
and conversion with a rates table. Pure functions: no tables, no network.
## Map
- `index.ts` — public API: `money`, `parseMoney`, `add`, `sub`, `sum`, `multiply`, `percent`, `allocate`, `charmPrice`, `format`, `toDecimalString`, `convert`, `currencyDecimals`.
- `money.ts` — implementation. The `Money` type comes from @core/contracts.
## Integration
1. Nothing to configure. Store money in two columns: `amount integer` (minor units) and `currency text`.
2. User input: `parseMoney('19,99', 'EUR')` (never `Number(input) * 100`).
3. Display: `format(price, locale)`; APIs/JSON-LD: `toDecimalString(price)`.
4. Split amounts: `allocate(discount, lines.map((l) => l.subtotal.amount))`.
5. Verify: `add(money(1, 'EUR'), money(1, 'USD'))` throws.
## Conventions
- All arithmetic through these helpers; JPY has 0 decimals and KWD 3, so never assume 100.
- Percentages round half-up by default; pass `'half-even'` where your accounting requires it.
- Exchange rates are inputs (store the rate used with each order); this module does not fetch them.
## Don't
- Don't use floats for prices, totals or taxes, and don't add amounts of different currencies.
- Don't round intermediate results before the final step unless the business rule says so.
应用 .genpmignore 后将被注入的确切目录树。固定于
// Dinero en enteros (unidades menores) con moneda ISO 4217. Sin decimales flotantes en ningún cálculo.
import type { Money } from '../contracts/index.ts';
export type { Money };
export class MoneyError extends Error {
constructor(message: string) {
super(message);
this.name = 'MoneyError';
}
}
const decimalsCache = new Map<string, number>();
/** Decimales de la moneda según Intl (EUR 2, JPY 0, KWD 3). */
export function currencyDecimals(currency: string): number {
let d = decimalsCache.get(currency);
if (d === undefined) {
if (!/^[A-Z]{3}$/.test(currency)) throw new MoneyError(`invalid currency: ${currency}`);
d = new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
decimalsCache.set(currency, d);
}
return d;
}
export function money(amount: number, currency: string): Money {
if (!Number.isSafeInteger(amount)) throw new MoneyError(`amount must be an integer of minor units: ${amount}`);
currencyDecimals(currency);
return { amount, currency };
}
export const zero = (currency: string) => money(0, currency);
/** Desde un importe decimal en texto (`"19.99"`, `"19,99"`) sin pasar por float. */
export function parseMoney(value: string, currency: string): Money {
const d = currencyDecimals(currency);
const m = /^\s*(-)?(\d+)(?:[.,](\d+))?\s*$/.exec(value);
if (!m) throw new MoneyError(`invalid amount: ${value}`);
const frac = (m[3] ?? '').padEnd(d, '0');
if (frac.length > d && /[1-9]/.test(frac.slice(d))) throw new MoneyError(`too many decimals for ${currency}: ${value}`);
const amount = Number(m[2]) * 10 ** d + Number(frac.slice(0, d) || '0');
return money(m[1] ? -amount : amount, currency);
}
function same(a: Money, b: Money): void {
if (a.currency !== b.currency) throw new MoneyError(`currency mismatch: ${a.currency} vs ${b.currency}`);
}
export const add = (a: Money, ...rest: Money[]): Money => rest.reduce((acc, b) => (same(acc, b), money(acc.amount + b.amount, acc.currency)), a);
export const sub = (a: Money, b: Money): Money => (same(a, b), money(a.amount - b.amount, a.currency));
export const sum = (items: Money[], currency: string): Money => items.reduce((acc, m) => add(acc, m), zero(currency));
export const isZero = (m: Money) => m.amount === 0;
export const isNegative = (m: Money) => m.amount < 0;
export const compare = (a: Money, b: Money) => (same(a, b), Math.sign(a.amount - b.amount));
export const max = (a: Money, b: Money) => (compare(a, b) >= 0 ? a : b);
export const min = (a: Money, b: Money) => (compare(a, b) <= 0 ? a : b);
export const equals = (a: Money, b: Money) => a.currency === b.currency && a.amount === b.amount;
/** Redondeo bancario opcional; por defecto "half away from zero" (el habitual en precios). */
function round(x: number, mode: 'half-up' | 'half-even'): number {
const r = Math.round(Math.abs(x) * 1e6) / 1e6; // limpia ruido binario (19.999999999 → 20)
const floor = Math.floor(r);
const diff = r - floor;
let out = diff > 0.5 ? floor + 1 : diff < 0.5 ? floor : mode === 'half-even' ? (floor % 2 === 0 ? floor : floor + 1) : floor + 1;
if (x < 0) out = -out;
return out === 0 ? 0 : out;
}
/** Multiplica por un factor (cantidades, tipos de IVA) y redondea a la unidad menor. */
export function multiply(m: Money, factor: number, mode: 'half-up' | 'half-even' = 'half-up'): Money {
return money(round(m.amount * factor, mode), m.currency);
}
/** Porcentaje (`percent(m, 21)` = 21 %). */
export const percent = (m: Money, pct: number, mode?: 'half-up' | 'half-even') => multiply(m, pct / 100, mode);
/**
* Reparte `m` en partes proporcionales a `ratios` sin perder ni crear céntimos (los restos van a las partes con mayor
* resto). Útil para repartir un descuento entre líneas.
*/
export function allocate(m: Money, ratios: number[]): Money[] {
if (!ratios.length || ratios.some((r) => r < 0 || !Number.isFinite(r))) throw new MoneyError('ratios must be non-negative numbers');
const total = ratios.reduce((a, b) => a + b, 0);
if (total === 0) return ratios.map((_, i) => money(i === 0 ? m.amount : 0, m.currency));
const sign = m.amount < 0 ? -1 : 1;
const abs = Math.abs(m.amount);
const exact = ratios.map((r) => (abs * r) / total);
const parts = exact.map(Math.floor);
let rest = abs - parts.reduce((a, b) => a + b, 0);
const order = exact.map((e, i) => ({ i, frac: e - Math.floor(e) })).sort((a, b) => b.frac - a.frac || a.i - b.i);
for (const { i } of order) {
if (rest <= 0) break;
parts[i]!++;
rest--;
}
return parts.map((p) => money(sign * p, m.currency));
}
/**
* Precio "psicológico": el menor importe con la terminación pedida (`.99`, `.95`, `.00`) que no baja del original.
* En monedas sin decimales (JPY) la terminación es en la decena: `…9` (o `…0` con `.00`), también hacia arriba.
*/
export function charmPrice(m: Money, ending: '.99' | '.95' | '.90' | '.00' = '.99'): Money {
const d = currencyDecimals(m.currency);
if (d === 0) {
const end = ending === '.00' ? 0 : 9;
const candidate = Math.floor(m.amount / 10) * 10 + end;
return money(candidate >= m.amount ? candidate : candidate + 10, m.currency);
}
const unit = 10 ** d;
const end = Math.round(Number(ending) * unit);
const whole = Math.floor(m.amount / unit);
const candidate = whole * unit + end;
return money(candidate >= m.amount ? candidate : (whole + 1) * unit + end, m.currency);
}
/** Importe decimal como texto (`"19.99"`), para APIs y JSON-LD. */
export function toDecimalString(m: Money): string {
const d = currencyDecimals(m.currency);
const abs = Math.abs(m.amount);
const s = d ? `${Math.floor(abs / 10 ** d)}.${String(abs % 10 ** d).padStart(d, '0')}` : String(abs);
return m.amount < 0 ? `-${s}` : s;
}
/** Formato local: `format(money(1999,'EUR'), 'es-ES')` → `19,99 €`. */
export function format(m: Money, locale = 'en', opts: Intl.NumberFormatOptions = {}): string {
return new Intl.NumberFormat(locale, { style: 'currency', currency: m.currency, ...opts }).format(Number(toDecimalString(m)));
}
/** Conversión con una tabla de tipos `{ USD: 1.08 }` relativa a la moneda base de la tabla. */
export function convert(m: Money, to: string, rates: { base: string; rates: Record<string, number> }): Money {
if (m.currency === to) return m;
const rate = (c: string) => (c === rates.base ? 1 : rates.rates[c]);
const from = rate(m.currency);
const target = rate(to);
if (!from || !target) throw new MoneyError(`no rate for ${m.currency} → ${to}`);
const major = m.amount / 10 ** currencyDecimals(m.currency);
return money(round((major / from) * target * 10 ** currencyDecimals(to), 'half-up'), to);
}
此包未声明 MCP 服务器。
| 版本 | 提交 | 发布时间 | 扫描 |
|---|---|---|---|
| 1.0.1 | e980a84 | 3小时前 | 扫描通过 |
- npm
- 无
- 建议
- GenPM 会给出 npm 命令建议,只有你同意时才会运行。
- 扫描
- 扫描通过 · 0 个问题
- 提交
- v1.0.1 → e980a84e69bda3afce0d0d5e1637a985e11a8dd0 · 获取后已校验
- 脚本
- 无。GenPM 从不运行包中的代码。
- 许可证
- MIT
- 质量
- 100/100
- 可识别的许可证已满足
- AGENTS.md 说明了用途已满足
- AGENTS.md 包含集成步骤已满足
- AGENTS.md 列出约定或禁止事项已满足
- 包含测试已满足
- 通过安全扫描已满足
- 最近 6 个月内发布已满足
- 已验证的发布者已满足
- 摘要和关键词已满足
- 举报
- 发现问题了吗?