feat(server-ts): paymentValidation → .cts, proving the CJS-module path (Phase 2)

The payment-input gate (well-tested by Phase 1) migrates to TypeScript as
a .cts (CommonJS) module: keeps require/module.exports, imports the branded
Cents type from money.mts, and casts the require(esm) result so toCents/
fromCents keep their branded signatures. This proves the low-churn path for
the ~90 remaining CJS services/routes (no require→import rewrite needed).

Both server-TS patterns now proven end-to-end: .mts (ESM, type-source) +
.cts (CJS). typecheck:server clean; suite 225 + probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 13:51:37 -05:00
parent 3f9a6cf23e
commit ef566b12f0
7 changed files with 51 additions and 21 deletions

View File

@ -14,7 +14,7 @@ const {
} = require('../services/billsService'); } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
const { normalizeMerchant } = require('../services/subscriptionService'); const { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService'); const { decorateTransaction } = require('../services/transactionService');

View File

@ -8,7 +8,7 @@ const {
} = require('../services/matchSuggestionService'); } = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
const { markMatched, markUnmatched } = require('../services/transactionMatchState'); const { markMatched, markUnmatched } = require('../services/transactionMatchState');
const { serializePayment } = require('../services/paymentValidation'); const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {

View File

@ -3,7 +3,7 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getCycleRange, resolveDueDate } = require('../services/statusService');
const { markUnmatched } = require('../services/transactionMatchState'); const { markUnmatched } = require('../services/transactionMatchState');
const { const {

View File

@ -1,12 +1,19 @@
'use strict'; 'use strict';
const { toCents, fromCents } = require('../utils/money.mts'); import type { Cents } from '../utils/money.mts';
function isPositiveIntegerString(value) { // CJS module (.cts): Node runs it as CommonJS; the require of the ESM money.mts
// works via Node's require(esm). Cast the require result to the module's type so
// toCents/fromCents keep their branded Cents/Dollars signatures.
const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');
type FieldResult<T> = { value: T } | { error: string };
function isPositiveIntegerString(value: unknown): boolean {
return /^\d+$/.test(String(value).trim()); return /^\d+$/.test(String(value).trim());
} }
function validateIsoDate(value, field = 'paid_date') { function validateIsoDate(value: unknown, field = 'paid_date'): FieldResult<string> {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return { error: `${field} must be a valid date in YYYY-MM-DD format` }; return { error: `${field} must be a valid date in YYYY-MM-DD format` };
} }
@ -37,19 +44,26 @@ function validateIsoDate(value, field = 'paid_date') {
* Validates a positive dollar amount and converts it to integer cents * Validates a positive dollar amount and converts it to integer cents
* (the unit `payments.amount` and related money columns are stored in). * (the unit `payments.amount` and related money columns are stored in).
*/ */
function validatePositiveAmount(value, field = 'amount') { function validatePositiveAmount(value: unknown, field = 'amount'): FieldResult<Cents> {
const cents = toCents(value); const cents = toCents(value as number | string);
if (!Number.isInteger(cents) || cents <= 0) { if (cents === null || !Number.isInteger(cents) || cents <= 0) {
return { error: `${field} must be a positive number` }; return { error: `${field} must be a positive number` };
} }
return { value: cents }; return { value: cents };
} }
interface PaymentRow {
amount?: number | null;
balance_delta?: number | null;
interest_delta?: number | null;
[key: string]: unknown;
}
/** /**
* Converts a payment row's cent columns (amount, balance_delta, interest_delta) * Converts a payment row's cent columns (amount, balance_delta, interest_delta)
* to dollars for API responses. * to dollars for API responses.
*/ */
function serializePayment(payment) { function serializePayment<T extends PaymentRow | null | undefined>(payment: T): T {
if (!payment) return payment; if (!payment) return payment;
const out = { ...payment }; const out = { ...payment };
if (out.amount != null) out.amount = fromCents(out.amount); if (out.amount != null) out.amount = fromCents(out.amount);
@ -58,28 +72,44 @@ function serializePayment(payment) {
return out; return out;
} }
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync']; const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'] as const;
function validatePaymentSource(value, field = 'payment_source') { function validatePaymentSource(value: unknown, field = 'payment_source'): FieldResult<string> {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` }; return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
} }
const source = value.trim(); const source = value.trim();
if (!PAYMENT_SOURCES.includes(source)) { if (!(PAYMENT_SOURCES as readonly string[]).includes(source)) {
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` }; return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
} }
return { value: source }; return { value: source };
} }
function validatePaymentInput(data, options = {}) { interface ValidatePaymentOptions {
requireBillId?: boolean;
requireAmount?: boolean;
requirePaidDate?: boolean;
fieldPrefix?: string;
}
interface NormalizedPayment {
bill_id?: number;
amount?: Cents;
paid_date?: string;
payment_source?: string;
}
function validatePaymentInput(
data: Record<string, unknown>,
options: ValidatePaymentOptions = {},
): { normalized: NormalizedPayment } | { error: string; field: string } {
const { const {
requireBillId = true, requireBillId = true,
requireAmount = true, requireAmount = true,
requirePaidDate = true, requirePaidDate = true,
fieldPrefix = '', fieldPrefix = '',
} = options; } = options;
const normalized = {}; const normalized: NormalizedPayment = {};
if (requireBillId || data.bill_id !== undefined) { if (requireBillId || data.bill_id !== undefined) {
if (data.bill_id === undefined || data.bill_id === null || data.bill_id === '') { if (data.bill_id === undefined || data.bill_id === null || data.bill_id === '') {
@ -96,7 +126,7 @@ function validatePaymentInput(data, options = {}) {
return { error: 'amount is required', field: `${fieldPrefix}amount` }; return { error: 'amount is required', field: `${fieldPrefix}amount` };
} }
const amount = validatePositiveAmount(data.amount, `${fieldPrefix}amount`); const amount = validatePositiveAmount(data.amount, `${fieldPrefix}amount`);
if (amount.error) return { error: amount.error, field: `${fieldPrefix}amount` }; if ('error' in amount) return { error: amount.error, field: `${fieldPrefix}amount` };
normalized.amount = amount.value; normalized.amount = amount.value;
} }
@ -105,13 +135,13 @@ function validatePaymentInput(data, options = {}) {
return { error: 'paid_date is required', field: `${fieldPrefix}paid_date` }; return { error: 'paid_date is required', field: `${fieldPrefix}paid_date` };
} }
const paidDate = validateIsoDate(data.paid_date, `${fieldPrefix}paid_date`); const paidDate = validateIsoDate(data.paid_date, `${fieldPrefix}paid_date`);
if (paidDate.error) return { error: paidDate.error, field: `${fieldPrefix}paid_date` }; if ('error' in paidDate) return { error: paidDate.error, field: `${fieldPrefix}paid_date` };
normalized.paid_date = paidDate.value; normalized.paid_date = paidDate.value;
} }
if (data.payment_source !== undefined) { if (data.payment_source !== undefined) {
const source = validatePaymentSource(data.payment_source, `${fieldPrefix}payment_source`); const source = validatePaymentSource(data.payment_source, `${fieldPrefix}payment_source`);
if (source.error) return { error: source.error, field: `${fieldPrefix}payment_source` }; if ('error' in source) return { error: source.error, field: `${fieldPrefix}payment_source` };
normalized.payment_source = source.value; normalized.payment_source = source.value;
} }

