feat(server-ts): migrate the money boundary to TypeScript on Node's native runtime (Phase 2)

Establishes the server-TypeScript foundation and migrates utils/money →
utils/money.mts with branded Cents/Dollars (mirroring the client), so the
cents↔dollars boundary — the origin of the reconciliation bug class — is a
compile error for any typed server caller.

Approach (no build step): Node 25 runs .ts natively (type-stripping).
Migrated modules use the explicit-ESM .mts extension (the project is
"type":"commonjs", which disables .ts ESM auto-detection) and are required
from the CJS callers via Node's require(esm) — verified working. Infra:
tsconfig.server.json (allowJs + checkJs:false → incremental like the
client), npm run typecheck:server, check:server extended to .mts, wired
into ci. 28 require sites repointed to money.mts.

typecheck:server clean; full server suite 225 + e2e probe 17/17 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 13:44:04 -05:00
parent 1d3b7435a0
commit 200c6373f5
32 changed files with 108 additions and 50 deletions

18
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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) => {

View File

@ -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();

View File

@ -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) => {

View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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,

View File

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

View File

@ -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']);

View File

@ -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',

View File

@ -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' }.

View File

@ -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) {

View File

@ -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.

View File

@ -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.

View File

@ -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 = [

View File

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

View File

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

View File

@ -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);

View File

@ -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 ────────────────────────────────────────────────

View File

@ -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());

View File

@ -1,5 +1,5 @@
const { roundMoney, sumMoney } = require('../utils/money');
const { roundMoney, sumMoney } = require('../utils/money.mts');
/**
* Debt payoff calculators Snowball and Avalanche methods.
*

View File

@ -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.

View File

@ -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 ────────────────────────────────────────────────────────────────

View File

@ -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) {

View File

@ -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',

View File

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

View File

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

View File

@ -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);

27
tsconfig.server.json Normal file
View File

@ -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"
]
}

View File

@ -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 centsdollars 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<T>(values: Iterable<T>, 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 };