fix(csv): parse M/D/YY dates with loud rejects; stop canceled rows zeroing stored fields (#65, #68)

Stampli exports switched from MM/DD/YYYY to M/D/YY, which toISODate
mangled into garbage ISO strings and slackAppHome's parseMDYLocal turned
into year-1926 dates. Canceled rows also blank 'Amount in USD' and can
blank dates, which the unconditional upsert wrote over previously stored
real values (116 records at amount_usd=0 as of the 2026-07-21
reconciliation).

- src/dates.js: shared strict parser for M/D/YY + MM/DD/YYYY (real-date
  validation, 2-digit years 2000-based, plausibility window helper)
- processPaymentCsv: canonicalize send_payment_on to MM/DD/YYYY; reject
  rows with unparseable/implausible dates and fail the invocation after
  valid rows are processed (surfaces via errors alarm + async DLQ;
  retry-safe since upserts are idempotent and existing checks are not
  re-offered to BoA); only overwrite amount_usd/send_payment_on with
  real values, falling back to the 'Amount' column on cancels
- BoA add_Issue/cancel_Issue now use stored record values when the CSV
  row is blank (cancel_Issue previously sent amount 0.00 for voids) and
  skip registration on missing date/amount instead of sending garbage;
  same guard on the backfill path
- slackAppHome: use the shared parser (fixes 1926 aging)
This commit is contained in:
Adam Moussa 2026-07-21 18:57:26 -04:00
parent bb8373900b
commit 97fbe4f68f
No known key found for this signature in database
3 changed files with 137 additions and 45 deletions

49
src/dates.js Normal file
View file

@ -0,0 +1,49 @@
// Shared date parsing for Stampli M/D/YY and MM/DD/YYYY formats.
// Stampli exports switched from MM/DD/YYYY to M/D/YY in 2026-06/07; both
// formats must parse, and anything else must be rejected (null), never
// passed through as a malformed string (payments-dashboard#65).
// Parse "M/D/YY" or "MM/DD/YYYY" into { year, month, day }, or null.
// Two-digit years are 2000-based. Validates the date is real (no 2/30).
export function parseMDY(value) {
const match = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/.exec(String(value ?? "").trim());
if (!match) return null;
const month = parseInt(match[1], 10);
const day = parseInt(match[2], 10);
let year = parseInt(match[3], 10);
if (match[3].length === 2) year += 2000;
const dt = new Date(year, month - 1, day);
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) {
return null;
}
return { year, month, day };
}
// "YYYY-MM-DD" or null.
export function toISODate(value) {
const p = parseMDY(value);
if (!p) return null;
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
}
// Canonical "MM/DD/YYYY" or null. Stored form for send_payment_on.
export function toCanonicalMDY(value) {
const p = parseMDY(value);
if (!p) return null;
return `${String(p.month).padStart(2, "0")}/${String(p.day).padStart(2, "0")}/${p.year}`;
}
// Local-midnight Date or null. Replaces slackAppHome's parseMDYLocal, which
// built year-1926 dates from 2-digit years via new Date(26, ...).
export function parseMDYLocal(value) {
const p = parseMDY(value);
if (!p) return null;
return new Date(p.year, p.month - 1, p.day);
}
// Plausibility window for ingested send dates. Anything outside is treated
// as parser garbage and the row is rejected loudly rather than stored.
export function isPlausibleSendYear(value) {
const p = parseMDY(value);
return p !== null && p.year >= 2020 && p.year <= 2035;
}

View file

