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');
|
} = 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');
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)}`;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue