diff --git a/package-lock.json b/package-lock.json index efb408b..359f1e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.50.1", "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.3", @@ -6030,6 +6031,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -13327,6 +13338,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", diff --git a/package.json b/package.json index 3b98bb6..cc35e80 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev:ui": "vite", "dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"", "build": "vite build", - "check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check", + "check:server": "find server.js db middleware routes services utils workers \\( -name '*.js' -o -name '*.mts' -o -name '*.ts' \\) -print0 | xargs -0 -n1 node --check", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json", "lint": "eslint client", "typecheck": "tsc --noEmit -p tsconfig.json", "check": "npm run check:server && npm run build", @@ -20,7 +21,7 @@ "test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots", "test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe", "smoke:prod": "bash scripts/prod-smoke.sh", - "ci": "npm run lint && npm run typecheck && npm run check:server && npm run test:all && npm run build", + "ci": "npm run lint && npm run typecheck && npm run typecheck:server && npm run check:server && npm run test:all && npm run build", "start": "node server.js" }, "dependencies": { @@ -69,6 +70,7 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.50.1", "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.3", diff --git a/routes/bills.js b/routes/bills.js index 473adda..a560054 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -23,7 +23,7 @@ const { applyBankPaymentAsSourceOfTruth, } = require('../services/paymentAccountingService'); const { localDateString, todayLocal } = require('../utils/dates'); -const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money'); +const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts'); // ── GET /api/bills ──────────────────────────────────────────────────────────── router.get('/', (req, res) => { diff --git a/routes/calendar.js b/routes/calendar.js index 489f79d..fcd14bb 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -14,7 +14,7 @@ const { revokeToken, } = require('../services/calendarFeedService'); const { localDateString } = require('../utils/dates'); -const { roundMoney, sumMoney, fromCents } = require('../utils/money'); +const { roundMoney, sumMoney, fromCents } = require('../utils/money.mts'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); diff --git a/routes/export.js b/routes/export.js index d2312c6..b622436 100644 --- a/routes/export.js +++ b/routes/export.js @@ -7,7 +7,7 @@ const fs = require('fs'); const Database = require('better-sqlite3'); const xlsx = require('xlsx'); const { getDb } = require('../db/database'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); // GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD router.get('/', (req, res) => { diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js index 84bf28e..9f50730 100644 --- a/routes/monthly-starting-amounts.js +++ b/routes/monthly-starting-amounts.js @@ -3,7 +3,7 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { getCycleRange } = require('../services/statusService'); const { accountingActiveSql } = require('../services/paymentAccountingService'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); function parseYearMonth(source) { const now = new Date(); diff --git a/routes/payments.js b/routes/payments.js index 321710e..59dbbe4 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -11,7 +11,7 @@ const { reactivatePaymentsOverriddenBy, } = require('../services/paymentAccountingService'); const { todayLocal } = require('../utils/dates'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); // SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied. // It cannot be a bind parameter (SQL fragments are not parameterisable — only diff --git a/routes/snowball.js b/routes/snowball.js index fcd9d99..7eb0df2 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -5,7 +5,7 @@ const { standardizeError } = require('../middleware/errorFormatter'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService'); const { serializeBill } = require('../services/billsService'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); const DEBT_LIKE_CLAUSES = `( b.snowball_include = 1 diff --git a/routes/subscriptions.js b/routes/subscriptions.js index 84a79b3..e2a43af 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); const { createSubscriptionFromRecommendation, declineRecommendation, diff --git a/routes/summary.js b/routes/summary.js index 1d735b1..13c9c4d 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -4,7 +4,7 @@ const { getDb } = require('../db/database'); const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getUserSettings } = require('../services/userSettings'); const { accountingActiveSql } = require('../services/paymentAccountingService'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_PENDING_DAYS = 3; diff --git a/routes/transactions.js b/routes/transactions.js index 30569c4..f3fdd3d 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -23,7 +23,7 @@ const { } = require('../services/merchantStoreMatchService'); const { categorizeTransaction } = require('../services/spendingService'); const { todayLocal } = require('../utils/dates'); -const { roundMoney } = require('../utils/money'); +const { roundMoney } = require('../utils/money.mts'); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); diff --git a/scripts/seedDemoData.js b/scripts/seedDemoData.js index fcb24a1..a8ce837 100644 --- a/scripts/seedDemoData.js +++ b/scripts/seedDemoData.js @@ -14,7 +14,7 @@ const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.d const { getDb, ensureUserDefaultCategories } = require('../db/database'); // Money columns (expected_amount, current_balance, minimum_payment) are stored as // integer cents since migration v1.03 — convert the demo dollars before insert. -const { toCents } = require('../utils/money'); +const { toCents } = require('../utils/money.mts'); const CATEGORIES = [ 'Utilities', diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.js index e31b927..4a8de9f 100644 --- a/services/amountSuggestionService.js +++ b/services/amountSuggestionService.js @@ -1,7 +1,7 @@ 'use strict'; const { accountingActiveSql } = require('./paymentAccountingService'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); // The 6 calendar months immediately preceding (year, month), newest first, each // as { y, m, key: 'YYYY-MM' }. diff --git a/services/analyticsService.js b/services/analyticsService.js index 8812ffc..61c9186 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.js @@ -2,7 +2,7 @@ const { getDb } = require('../db/database'); const { accountingActiveSql } = require('./paymentAccountingService'); -const { sumMoney, fromCents } = require('../utils/money'); +const { sumMoney, fromCents } = require('../utils/money.mts'); const { resolveDueDate } = require('./statusService'); function parseInteger(value, fallback) { diff --git a/services/aprService.js b/services/aprService.js index a12ee61..f8767c8 100644 --- a/services/aprService.js +++ b/services/aprService.js @@ -1,5 +1,5 @@ -const { roundMoney, sumMoney } = require('../utils/money'); +const { roundMoney, sumMoney } = require('../utils/money.mts'); /** * APR / amortization mathematics. * All functions are pure — no DB access, no side effects. diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 2353631..867362b 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -4,7 +4,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { getUserSettings } = require('./userSettings'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); const { localDateString } = require('../utils/dates'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. diff --git a/services/billsService.js b/services/billsService.js index a9aac00..8939bee 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -1,5 +1,5 @@ const { monthKey } = require('../utils/dates'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const TEMPLATE_FIELDS = [ diff --git a/services/calendarFeedService.js b/services/calendarFeedService.js index 9c0de87..940c034 100644 --- a/services/calendarFeedService.js +++ b/services/calendarFeedService.js @@ -3,7 +3,7 @@ const crypto = require('crypto'); const { getDb } = require('../db/database'); const { normalizeCycleType, resolveDueDate } = require('./statusService'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const FEED_PAST_MONTHS = 12; diff --git a/services/driftService.js b/services/driftService.js index 993895d..5721f94 100644 --- a/services/driftService.js +++ b/services/driftService.js @@ -5,7 +5,7 @@ const { getCycleRange } = require('./statusService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { getUserSettings } = require('./userSettings'); const { localDateString } = require('../utils/dates'); -const { roundMoney, fromCents } = require('../utils/money'); +const { roundMoney, fromCents } = require('../utils/money.mts'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index c0ee767..67dfb0d 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -3,7 +3,7 @@ const { getDb } = require('../db/database'); const { getCycleRange, resolveDueDate } = require('./statusService'); const { decorateTransaction } = require('./transactionService'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); function suggestionError(status, message, code, field = null) { const err = new Error(message); diff --git a/services/notificationService.js b/services/notificationService.js index 341a050..00e77ec 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -8,7 +8,7 @@ const { markNotificationTestSuccess, } = require('./statusRuntime'); const { localDateString } = require('../utils/dates'); -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); // ── Push notification channels ──────────────────────────────────────────────── diff --git a/services/paymentValidation.js b/services/paymentValidation.js index 2d93e96..d8bc6b0 100644 --- a/services/paymentValidation.js +++ b/services/paymentValidation.js @@ -1,6 +1,6 @@ 'use strict'; -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); function isPositiveIntegerString(value) { return /^\d+$/.test(String(value).trim()); diff --git a/services/snowballService.js b/services/snowballService.js index 8239211..a274a57 100644 --- a/services/snowballService.js +++ b/services/snowballService.js @@ -1,5 +1,5 @@ -const { roundMoney, sumMoney } = require('../utils/money'); +const { roundMoney, sumMoney } = require('../utils/money.mts'); /** * Debt payoff calculators — Snowball and Avalanche methods. * diff --git a/services/spendingService.js b/services/spendingService.js index 4ae4bb0..b5ce17b 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -2,7 +2,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); // Spending = unmatched outflows (amount < 0) that haven't been ignored. // Bill-matched transactions are excluded so there's no double-counting. diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index 73427a8..0013e1d 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -15,7 +15,7 @@ const xlsx = require('xlsx'); const crypto = require('crypto'); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); -const { toCents, fromCents } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money.mts'); // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/services/statusService.js b/services/statusService.js index 23a2830..d70f2e0 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -29,7 +29,7 @@ function pad(value) { return String(value).padStart(2, '0'); } -const { fromCents } = require('../utils/money'); +const { fromCents } = require('../utils/money.mts'); const { serializePayment } = require('./paymentValidation'); function dateString(year, month, day) { diff --git a/services/subscriptionService.js b/services/subscriptionService.js index e763d5f..1b38af2 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -2,7 +2,7 @@ const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { localDateString, todayLocal } = require('../utils/dates'); -const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money'); +const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money.mts'); const SUBSCRIPTION_TYPES = [ 'streaming', 'software', 'cloud', 'music', 'news', diff --git a/services/trackerService.js b/services/trackerService.js index cf01939..86d7109 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -8,7 +8,7 @@ const { computeAmountSuggestionsBatch } = require('./amountSuggestionService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates'); -const { sumMoney, roundMoney, fromCents } = require('../utils/money'); +const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts'); const DEFAULT_PENDING_DAYS = 3; diff --git a/services/userDbImportService.js b/services/userDbImportService.js index 8fc74d1..2146433 100644 --- a/services/userDbImportService.js +++ b/services/userDbImportService.js @@ -7,7 +7,7 @@ const path = require('path'); const Database = require('better-sqlite3'); const { getDb } = require('../db/database'); const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService'); -const { toCents } = require('../utils/money'); +const { toCents } = require('../utils/money.mts'); const MAX_SQLITE_BYTES = 50 * 1024 * 1024; const SESSION_TTL_HOURS = 24; diff --git a/tests/money.test.js b/tests/money.test.js index acb3857..99b869a 100644 --- a/tests/money.test.js +++ b/tests/money.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD } = require('../utils/money'); +const { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD } = require('../utils/money.mts'); test('toCents — unchanged for integer / ≤2-decimal / formatted inputs', () => { assert.strictEqual(toCents(0), 0); diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..d32f557 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,27 @@ +{ + "//": "Server-side type-checking. Node 25 runs .ts natively (type-stripping), so there is NO build step — this config only type-checks (noEmit). Migration is incremental (like the client): allowJs + checkJs:false means only .ts server files are checked; the remaining .js run untouched. Migrated modules are ESM (.ts) and are required from .js callers via Node's require(esm).", + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowJs": true, + "checkJs": false, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": [ + "utils/**/*.ts", "utils/**/*.mts", + "services/**/*.ts", "services/**/*.mts", + "routes/**/*.ts", "routes/**/*.mts", + "middleware/**/*.ts", "middleware/**/*.mts", + "db/**/*.ts", "db/**/*.mts", + "workers/**/*.ts", "workers/**/*.mts", + "server.mts" + ] +} diff --git a/utils/money.js b/utils/money.mts similarity index 64% rename from utils/money.js rename to utils/money.mts index a57dd4c..4d7ce9f 100644 --- a/utils/money.js +++ b/utils/money.mts @@ -1,5 +1,3 @@ -'use strict'; - /** * Money utilities — integer-cents core. * @@ -14,8 +12,23 @@ * The planned v1.03 migration (see docs/cents-migration-plan.md) converts the * dollar columns to integer cents. Until then, services work in dollars at * their boundaries and these helpers guarantee cent-exact math internally. + * + * Branded types (mirroring client/lib/money.ts) make the cents↔dollars boundary + * a compile error for any typed (.ts) caller: a `Cents` value cannot be passed + * where `Dollars` is expected, and vice-versa. (.js callers are unaffected — + * the brands are erased at runtime; toCents/fromCents are the only crossings.) */ +/** Integer cents — a raw amount as stored for SimpleFIN transactions/accounts. */ +export type Cents = number & { readonly __brand: 'Cents' }; +/** Dollars — the unit bills/payments/budgets store and the API serialises. */ +export type Dollars = number & { readonly __brand: 'Dollars' }; + +/** Accepted at the dollars→cents boundary: a Dollars/number, a "$1,234.56" string, or empty. */ +export type DollarsInput = Dollars | number | string | null | undefined; +/** Accepted at the cents→dollars boundary. */ +export type CentsInput = Cents | number | null | undefined; + /** * Dollars (number or string like "$1,234.56") → integer cents. * null/undefined/'' → null. Unparseable input → NaN (caller validates). @@ -26,39 +39,39 @@ * old helper for all integer / ≤2-decimal / "$1,234.56" inputs; only 3+-decimal * half-cent values change, and they now round half away from zero. */ -function toCents(dollars) { +export function toCents(dollars: DollarsInput): Cents | null { if (dollars === null || dollars === undefined || dollars === '') return null; const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars; const n = Number(cleaned); - if (!Number.isFinite(n)) return NaN; + if (!Number.isFinite(n)) return NaN as Cents; // The shortest decimal string that round-trips to this float (e.g. 1.005 for // the number 1.005, not 1.00499999…). Scientific notation → float fallback. const decimal = typeof cleaned === 'string' ? cleaned : n.toString(); - if (/[eE]/.test(decimal)) return Math.round(n * 100); + if (/[eE]/.test(decimal)) return Math.round(n * 100) as Cents; const negative = decimal.trim().startsWith('-'); const [intPart, fracPart = ''] = decimal.replace('-', '').split('.'); const frac3 = (fracPart + '000').slice(0, 3); const cents = Number(intPart || '0') * 100 + Number(frac3.slice(0, 2)) + (Number(frac3[2]) >= 5 ? 1 : 0); - return negative ? -cents : cents; + return (negative ? -cents : cents) as Cents; } /** Integer cents → dollar number (for API payloads). null/undefined → null. */ -function fromCents(cents) { +export function fromCents(cents: CentsInput): Dollars | null { if (cents === null || cents === undefined) return null; const n = Number(cents); if (!Number.isFinite(n)) return null; - return n / 100; + return (n / 100) as Dollars; } /** * Round a dollar amount to the nearest cent, exactly. * Drop-in replacement for the old `Math.round(x * 100) / 100` helpers. */ -function roundMoney(value) { +export function roundMoney(value: DollarsInput): Dollars { const cents = toCents(value); - return cents === null || Number.isNaN(cents) ? 0 : cents / 100; + return (cents === null || Number.isNaN(cents) ? 0 : cents / 100) as Dollars; } /** @@ -68,37 +81,35 @@ function roundMoney(value) { * sumMoney([0.1, 0.2]) → 0.3 (not 0.30000000000000004) * sumMoney(rows, r => r.amount) → picks a field */ -function sumMoney(values, pick) { +export function sumMoney(values: Iterable, pick?: (value: T) => DollarsInput): Dollars { let total = 0; for (const v of values) { - const raw = pick ? pick(v) : v; + const raw = pick ? pick(v) : (v as unknown as DollarsInput); const cents = toCents(raw); if (cents !== null && !Number.isNaN(cents)) total += cents; } - return total / 100; + return (total / 100) as Dollars; } /** * Multiply a dollar amount by a factor (interest rate, proration, etc.), * rounding the result to the nearest cent. */ -function mulMoney(dollars, factor) { +export function mulMoney(dollars: DollarsInput, factor: number): Dollars { const cents = toCents(dollars); - if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0; - return Math.round(cents * factor) / 100; + if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0 as Dollars; + return (Math.round(cents * factor) / 100) as Dollars; } const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** Dollar number → "$1,234.56" (negatives as "-$1,234.56"). null/undefined → "$0.00". */ -function formatUSD(dollars) { +export function formatUSD(dollars: DollarsInput): string { const n = Number(dollars) || 0; return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n)); } /** Integer cents → "$1,234.56". */ -function formatCentsUSD(cents) { +export function formatCentsUSD(cents: CentsInput): string { return formatUSD(fromCents(cents) ?? 0); } - -module.exports = { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD };