@amboras-dev/money-gate
The money gate: a dev-time checker that finds hand-written money code @amboras-dev/money replaces.
Every finding names the package function to use instead.
One runtime dependency, @amboras-dev/money (its currency registry); Node >= 20.
pnpm add -D @amboras-dev/money-gate
npx money-gate baseline # once, when a repo adopts the gate
npx money-gate check --ratchet # in CI
src/checkout.ts:4:30 MG002 hand-rolled x100 / x1000 money scaling -> fromMajor / toMajorDecimal / percentOf
src/checkout.ts MG002: 2 found, baseline allows 1
money-gate: 1 count(s) above the baseline, 0 below it
Rules
| Id | Finds | Use instead |
|---|---|---|
| MG001 | 10 **, Math.pow(10,, 1e2, 1e3, 1e7, and 10_000_000 next to a rate (elsewhere it is a limit) | storageScale, fromMajor, toMajorDecimal, RATE_REF |
| MG002 (opt-in) | * 100, / 100, * 1000, / 1000 with a money operand (amount, price, total, cost, fee, subtotal, balance, minor, cents, major, stored, value), not a time one | fromMajor, toMajorDecimal, percentOf |
| MG003 (opt-in) | toFixed( on, and parseFloat( / Number( of, a money value | toMajorDecimal, readStored, parseImportAmount |
| MG004 | Intl.NumberFormat with style: 'currency' or currencyDisplay, toLocaleString with a currency | formatMoney |
| MG005 | an array, Set or object literal holding 3 or more currency codes | the registry, pspCurrencies |
| MG006 | ?? 'USD', || 'EUR', currency = 'usd' defaults, SQL COALESCE(currency_code, 'usd') | firstCurrency |
| MG007 | SQL: decimal_digits, THEN 1000 ELSE 100, POWER(10, ...), 10 ^ ..., jsonb_build_object('value', arithmetic on rate_numerator / rate_denominator, ROUND(..., 2) on money | @amboras-dev/money/sql builders |
| MG008 | imports of retired helpers (table below), a local formatMoney / formatCurrency, big-number toNumber / toNum, the legacy currency shim outside legacyShimAllow | the function each finding names |
| MG009 | minor units, in cents, divide by 100, (cents) in strings, outside decorator calls (not when the text forbids the unit: "Never cents or minor units") | MONEY_UNIT_SENTENCE, STORAGE_UNIT_SENTENCE |
| MG000 | an exemption without a rule id or a reason | a complete exemption |
Code rules read code only: comments, string literals and regex literals are blanked first by a small tokenizer.
JSX closing tags (</p>, </>, <a href={x} />) are not read as regex literals.
String rules read string and template literals; SQL rules read string literals and .sql files.
MG002 and MG003 are off by default: on their own they flag percentages, counts named total and DOM .value reads too often.
Turn them on per repo with "rules": { "MG002": "on", "MG003": "on" } in the config.
Currency codes (MG005, MG006): an upper-case code always counts.
A lower-case code, or a code that is also a common word or abbreviation (ALL, TOP, TRY, PHP, CAD, AMD, BTN and the rest of WORD_COLLISIONS in src/currencies.ts), counts only in a currency context.
In code that means the statement before it names a currency (currency, currencies, ZERO_DECIMAL...): store.currency ?? 'usd' and const zeroDecimalCurrencies = ['jpy', ...] count, filter ?? 'all' and ['all', 'top', 'try'] do not.
A statement that is the first in a block also reads the block's header, so function normalizeCurrency(code) { return code ?? 'usd' } counts.
A fallback inside a template literal (storefront code generated as text) is read in the template's own text.
In SQL COALESCE, the first arguments must mention a currency or a code column.
MG009 skips strings inside a decorator call's arguments (@ApiProperty({ description: 'Amount in cents' }), any @Name(...)): those document a wire field for Swagger/OpenAPI, not wording for the model.
Strings in tool schemas (z.number().describe('...')) and prompts are still checked.
Retired helpers (MG008)
| Helper | Use instead |
|---|---|
divisorFor | storageScale(code) |
storageDivisorForDigits | storageScale(code) (keyed by the code, not the digits); in SQL the @amboras-dev/money/sql builders |
fractionDigitsFor | storageDigits(code) for how it is stored, displayDigits(code) for what to show |
isoFractionDigits | pspWireDigits(psp, code) from @amboras-dev/money/wire |
toMinorUnits | fromMajor(major, code) |
toMajorUnits | toMajorDecimal(roundToUnit(readStoredDecimal(raw), code)): Medusa totals can be fractional (sevDesk, GA4, invoices) and readStored throws on fractions |
readAmountNumber, big-number toNumber / toNum | readStoredDecimal(raw) (keeps fractions), or readStored(raw, code) for a whole stored amount |
a local formatMoney / formatCurrency | formatMoney(m) from @amboras-dev/money |
| the legacy currency shim | currencyCode(value) or firstCurrency(...) |
toStorageAmount (@amboras-dev/capability-kit) | fromMajor(major, code).amount |
fromStorageAmount (@amboras-dev/capability-kit) | toMajorDecimal(readStored(stored, code)), or toMajorNumber for a float |
storageScale (@amboras-dev/capability-kit) | storageScale(code) from @amboras-dev/money |
fractionDigits (@amboras-dev/capability-kit) | storageDigits(code) |
moneyView (@amboras-dev/capability-kit) | moneyView(readStored(stored, code)) from @amboras-dev/money/ai |
The capability-kit helpers are flagged only when imported from @amboras-dev/capability-kit; the package's own storageScale and moneyView are the replacements.
Exemptions
// money-gate-exempt: MG001 the one legacy scale kept until the wire migration
const LEGACY_SCALE = 10 ** 2;
The marker covers its own line and the two lines below it, for the named rule only.
It counts only inside a comment (//, /* */, or -- in SQL); the same text inside a string literal is data and exempts nothing.
A rule id and a reason are mandatory; without them the marker is an MG000 violation and exempts nothing.
The old money-scale-exempt: <reason> marker counts as an MG001 exemption during migration.
Baseline
money-gate.baseline.json holds the violations a repo had when it adopted the gate, counted per file and rule.
check fails on any count above the baseline (a new violation); check --ratchet also fails on a count below it until the baseline is lowered, so the baseline only shrinks.
baseline writes the current counts and refuses to raise any count unless --force (which shows up in review).
The baseline (version 2) also stores a fingerprint per file and rule: the source text of each violating line, trimmed, sorted and hashed (SHA-256, first 16 hex digits). It does not change when lines move within a file or the file is renamed; it changes when any violating line is added, removed or edited.
A pure rename or move keeps its counts.
A file the baseline does not know inherits the entry of a baseline file that is no longer scanned only when, for every rule, both the count and the fingerprint are equal.
Then check passes and prints the rename, and baseline rewrites the entry at the new path without --force.
check --ratchet fails until the baseline is refreshed, so a stale path cannot be reused later.
Deleting a baselined file and adding an unrelated one with the same counts is not a rename (the lines differ), so its violations fail check as new ones.
A rename that also adds, removes or edits a violating line is not carried: fix the violation, or baseline --force.
A version 1 baseline (counts only) is still read, but nothing is carried from it; money-gate baseline upgrades it.
Config
money-gate.config.json at the repo root (all keys optional):
{
"include": ["src/**/*.ts", "scripts/**/*.ts"],
"sql": ["src/migrations/**/*.sql"],
"exclude": ["src/generated/**"],
"legacyShimAllow": ["src/modules/payments/stripe/**"],
"baseline": "money-gate.baseline.json",
"rules": { "MG002": "on", "MG003": "on" }
}
include defaults to src/**/*.{ts,tsx,js,jsx,mjs,cjs}.
A config that selects no file at all is a config error (exit 2) naming the globs and the root, never an "ok": a repo whose code is not under src/ must set include.
rules switches MG001-MG009 "on" or "off" from their defaults (MG002 and MG003 start off).
Never scanned: node_modules, dist, *.generated.ts and test files (*.spec.*, *.test.*), which may build fixtures.
Globs support **, *, ? and {a,b}.
Negation (!pattern) is not supported and is a config error rather than a silent no-op: list the pattern under exclude.
Adoption
- Add a
money-gate.config.jsonwhoseinclude(andsql) select the repo's code; the gate refuses to run on an empty selection. npx money-gate baselineonce, commit the baseline, and runnpx money-gate check --ratchetin CI.- Retire the repo's own money guards as their rules land here.
Medusa:
src/common/utils/money-conversion-sites.spec.tsrequires theCASE WHEN ... decimal_digits ... THEN 1000 ELSE 100 ENDstorage divisor inanalytics.service.ts, which MG007 bans. Its adoption PR rewrites that spec to assert the@amboras-dev/money/sqlbuilders instead, and drops its power-of-ten checks, which MG001 (code) and MG007 (POWER(10, ...),10 ^ ...in SQL) now cover.
Commands
money-gate check [--ratchet] [--json] [--root <dir>] [--config <file>]: exit 0 clean, 1 violations, 2 usage or config error.money-gate baseline [--force] [--root <dir>] [--config <file>].- The library exports the same pieces (
run,scanSource,scanRepo,RULES,loadConfig, baseline helpers).