From ef566b12f021a39369d9fc4d28adbd036854a4c3 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 13:51:37 -0500 Subject: [PATCH] =?UTF-8?q?feat(server-ts):=20paymentValidation=20?= =?UTF-8?q?=E2=86=92=20.cts,=20proving=20the=20CJS-module=20path=20(Phase?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- routes/bills.js | 2 +- routes/matches.js | 2 +- routes/payments.js | 2 +- ...entValidation.js => paymentValidation.cts} | 60 ++++++++++++++----- services/statusService.js | 2 +- services/transactionMatchService.js | 2 +- tests/paymentValidation.test.js | 2 +- 7 files changed, 51 insertions(+), 21 deletions(-) rename services/{paymentValidation.js => paymentValidation.cts} (63%) diff --git a/routes/bills.js b/routes/bills.js index cd83cb9..88315d4 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -14,7 +14,7 @@ const { } = require('../services/billsService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); 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 { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); diff --git a/routes/matches.js b/routes/matches.js index 3b5f830..84f7ffc 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -8,7 +8,7 @@ const { } = require('../services/matchSuggestionService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { markMatched, markUnmatched } = require('../services/transactionMatchState'); -const { serializePayment } = require('../services/paymentValidation'); +const { serializePayment } = require('../services/paymentValidation.cts'); const { todayLocal } = require('../utils/dates.mts'); function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { diff --git a/routes/payments.js b/routes/payments.js index 3a20d41..6a8d12c 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -3,7 +3,7 @@ const { standardizeError } = require('../middleware/errorFormatter'); const router = require('express').Router(); const { getDb } = require('../db/database'); 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 { markUnmatched } = require('../services/transactionMatchState'); const { diff --git a/services/paymentValidation.js b/services/paymentValidation.cts similarity index 63% rename from services/paymentValidation.js rename to services/paymentValidation.cts index d8bc6b0..acd1771 100644 --- a/services/paymentValidation.js +++ b/services/paymentValidation.cts @@ -1,12 +1,19 @@ '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 = { value: T } | { error: string }; + +function isPositiveIntegerString(value: unknown): boolean { return /^\d+$/.test(String(value).trim()); } -function validateIsoDate(value, field = 'paid_date') { +function validateIsoDate(value: unknown, field = 'paid_date'): FieldResult { if (typeof value !== 'string') { 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 * (the unit `payments.amount` and related money columns are stored in). */ -function validatePositiveAmount(value, field = 'amount') { - const cents = toCents(value); - if (!Number.isInteger(cents) || cents <= 0) { +function validatePositiveAmount(value: unknown, field = 'amount'): FieldResult { + const cents = toCents(value as number | string); + if (cents === null || !Number.isInteger(cents) || cents <= 0) { return { error: `${field} must be a positive number` }; } 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) * to dollars for API responses. */ -function serializePayment(payment) { +function serializePayment(payment: T): T { if (!payment) return payment; const out = { ...payment }; if (out.amount != null) out.amount = fromCents(out.amount); @@ -58,28 +72,44 @@ function serializePayment(payment) { 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 { if (typeof value !== 'string') { return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` }; } 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 { 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, + options: ValidatePaymentOptions = {}, +): { normalized: NormalizedPayment } | { error: string; field: string } { const { requireBillId = true, requireAmount = true, requirePaidDate = true, fieldPrefix = '', } = options; - const normalized = {}; + const normalized: NormalizedPayment = {}; if (requireBillId || data.bill_id !== undefined) { 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` }; } 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; } @@ -105,13 +135,13 @@ function validatePaymentInput(data, options = {}) { return { error: 'paid_date is required', field: `${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; } if (data.payment_source !== undefined) { 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; } diff --git a/services/statusService.js b/services/statusService.js index d70f2e0..0771a92 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -30,7 +30,7 @@ function pad(value) { } const { fromCents } = require('../utils/money.mts'); -const { serializePayment } = require('./paymentValidation'); +const { serializePayment } = require('./paymentValidation.cts'); function dateString(year, month, day) { return `${year}-${pad(month)}-${pad(day)}`; diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index 167595a..1d02ecc 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -10,7 +10,7 @@ const { decorateTransaction, getTransactionForUser, } = require('./transactionService'); -const { serializePayment } = require('./paymentValidation'); +const { serializePayment } = require('./paymentValidation.cts'); const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState'); const MATCH_PAYMENT_SOURCE = 'transaction_match'; diff --git a/tests/paymentValidation.test.js b/tests/paymentValidation.test.js index bf64cd9..e7c3f85 100644 --- a/tests/paymentValidation.test.js +++ b/tests/paymentValidation.test.js @@ -9,7 +9,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { validatePaymentInput, validatePositiveAmount, validateIsoDate, validatePaymentSource, PAYMENT_SOURCES, -} = require('../services/paymentValidation'); +} = require('../services/paymentValidation.cts'); 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' });