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:
parent
1d3b7435a0
commit
200c6373f5
|
|
@ -54,6 +54,7 @@
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|
@ -6030,6 +6031,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
|
|
@ -13327,6 +13338,13 @@
|
||||||
"node": ">=20.18.1"
|
"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": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"dev:ui": "vite",
|
"dev:ui": "vite",
|
||||||
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
|
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
|
||||||
"build": "vite build",
|
"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",
|
"lint": "eslint client",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"check": "npm run check:server && npm run build",
|
"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: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",
|
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
||||||
"smoke:prod": "bash scripts/prod-smoke.sh",
|
"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"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -69,6 +70,7 @@
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const {
|
||||||
applyBankPaymentAsSourceOfTruth,
|
applyBankPaymentAsSourceOfTruth,
|
||||||
} = require('../services/paymentAccountingService');
|
} = require('../services/paymentAccountingService');
|
||||||
const { localDateString, todayLocal } = require('../utils/dates');
|
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 ────────────────────────────────────────────────────────────
|
// ── GET /api/bills ────────────────────────────────────────────────────────────
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const {
|
||||||
revokeToken,
|
revokeToken,
|
||||||
} = require('../services/calendarFeedService');
|
} = require('../services/calendarFeedService');
|
||||||
const { localDateString } = require('../utils/dates');
|
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) {
|
function clampDay(year, month, day) {
|
||||||
const daysInMonth = new Date(year, month, 0).getDate();
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const fs = require('fs');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const xlsx = require('xlsx');
|
const xlsx = require('xlsx');
|
||||||
const { getDb } = require('../db/database');
|
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
|
// GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange } = require('../services/statusService');
|
const { getCycleRange } = require('../services/statusService');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
function parseYearMonth(source) {
|
function parseYearMonth(source) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const {
|
||||||
reactivatePaymentsOverriddenBy,
|
reactivatePaymentsOverriddenBy,
|
||||||
} = require('../services/paymentAccountingService');
|
} = require('../services/paymentAccountingService');
|
||||||
const { todayLocal } = require('../utils/dates');
|
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.
|
// 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
|
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
|
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
|
||||||
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
|
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
|
||||||
const { serializeBill } = require('../services/billsService');
|
const { serializeBill } = require('../services/billsService');
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
const DEBT_LIKE_CLAUSES = `(
|
const DEBT_LIKE_CLAUSES = `(
|
||||||
b.snowball_include = 1
|
b.snowball_include = 1
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
const { standardizeError } = require('../middleware/errorFormatter');
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
const { fromCents } = require('../utils/money');
|
const { fromCents } = require('../utils/money.mts');
|
||||||
const {
|
const {
|
||||||
createSubscriptionFromRecommendation,
|
createSubscriptionFromRecommendation,
|
||||||
declineRecommendation,
|
declineRecommendation,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const { getDb } = require('../db/database');
|
||||||
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||||
const { getUserSettings } = require('../services/userSettings');
|
const { getUserSettings } = require('../services/userSettings');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
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_INCOME_LABEL = 'Salary';
|
||||||
const DEFAULT_PENDING_DAYS = 3;
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const {
|
||||||
} = require('../services/merchantStoreMatchService');
|
} = require('../services/merchantStoreMatchService');
|
||||||
const { categorizeTransaction } = require('../services/spendingService');
|
const { categorizeTransaction } = require('../services/spendingService');
|
||||||
const { todayLocal } = require('../utils/dates');
|
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 MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
|
||||||
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
|
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.d
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
// Money columns (expected_amount, current_balance, minimum_payment) are stored as
|
// Money columns (expected_amount, current_balance, minimum_payment) are stored as
|
||||||
// integer cents since migration v1.03 — convert the demo dollars before insert.
|
// 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 = [
|
const CATEGORIES = [
|
||||||
'Utilities',
|
'Utilities',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
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
|
// The 6 calendar months immediately preceding (year, month), newest first, each
|
||||||
// as { y, m, key: 'YYYY-MM' }.
|
// as { y, m, key: 'YYYY-MM' }.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { sumMoney, fromCents } = require('../utils/money');
|
const { sumMoney, fromCents } = require('../utils/money.mts');
|
||||||
const { resolveDueDate } = require('./statusService');
|
const { resolveDueDate } = require('./statusService');
|
||||||
|
|
||||||
function parseInteger(value, fallback) {
|
function parseInteger(value, fallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
const { roundMoney, sumMoney } = require('../utils/money');
|
const { roundMoney, sumMoney } = require('../utils/money.mts');
|
||||||
/**
|
/**
|
||||||
* APR / amortization mathematics.
|
* APR / amortization mathematics.
|
||||||
* All functions are pure — no DB access, no side effects.
|
* All functions are pure — no DB access, no side effects.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const { normalizeMerchant } = require('./subscriptionService');
|
||||||
const { getUserSettings } = require('./userSettings');
|
const { getUserSettings } = require('./userSettings');
|
||||||
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
|
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
|
||||||
const { localDateString } = require('../utils/dates');
|
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)
|
// 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.
|
// within the transaction string (or vice versa), not just as a substring.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { monthKey } = require('../utils/dates');
|
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 VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
|
||||||
|
|
||||||
const TEMPLATE_FIELDS = [
|
const TEMPLATE_FIELDS = [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { normalizeCycleType, resolveDueDate } = require('./statusService');
|
const { normalizeCycleType, resolveDueDate } = require('./statusService');
|
||||||
const { fromCents } = require('../utils/money');
|
const { fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
|
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
|
||||||
const FEED_PAST_MONTHS = 12;
|
const FEED_PAST_MONTHS = 12;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ const { getCycleRange } = require('./statusService');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { getUserSettings } = require('./userSettings');
|
const { getUserSettings } = require('./userSettings');
|
||||||
const { localDateString } = require('../utils/dates');
|
const { localDateString } = require('../utils/dates');
|
||||||
const { roundMoney, fromCents } = require('../utils/money');
|
const { roundMoney, fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
const MONTHS_BACK = 3;
|
const MONTHS_BACK = 3;
|
||||||
const MIN_PAID_MONTHS = 2;
|
const MIN_PAID_MONTHS = 2;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange, resolveDueDate } = require('./statusService');
|
const { getCycleRange, resolveDueDate } = require('./statusService');
|
||||||
const { decorateTransaction } = require('./transactionService');
|
const { decorateTransaction } = require('./transactionService');
|
||||||
const { fromCents } = require('../utils/money');
|
const { fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
function suggestionError(status, message, code, field = null) {
|
function suggestionError(status, message, code, field = null) {
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const {
|
||||||
markNotificationTestSuccess,
|
markNotificationTestSuccess,
|
||||||
} = require('./statusRuntime');
|
} = require('./statusRuntime');
|
||||||
const { localDateString } = require('../utils/dates');
|
const { localDateString } = require('../utils/dates');
|
||||||
const { fromCents } = require('../utils/money');
|
const { fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
// ── Push notification channels ────────────────────────────────────────────────
|
// ── Push notification channels ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
function isPositiveIntegerString(value) {
|
function isPositiveIntegerString(value) {
|
||||||
return /^\d+$/.test(String(value).trim());
|
return /^\d+$/.test(String(value).trim());
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
const { roundMoney, sumMoney } = require('../utils/money');
|
const { roundMoney, sumMoney } = require('../utils/money.mts');
|
||||||
/**
|
/**
|
||||||
* Debt payoff calculators — Snowball and Avalanche methods.
|
* Debt payoff calculators — Snowball and Avalanche methods.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { normalizeMerchant } = require('./subscriptionService');
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
const { localDateString } = require('../utils/dates');
|
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.
|
// Spending = unmatched outflows (amount < 0) that haven't been ignored.
|
||||||
// Bill-matched transactions are excluded so there's no double-counting.
|
// Bill-matched transactions are excluded so there's no double-counting.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const xlsx = require('xlsx');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function pad(value) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fromCents } = require('../utils/money');
|
const { fromCents } = require('../utils/money.mts');
|
||||||
const { serializePayment } = require('./paymentValidation');
|
const { serializePayment } = require('./paymentValidation');
|
||||||
|
|
||||||
function dateString(year, month, day) {
|
function dateString(year, month, day) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
|
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
|
||||||
const { localDateString, todayLocal } = require('../utils/dates');
|
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 = [
|
const SUBSCRIPTION_TYPES = [
|
||||||
'streaming', 'software', 'cloud', 'music', 'news',
|
'streaming', 'software', 'cloud', 'music', 'news',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { normalizeMerchant } = require('./subscriptionService');
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
const { localDateString } = require('../utils/dates');
|
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;
|
const DEFAULT_PENDING_DAYS = 3;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const path = require('path');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
||||||
const { toCents } = require('../utils/money');
|
const { toCents } = require('../utils/money.mts');
|
||||||
|
|
||||||
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
||||||
const SESSION_TTL_HOURS = 24;
|
const SESSION_TTL_HOURS = 24;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
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', () => {
|
test('toCents — unchanged for integer / ≤2-decimal / formatted inputs', () => {
|
||||||
assert.strictEqual(toCents(0), 0);
|
assert.strictEqual(toCents(0), 0);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Money utilities — integer-cents core.
|
* Money utilities — integer-cents core.
|
||||||
*
|
*
|
||||||
|
|
@ -14,8 +12,23 @@
|
||||||
* The planned v1.03 migration (see docs/cents-migration-plan.md) converts the
|
* 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
|
* dollar columns to integer cents. Until then, services work in dollars at
|
||||||
* their boundaries and these helpers guarantee cent-exact math internally.
|
* 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.
|
* Dollars (number or string like "$1,234.56") → integer cents.
|
||||||
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
* 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
|
* 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.
|
* 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;
|
if (dollars === null || dollars === undefined || dollars === '') return null;
|
||||||
const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars;
|
const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars;
|
||||||
const n = Number(cleaned);
|
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 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.
|
// the number 1.005, not 1.00499999…). Scientific notation → float fallback.
|
||||||
const decimal = typeof cleaned === 'string' ? cleaned : n.toString();
|
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 negative = decimal.trim().startsWith('-');
|
||||||
const [intPart, fracPart = ''] = decimal.replace('-', '').split('.');
|
const [intPart, fracPart = ''] = decimal.replace('-', '').split('.');
|
||||||
const frac3 = (fracPart + '000').slice(0, 3);
|
const frac3 = (fracPart + '000').slice(0, 3);
|
||||||
const cents = Number(intPart || '0') * 100 + Number(frac3.slice(0, 2)) + (Number(frac3[2]) >= 5 ? 1 : 0);
|
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. */
|
/** 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;
|
if (cents === null || cents === undefined) return null;
|
||||||
const n = Number(cents);
|
const n = Number(cents);
|
||||||
if (!Number.isFinite(n)) return null;
|
if (!Number.isFinite(n)) return null;
|
||||||
return n / 100;
|
return (n / 100) as Dollars;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Round a dollar amount to the nearest cent, exactly.
|
* Round a dollar amount to the nearest cent, exactly.
|
||||||
* Drop-in replacement for the old `Math.round(x * 100) / 100` helpers.
|
* 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);
|
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([0.1, 0.2]) → 0.3 (not 0.30000000000000004)
|
||||||
* sumMoney(rows, r => r.amount) → picks a field
|
* 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;
|
let total = 0;
|
||||||
for (const v of values) {
|
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);
|
const cents = toCents(raw);
|
||||||
if (cents !== null && !Number.isNaN(cents)) total += cents;
|
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.),
|
* Multiply a dollar amount by a factor (interest rate, proration, etc.),
|
||||||
* rounding the result to the nearest cent.
|
* rounding the result to the nearest cent.
|
||||||
*/
|
*/
|
||||||
function mulMoney(dollars, factor) {
|
export function mulMoney(dollars: DollarsInput, factor: number): Dollars {
|
||||||
const cents = toCents(dollars);
|
const cents = toCents(dollars);
|
||||||
if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0;
|
if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0 as Dollars;
|
||||||
return Math.round(cents * factor) / 100;
|
return (Math.round(cents * factor) / 100) as Dollars;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
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". */
|
/** 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;
|
const n = Number(dollars) || 0;
|
||||||
return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n));
|
return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer cents → "$1,234.56". */
|
/** Integer cents → "$1,234.56". */
|
||||||
function formatCentsUSD(cents) {
|
export function formatCentsUSD(cents: CentsInput): string {
|
||||||
return formatUSD(fromCents(cents) ?? 0);
|
return formatUSD(fromCents(cents) ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD };
|
|
||||||
Loading…
Reference in New Issue