@ -3,6 +3,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { parse } from "csv-parse/sync";
import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js";
const s3 = new S3Client();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
@ -39,14 +40,6 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
return data.access_token;
}
// Convert MM/DD/YYYY to YYYY-MM-DD
function toISODate(mdyDate) {
const parts = String(mdyDate).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts;
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
}
async function backfillBoA() {
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
@ -84,10 +77,16 @@ async function backfillBoA() {
if (submitted.has(item.check_number)) continue;
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
if (item.check_number.length > 10) continue;
const issueDate = toISODate(item.send_payment_on);
const amount = Number(item.amount_usd);
if (!issueDate || !(amount > 0)) {
console.error(`Backfill skipping check ${item.check_number}: missing issue date or amount`);
continue;
}
toSubmit.push({
checkNumber: item.check_number,
amount: Number(item.amount_usd).toFixed(2),
issueDate: toISODate(item.send_payment_on),
amount: amount.toFixed(2),
issueDate,
});
}
payKey = payScan.LastEvaluatedKey;
@ -272,6 +271,7 @@ export const handler = async (event) => {
const newChecks = [];
const cancelChecks = [];
const rejectedRows = [];
// Upsert each payment, tracking new and cancelled checks
let count = 0;
@ -281,7 +281,17 @@ export const handler = async (event) => {
const method = (row["Method"] || "").trim();
let status = (row["Status"] || "").trim();
const sendOn = (row["Send Payment On"] || "").trim();
const rawSendOn = (row["Send Payment On"] || "").trim();
// Reject rows with unparseable/implausible dates rather than storing
// garbage (Stampli switched MM/DD/YYYY -> M/D/YY once already; a future
// format change must fail loudly, not corrupt issueDate/aging). Blank is
// allowed — canceled payments legitimately have no send date.
const sendOn = rawSendOn ? toCanonicalMDY(rawSendOn) : "";
if (rawSendOn && (sendOn === null || !isPlausibleSendYear(rawSendOn))) {
rejectedRows.push({ checkNumber, field: "Send Payment On", value: rawSendOn });
continue;
}
// ACH payments clear automatically on their send date
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
@ -319,34 +329,47 @@ export const handler = async (event) => {
}
}
// Stampli blanks "Amount in USD" (and can blank dates) on canceled rows.
// Never let a blank overwrite a previously stored real value; fall back
// to the "Amount" column, which keeps its value on cancels.
const usdAmount = parseAmount(row["Amount in USD"]);
const amount = usdAmount > 0 ? usdAmount : parseAmount(row["Amount"]);
const sets = [
"#method = :method",
"payee = :payee",
"check_number = :check_number",
"invoice_numbers = :invoice_numbers",
"#status = :status",
"company_subsidiary = :company_subsidiary",
];
const values = {
":method": method,
":payee": (row["Payee"] || "").trim(),
":check_number": checkNumber,
":invoice_numbers": (row["Invoice Numbers"] || "").trim(),
":status": status,
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
};
if (amount > 0 || !existing) {
sets.push("amount_usd = :amount_usd");
values[":amount_usd"] = amount;
}
if (sendOn || !existing) {
sets.push("send_payment_on = :send_payment_on");
values[":send_payment_on"] = sendOn;
}
await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: { pk },
UpdateExpression: `
SET #method = :method,
payee = :payee,
check_number = :check_number,
invoice_numbers = :invoice_numbers,
send_payment_on = :send_payment_on,
amount_usd = :amount_usd,
#status = :status,
company_subsidiary = :company_subsidiary
`,
UpdateExpression: `SET ${sets.join(", ")}`,
ExpressionAttributeNames: {
"#method": "method",
"#status": "status",
},
ExpressionAttributeValues: {
":method": method,
":payee": (row["Payee"] || "").trim(),
":check_number": checkNumber,
":invoice_numbers": (row["Invoice Numbers"] || "").trim(),
":send_payment_on": sendOn,
":amount_usd": parseAmount(row["Amount in USD"]),
":status": status,
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
},
ExpressionAttributeValues: values,
})
);
count++;
@ -355,22 +378,35 @@ export const handler = async (event) => {
if (method !== "Check") continue;
if (checkNumber.length > 10) continue;
// CashPro needs the real amount and issue date; canceled rows blank both,
// so fall back to the stored record's values before giving up.
const boaAmount = amount > 0 ? amount : parseAmount(existing?.amount_usd);
const boaIssueDate = toISODate(sendOn) || toISODate(existing?.send_payment_on);
if (!existing) {
if (!boaIssueDate || !(boaAmount > 0)) {
console.error(`Skipping BoA add_Issue for check ${checkNumber}: missing issue date or amount`);
continue;
}
// New check → issue
newChecks.push({
checkNumber,
amount: parseAmount(row["Amount in USD"]).toFixed(2),
issueDate: toISODate(sendOn),
amount: boaAmount.toFixed(2),
issueDate: boaIssueDate,
});
} else if (
cancelStatuses.includes(status.toLowerCase()) &&
!cancelStatuses.includes((existing.status || "").toLowerCase())
) {
if (!boaIssueDate || !(boaAmount > 0)) {
console.error(`Skipping BoA cancel_Issue for check ${checkNumber}: missing issue date or amount`);
continue;
}
// Existing check now voided/cancelled → cancel
cancelChecks.push({
checkNumber,
amount: parseAmount(row["Amount in USD"]).toFixed(2),
issueDate: toISODate(sendOn),
amount: boaAmount.toFixed(2),
issueDate: boaIssueDate,
});
}
}
@ -481,13 +517,28 @@ export const handler = async (event) => {
file_name: key.split("/").pop(),
last_updated: new Date().toISOString(),
last_file_count: count,
last_rejected_count: rejectedRows.length,
},
})
);
console.log(
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled`
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled, ${rejectedRows.length} rejected`
);
// All valid rows are processed and BoA submissions are done; now fail the
// invocation so rejects surface via the errors alarm + async DLQ. Retries
// are safe: upserts are idempotent and already-existing checks are not
// re-offered to BoA.
if (rejectedRows.length) {
for (const r of rejectedRows) {
console.error(`Rejected row: check ${r.checkNumber}, ${r.field}="${r.value}"`);
}
throw new Error(
`${rejectedRows.length} of ${normalizedRows.length} rows rejected for unparseable dates (${count} valid rows processed)`
);
}
return {
statusCode: 200,
body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`,

View file

@ -2,6 +2,7 @@ import crypto from "node:crypto";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { parseMDYLocal } from "./dates.js";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const secrets = new SecretsManagerClient();
@ -53,15 +54,6 @@ const formatCurrency = (value) =>
Number(value || 0)
);
const parseMDYLocal = (value) => {
if (!value) return null;
const parts = String(value).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
if (!mm || !dd || !yyyy) return null;
return new Date(yyyy, mm - 1, dd);
};
const formatDisplayDate = (date) =>
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });