mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-10-02 21:33:54 +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 { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||||
import { parse } from "csv-parse/sync";
|
import { parse } from "csv-parse/sync";
|
||||||
|
import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js";
|
||||||
|
|
||||||
const s3 = new S3Client();
|
const s3 = new S3Client();
|
||||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
|
|
@ -39,14 +40,6 @@ async function getAccessToken(applicationID, clientId, clientSecret) {
|
||||||
return data.access_token;
|
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() {
|
async function backfillBoA() {
|
||||||
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
||||||
|
|
||||||
|
|
@ -84,10 +77,16 @@ async function backfillBoA() {
|
||||||
if (submitted.has(item.check_number)) continue;
|
if (submitted.has(item.check_number)) continue;
|
||||||
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
|
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
|
||||||
if (item.check_number.length > 10) 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({
|
toSubmit.push({
|
||||||
checkNumber: item.check_number,
|
checkNumber: item.check_number,
|
||||||
amount: Number(item.amount_usd).toFixed(2),
|
amount: amount.toFixed(2),
|
||||||
issueDate: toISODate(item.send_payment_on),
|
issueDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
payKey = payScan.LastEvaluatedKey;
|
payKey = payScan.LastEvaluatedKey;
|
||||||
|
|
@ -272,6 +271,7 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const newChecks = [];
|
const newChecks = [];
|
||||||
const cancelChecks = [];
|
const cancelChecks = [];
|
||||||
|
const rejectedRows = [];
|
||||||
|
|
||||||
// Upsert each payment, tracking new and cancelled checks
|
// Upsert each payment, tracking new and cancelled checks
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
@ -281,7 +281,17 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const method = (row["Method"] || "").trim();
|
const method = (row["Method"] || "").trim();
|
||||||
let status = (row["Status"] || "").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
|
// ACH payments clear automatically on their send date
|
||||||
if (method === "ACH" && !cancelStatuses.includes(status.toLowerCase())) {
|
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(
|
await ddb.send(
|
||||||
new UpdateCommand({
|
new UpdateCommand({
|
||||||
TableName: TABLE_NAME,
|
TableName: TABLE_NAME,
|
||||||
Key: { pk },
|
Key: { pk },
|
||||||
UpdateExpression: `
|
UpdateExpression: `SET ${sets.join(", ")}`,
|
||||||
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
|
|
||||||
`,
|
|
||||||
ExpressionAttributeNames: {
|
ExpressionAttributeNames: {
|
||||||
"#method": "method",
|
"#method": "method",
|
||||||
"#status": "status",
|
"#status": "status",
|
||||||
},
|
},
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: values,
|
||||||
":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(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
count++;
|
count++;
|
||||||
|
|
@ -355,22 +378,35 @@ export const handler = async (event) => {
|
||||||
if (method !== "Check") continue;
|
if (method !== "Check") continue;
|
||||||
if (checkNumber.length > 10) 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 (!existing) {
|
||||||
|
if (!boaIssueDate || !(boaAmount > 0)) {
|
||||||
|
console.error(`Skipping BoA add_Issue for check ${checkNumber}: missing issue date or amount`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// New check → issue
|
// New check → issue
|
||||||
newChecks.push({
|
newChecks.push({
|
||||||
checkNumber,
|
checkNumber,
|
||||||
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
amount: boaAmount.toFixed(2),
|
||||||
issueDate: toISODate(sendOn),
|
issueDate: boaIssueDate,
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
cancelStatuses.includes(status.toLowerCase()) &&
|
cancelStatuses.includes(status.toLowerCase()) &&
|
||||||
!cancelStatuses.includes((existing.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
|
// Existing check now voided/cancelled → cancel
|
||||||
cancelChecks.push({
|
cancelChecks.push({
|
||||||
checkNumber,
|
checkNumber,
|
||||||
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
amount: boaAmount.toFixed(2),
|
||||||
issueDate: toISODate(sendOn),
|
issueDate: boaIssueDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -481,13 +517,28 @@ export const handler = async (event) => {
|
||||||
file_name: key.split("/").pop(),
|
file_name: key.split("/").pop(),
|
||||||
last_updated: new Date().toISOString(),
|
last_updated: new Date().toISOString(),
|
||||||
last_file_count: count,
|
last_file_count: count,
|
||||||
|
last_rejected_count: rejectedRows.length,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
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 {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`,
|
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 { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
||||||
|
import { parseMDYLocal } from "./dates.js";
|
||||||
|
|
||||||
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
const secrets = new SecretsManagerClient();
|
const secrets = new SecretsManagerClient();
|
||||||
|
|
@ -53,15 +54,6 @@ const formatCurrency = (value) =>
|
||||||
Number(value || 0)
|
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) =>
|
const formatDisplayDate = (date) =>
|
||||||
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue