mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 14:43:11 +00:00
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:
parent
bb8373900b
commit
97fbe4f68f
3 changed files with 137 additions and 45 deletions
49
src/dates.js
Normal file
49
src/dates.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue