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:
parent
3f9a6cf23e
commit
ef566b12f0
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<T> = { 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<string> {
|
||||
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<Cents> {
|
||||
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<T extends PaymentRow | null | undefined>(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<string> {
|
||||
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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in New Issue