View File

@ -30,7 +30,7 @@ function pad(value) {
} }
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');
const { serializePayment } = require('./paymentValidation'); const { serializePayment } = require('./paymentValidation.cts');
function dateString(year, month, day) { function dateString(year, month, day) {
return `${year}-${pad(month)}-${pad(day)}`; return `${year}-${pad(month)}-${pad(day)}`;

View File

@ -10,7 +10,7 @@ const {
decorateTransaction, decorateTransaction,
getTransactionForUser, getTransactionForUser,
} = require('./transactionService'); } = require('./transactionService');
const { serializePayment } = require('./paymentValidation'); const { serializePayment } = require('./paymentValidation.cts');
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState'); const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState');
const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_SOURCE = 'transaction_match';

View File

@ -9,7 +9,7 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
validatePaymentInput, validatePositiveAmount, validateIsoDate, validatePaymentSource, PAYMENT_SOURCES, validatePaymentInput, validatePositiveAmount, validateIsoDate, validatePaymentSource, PAYMENT_SOURCES,
} = require('../services/paymentValidation'); } = require('../services/paymentValidation.cts');
test('happy path: normalizes dollars → integer cents and echoes the fields', () => { test('happy path: normalizes dollars → integer cents and echoes the fields', () => {
const r = validatePaymentInput({ bill_id: '5', amount: 100, paid_date: '2026-07-01', payment_source: 'manual' }); const r = validatePaymentInput({ bill_id: '5', amount: 100, paid_date: '2026-07-01', payment_source: 'manual' });