BillTracker/services/paymentValidation.cts

159 lines
5.3 KiB
TypeScript
Raw Permalink Normal View History

2026-05-15 22:45:38 -05:00
'use strict';
import type { Cents } from '../utils/money.mts';
// 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 {
2026-05-15 22:45:38 -05:00
return /^\d+$/.test(String(value).trim());
}
function validateIsoDate(value: unknown, field = 'paid_date'): FieldResult<string> {
2026-05-15 22:45:38 -05:00
if (typeof value !== 'string') {
return { error: `${field} must be a valid date in YYYY-MM-DD format` };
}
const trimmed = value.trim();
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
if (!match) {
return { error: `${field} must be a valid date in YYYY-MM-DD format` };
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return { error: `${field} must be a real calendar date in YYYY-MM-DD format` };
}
return { value: trimmed };
}
/**
* 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: unknown, field = 'amount'): FieldResult<Cents> {
const cents = toCents(value as number | string);
if (cents === null || !Number.isInteger(cents) || cents <= 0) {
2026-05-15 22:45:38 -05:00
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<T extends PaymentRow | null | undefined>(payment: T): T {
if (!payment) return payment;
const out = { ...payment };
if (out.amount != null) out.amount = fromCents(out.amount);
if (out.balance_delta != null) out.balance_delta = fromCents(out.balance_delta);
if (out.interest_delta != null) out.interest_delta = fromCents(out.interest_delta);
return out;
2026-05-15 22:45:38 -05:00
}
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'] as const;
2026-05-16 20:26:09 -05:00
function validatePaymentSource(value: unknown, field = 'payment_source'): FieldResult<string> {
2026-05-16 20:26:09 -05:00
if (typeof value !== 'string') {
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
}
const source = value.trim();
if (!(PAYMENT_SOURCES as readonly string[]).includes(source)) {
2026-05-16 20:26:09 -05:00
return { error: `${field} must be one of: ${PAYMENT_SOURCES.join(', ')}` };
}
return { value: source };
}
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 } {
2026-05-15 22:45:38 -05:00
const {
requireBillId = true,
requireAmount = true,
requirePaidDate = true,
fieldPrefix = '',
} = options;
const normalized: NormalizedPayment = {};
2026-05-15 22:45:38 -05:00
if (requireBillId || data.bill_id !== undefined) {
if (data.bill_id === undefined || data.bill_id === null || data.bill_id === '') {
return { error: 'bill_id is required', field: `${fieldPrefix}bill_id` };
}
if (!isPositiveIntegerString(data.bill_id)) {
return { error: 'bill_id must be a positive integer', field: `${fieldPrefix}bill_id` };
}
normalized.bill_id = Number(String(data.bill_id).trim());
}
if (requireAmount || data.amount !== undefined) {
if (data.amount === undefined || data.amount === null || data.amount === '') {
return { error: 'amount is required', field: `${fieldPrefix}amount` };
}
const amount = validatePositiveAmount(data.amount, `${fieldPrefix}amount`);
if ('error' in amount) return { error: amount.error, field: `${fieldPrefix}amount` };
2026-05-15 22:45:38 -05:00
normalized.amount = amount.value;
}
if (requirePaidDate || data.paid_date !== undefined) {
if (data.paid_date === undefined || data.paid_date === null || data.paid_date === '') {
return { error: 'paid_date is required', field: `${fieldPrefix}paid_date` };
}
const paidDate = validateIsoDate(data.paid_date, `${fieldPrefix}paid_date`);
if ('error' in paidDate) return { error: paidDate.error, field: `${fieldPrefix}paid_date` };
2026-05-15 22:45:38 -05:00
normalized.paid_date = paidDate.value;
}
2026-05-16 20:26:09 -05:00
if (data.payment_source !== undefined) {
const source = validatePaymentSource(data.payment_source, `${fieldPrefix}payment_source`);
if ('error' in source) return { error: source.error, field: `${fieldPrefix}payment_source` };
2026-05-16 20:26:09 -05:00
normalized.payment_source = source.value;
}
2026-05-15 22:45:38 -05:00
return { normalized };
}
module.exports = {
2026-05-16 20:26:09 -05:00
PAYMENT_SOURCES,
serializePayment,
2026-05-15 22:45:38 -05:00
validateIsoDate,
validatePaymentInput,
2026-05-16 20:26:09 -05:00
validatePaymentSource,
2026-05-15 22:45:38 -05:00
validatePositiveAmount,
};