2026-04-09 13:29:28 -04:00
|
|
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
|
|
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
2026-10-01 21:39:38 -04:00
|
|
|
import {
|
|
|
|
|
DynamoDBDocumentClient,
|
|
|
|
|
GetCommand,
|
|
|
|
|
PutCommand,
|
|
|
|
|
UpdateCommand,
|
|
|
|
|
ScanCommand,
|
|
|
|
|
} from "@aws-sdk/lib-dynamodb";
|
2026-06-02 20:29:18 -04:00
|
|
|
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
|
2026-04-09 13:29:28 -04:00
|
|
|
import { parse } from "csv-parse/sync";
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
import { toISODate, toCanonicalMDY, isPlausibleSendYear } from "./dates.js";
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
const s3 = new S3Client();
|
|
|
|
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
2026-06-02 20:29:18 -04:00
|
|
|
const secrets = new SecretsManagerClient();
|
2026-04-09 13:29:28 -04:00
|
|
|
const TABLE_NAME = process.env.TABLE_NAME;
|
2026-04-09 15:14:51 -04:00
|
|
|
const BOA_BASE_URL = process.env.BOA_BASE_URL;
|
|
|
|
|
|
2026-06-02 20:29:18 -04:00
|
|
|
let cachedCreds;
|
|
|
|
|
async function getCheckMgmtCreds() {
|
|
|
|
|
if (cachedCreds) return cachedCreds;
|
|
|
|
|
const { SecretString } = await secrets.send(
|
2026-10-01 21:39:38 -04:00
|
|
|
new GetSecretValueCommand({ SecretId: process.env.BOA_CHECK_MGMT_SECRET_NAME }),
|
2026-04-09 15:14:51 -04:00
|
|
|
);
|
2026-06-02 20:29:18 -04:00
|
|
|
cachedCreds = JSON.parse(SecretString);
|
|
|
|
|
return cachedCreds;
|
2026-04-09 15:14:51 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:24:20 -04:00
|
|
|
async function getAccessToken(applicationID, clientId, clientSecret) {
|
|
|
|
|
const res = await fetch(`${BOA_BASE_URL}/authn/v1/client-authentication`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
applicationID,
|
|
|
|
|
authn: { client_id: clientId, client_secret: clientSecret },
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
throw new Error(`OAuth token exchange failed: HTTP ${res.status}`);
|
2026-04-13 16:24:20 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return data.access_token;
|
|
|
|
|
}
|
|
|
|
|
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
// Untrusted values (CSV cells, record fields) must be encoded before log
|
|
|
|
|
// interpolation — quoted CSV cells can carry newlines, which would forge
|
|
|
|
|
// CloudWatch log lines (CWE-117).
|
|
|
|
|
const logSafe = (v) => JSON.stringify(String(v ?? "").slice(0, 64));
|
|
|
|
|
|
|
|
|
|
// Comma-tolerant amount parsing, shared by the CSV path and the backfill so
|
|
|
|
|
// a hand-inserted "1,234.56" string record can't NaN out of registration.
|
|
|
|
|
const parseAmount = (value) => {
|
2026-10-01 21:39:38 -04:00
|
|
|
const num = parseFloat(
|
|
|
|
|
String(value || "0")
|
|
|
|
|
.replace(/,/g, "")
|
|
|
|
|
.trim(),
|
|
|
|
|
);
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
return isNaN(num) ? 0 : num;
|
|
|
|
|
};
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-05-07 20:44:14 -04:00
|
|
|
async function backfillBoA() {
|
|
|
|
|
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
|
|
|
|
|
|
|
|
|
// Find all successfully submitted check numbers
|
|
|
|
|
const submitted = new Set();
|
|
|
|
|
let txnKey;
|
|
|
|
|
do {
|
2026-10-01 21:39:38 -04:00
|
|
|
const txnScan = await ddb.send(
|
|
|
|
|
new ScanCommand({
|
|
|
|
|
TableName: TABLE_NAME,
|
|
|
|
|
FilterExpression: "begins_with(pk, :prefix) AND success = :t AND #action = :add",
|
|
|
|
|
ExpressionAttributeNames: { "#action": "action" },
|
|
|
|
|
ExpressionAttributeValues: { ":prefix": "boa_txn#", ":t": true, ":add": "add_Issue" },
|
|
|
|
|
ProjectionExpression: "check_numbers",
|
|
|
|
|
...(txnKey && { ExclusiveStartKey: txnKey }),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
for (const item of txnScan.Items || []) {
|
|
|
|
|
for (const cn of item.check_numbers || []) submitted.add(cn);
|
|
|
|
|
}
|
|
|
|
|
txnKey = txnScan.LastEvaluatedKey;
|
|
|
|
|
} while (txnKey);
|
|
|
|
|
|
|
|
|
|
// Find all Check-method records not yet submitted
|
|
|
|
|
const toSubmit = [];
|
|
|
|
|
let payKey;
|
|
|
|
|
do {
|
2026-10-01 21:39:38 -04:00
|
|
|
const payScan = await ddb.send(
|
|
|
|
|
new ScanCommand({
|
|
|
|
|
TableName: TABLE_NAME,
|
|
|
|
|
FilterExpression: "begins_with(pk, :prefix) AND #method = :check",
|
|
|
|
|
ExpressionAttributeNames: { "#method": "method", "#status": "status" },
|
|
|
|
|
ExpressionAttributeValues: { ":prefix": "payment#", ":check": "Check" },
|
|
|
|
|
ProjectionExpression: "check_number, amount_usd, send_payment_on, #status",
|
|
|
|
|
...(payKey && { ExclusiveStartKey: payKey }),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
for (const item of payScan.Items || []) {
|
|
|
|
|
if (submitted.has(item.check_number)) continue;
|
|
|
|
|
if (cancelStatuses.includes((item.status || "").toLowerCase())) continue;
|
|
|
|
|
if (item.check_number.length > 10) continue;
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
const issueDate = toISODate(item.send_payment_on);
|
|
|
|
|
const amount = parseAmount(item.amount_usd);
|
|
|
|
|
if (!issueDate || !(amount > 0)) {
|
2026-10-01 21:39:38 -04:00
|
|
|
console.error(
|
|
|
|
|
`Backfill skipping check ${logSafe(item.check_number)}: missing issue date or amount`,
|
|
|
|
|
);
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-07 20:44:14 -04:00
|
|
|
toSubmit.push({
|
|
|
|
|
checkNumber: item.check_number,
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
amount: amount.toFixed(2),
|
|
|
|
|
issueDate,
|
2026-05-07 20:44:14 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
payKey = payScan.LastEvaluatedKey;
|
|
|
|
|
} while (payKey);
|
|
|
|
|
|
|
|
|
|
if (!toSubmit.length) {
|
|
|
|
|
console.log("Backfill: no unsubmitted checks found");
|
|
|
|
|
return { statusCode: 200, body: "No checks to backfill" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Backfill: ${toSubmit.length} checks to submit`);
|
|
|
|
|
|
2026-10-01 21:39:38 -04:00
|
|
|
const {
|
|
|
|
|
appId,
|
|
|
|
|
clientId,
|
|
|
|
|
token: clientSecret,
|
|
|
|
|
accountNumber,
|
|
|
|
|
companyId,
|
|
|
|
|
} = await getCheckMgmtCreds();
|
2026-05-07 20:44:14 -04:00
|
|
|
|
|
|
|
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
|
|
|
|
const BOA_BATCH_SIZE = 100;
|
|
|
|
|
const ALREADY_SUBMITTED = new Set(["10096", "10097", "10098"]);
|
|
|
|
|
let totalProcessed = 0;
|
|
|
|
|
let totalSkippedDuplicates = 0;
|
|
|
|
|
|
|
|
|
|
const submitBatch = async (items, label) => {
|
|
|
|
|
const issueList = items.map((item) => ({
|
|
|
|
|
accountNumber,
|
|
|
|
|
issueAction: "add_Issue",
|
|
|
|
|
checkNumber: item.checkNumber,
|
|
|
|
|
amount: item.amount,
|
|
|
|
|
issueDate: item.issueDate,
|
|
|
|
|
payee: "",
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const timestamp = new Date().toISOString();
|
2026-10-01 21:39:38 -04:00
|
|
|
const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${bearerToken}`,
|
|
|
|
|
companyId,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ issueList }),
|
|
|
|
|
});
|
2026-05-07 20:44:14 -04:00
|
|
|
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
const headers = Object.fromEntries(res.headers.entries());
|
|
|
|
|
const transactionId =
|
2026-10-01 21:39:38 -04:00
|
|
|
headers["transactionid"] ||
|
|
|
|
|
headers["x-transactionid"] ||
|
|
|
|
|
headers["x-correlation-id"] ||
|
|
|
|
|
headers["x-cashpro-transaction-id"] ||
|
|
|
|
|
null;
|
2026-05-07 20:44:14 -04:00
|
|
|
let data = {};
|
|
|
|
|
let parseError = null;
|
|
|
|
|
if (text.trim()) {
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(text);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
parseError = err;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
parseError = new Error("BoA returned an empty response body");
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 21:39:38 -04:00
|
|
|
await ddb.send(
|
|
|
|
|
new PutCommand({
|
|
|
|
|
TableName: TABLE_NAME,
|
|
|
|
|
Item: {
|
|
|
|
|
pk: `boa_txn#${timestamp}#add_Issue`,
|
|
|
|
|
timestamp,
|
|
|
|
|
action: "add_Issue",
|
|
|
|
|
backfill: true,
|
|
|
|
|
http_status: res.status,
|
|
|
|
|
transaction_id: transactionId,
|
|
|
|
|
check_numbers: items.map((i) => i.checkNumber),
|
|
|
|
|
total_amount: items.reduce((sum, i) => sum + parseFloat(i.amount), 0).toFixed(2),
|
|
|
|
|
success: res.ok,
|
|
|
|
|
processed_items: data.processedItems || 0,
|
|
|
|
|
total_items: data.totalItems || 0,
|
|
|
|
|
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
|
|
|
|
|
if (res.ok && parseError) {
|
|
|
|
|
throw new Error(`Backfill ${label} failed: BoA returned invalid JSON (HTTP ${res.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 17:04:35 -04:00
|
|
|
return { ok: res.ok, status: res.status, data, text, parseError, transactionId };
|
2026-05-07 20:44:14 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < toSubmit.length; i += BOA_BATCH_SIZE) {
|
|
|
|
|
const batch = toSubmit.slice(i, i + BOA_BATCH_SIZE);
|
|
|
|
|
const batchNum = Math.floor(i / BOA_BATCH_SIZE) + 1;
|
|
|
|
|
console.log(`Backfill batch ${batchNum}: ${batch.length} items`);
|
|
|
|
|
|
|
|
|
|
const result = await submitBatch(batch, `batch ${batchNum}`);
|
|
|
|
|
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
totalProcessed += result.data.processedItems;
|
|
|
|
|
console.log(`Backfill batch ${batchNum}: ${result.data.processedItems} processed`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse the error to separate duplicates from genuinely new items
|
|
|
|
|
const dupeCheckNumbers = new Set();
|
|
|
|
|
for (const item of result.data?.issueList || []) {
|
|
|
|
|
if (ALREADY_SUBMITTED.has(String(item.status))) {
|
|
|
|
|
dupeCheckNumbers.add(item.checkNumber);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dupeCheckNumbers.size === 0) {
|
|
|
|
|
const detail = result.parseError
|
|
|
|
|
? "BoA returned a non-JSON response"
|
|
|
|
|
: "BoA returned a non-duplicate error";
|
|
|
|
|
throw new Error(`Backfill batch ${batchNum} failed: ${detail} (HTTP ${result.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalSkippedDuplicates += dupeCheckNumbers.size;
|
|
|
|
|
const retryItems = batch.filter((item) => !dupeCheckNumbers.has(item.checkNumber));
|
2026-10-01 21:39:38 -04:00
|
|
|
console.log(
|
|
|
|
|
`Backfill batch ${batchNum}: ${dupeCheckNumbers.size} already in BoA, ${retryItems.length} to retry`,
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
|
|
|
|
|
if (retryItems.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
const retryResult = await submitBatch(retryItems, `batch ${batchNum} retry`);
|
|
|
|
|
if (!retryResult.ok) {
|
2026-07-10 17:04:35 -04:00
|
|
|
// Do not log/throw the raw BoA response body — it can carry account
|
|
|
|
|
// numbers/PII. Status + txnId are enough to triage; the full body is
|
|
|
|
|
// retrievable from BoA support via txnId.
|
2026-05-07 20:44:14 -04:00
|
|
|
const detail = retryResult.parseError
|
|
|
|
|
? "BoA returned a non-JSON response"
|
2026-07-10 17:04:35 -04:00
|
|
|
: "BoA returned a non-duplicate error";
|
2026-10-01 21:39:38 -04:00
|
|
|
console.error(
|
|
|
|
|
`Backfill batch ${batchNum} retry failed: HTTP ${retryResult.status}, txnId=${retryResult.transactionId}`,
|
|
|
|
|
);
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Backfill batch ${batchNum} retry failed: ${detail} (HTTP ${retryResult.status})`,
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
}
|
|
|
|
|
totalProcessed += retryResult.data.processedItems;
|
|
|
|
|
console.log(`Backfill batch ${batchNum} retry: ${retryResult.data.processedItems} processed`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 21:39:38 -04:00
|
|
|
console.log(
|
|
|
|
|
`Backfill complete: ${totalProcessed} submitted, ${totalSkippedDuplicates} already in BoA`,
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
body: `Backfilled ${totalProcessed} checks, ${totalSkippedDuplicates} already in BoA`,
|
|
|
|
|
};
|
2026-05-07 20:44:14 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 13:29:28 -04:00
|
|
|
export const handler = async (event) => {
|
2026-05-07 20:44:14 -04:00
|
|
|
if (event.backfill) return backfillBoA();
|
|
|
|
|
|
2026-04-09 13:29:28 -04:00
|
|
|
const record = event.Records[0];
|
|
|
|
|
const bucket = record.s3.bucket.name;
|
|
|
|
|
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
|
|
|
|
|
|
|
|
|
|
// Download CSV from S3
|
|
|
|
|
const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
|
|
|
const csvText = await Body.transformToString("utf-8");
|
|
|
|
|
|
|
|
|
|
// Parse CSV
|
|
|
|
|
const rows = parse(csvText, {
|
|
|
|
|
columns: true,
|
|
|
|
|
skip_empty_lines: true,
|
|
|
|
|
trim: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const normalizedRows = rows.map((row) => {
|
|
|
|
|
const clean = {};
|
|
|
|
|
for (const [k, v] of Object.entries(row)) {
|
|
|
|
|
clean[String(k).trim()] = typeof v === "string" ? v.trim() : v;
|
|
|
|
|
}
|
|
|
|
|
return clean;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 17:31:27 -04:00
|
|
|
const cancelStatuses = ["voided", "cancelled", "canceled", "marked as void"];
|
2026-04-14 17:43:13 -04:00
|
|
|
|
|
|
|
|
// Status progression ranks — higher number = further along in lifecycle
|
|
|
|
|
// Once a payment reaches a higher rank, CSV cannot move it backward
|
|
|
|
|
const statusRank = {
|
2026-10-01 21:39:38 -04:00
|
|
|
scheduled: 1,
|
2026-04-14 17:43:13 -04:00
|
|
|
"payment submitted": 2,
|
2026-10-01 21:39:38 -04:00
|
|
|
issued: 3,
|
|
|
|
|
outstanding: 4,
|
|
|
|
|
cleared: 5,
|
2026-04-14 17:43:13 -04:00
|
|
|
};
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
const newChecks = [];
|
|
|
|
|
const cancelChecks = [];
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
const rejectedRows = [];
|
2026-04-09 15:14:51 -04:00
|
|
|
|
|
|
|
|
// Upsert each payment, tracking new and cancelled checks
|
2026-04-09 14:59:39 -04:00
|
|
|
let count = 0;
|
|
|
|
|
for (const row of normalizedRows) {
|
|
|
|
|
const checkNumber = (row["Check Number"] || "").trim();
|
2026-04-10 17:23:54 -04:00
|
|
|
if (!checkNumber) continue;
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
const method = (row["Method"] || "").trim();
|
2026-04-10 17:31:27 -04:00
|
|
|
let status = (row["Status"] || "").trim();
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-10 17:31:27 -04:00
|
|
|
|
feat(fetchboa): bank-truth reconciliation v2 — returns, redeposits, ACH confirmation (#73)
* feat(fetchboa): v2 return/redeposit-aware reconciliation (#66)
The two-code 475/255 filter missed every return on the Jan-Jul statement
(29 ARP refer-to-maker credits, 24 electronic return credits, and the
redeposit cycles), leaving 5 paid-then-returned checks stuck at Cleared
($5,256.62, vendors unpaid). Rewrite fetchBoaTransactions around a pure
reconciliation module (src/boaRecon.js) covered by node:test:
- BAI transaction-code event map (only 475 = check paid is confirmed;
remaining codes pend the enumeration replay) with a description-text
fallback classifier for ARP refer-to-maker, return-of-posted-check
(check-numbered and electronic) and ACH DES:PAYMENTS formats. Unknown
check-shaped transactions are logged and counted, never dropped.
- Matching on check number AND amount over all candidates; wrong-amount
or collapsed-number postings fall back to a unique exact-amount match
within checks issued in the last 120 days; ambiguity means unmatched
with no write.
- Transitions always set BOTH status and clear_status (the missed
returns slipped through the divergence between them). clear_status is
bank truth: returns apply even to Cleared records, a second paid debit
on a Returned check is a redeposit back to Cleared, and a return on a
voided check is terminal voided-and-bounced. Every applied event is
appended to a history list; identical replayed events are noops.
- Optional {fromDate, toDate} replay payload (strictly validated) for
gap replays and the BAI-code enumeration runs; default stays
yesterday.
- Each run writes a boa_recon#<runDate> summary item (90-day TTL) with
per-event counts, unmatched check numbers/amounts, and unknown codes;
unmatched/unknown also console.error (Slack alerting is #71).
ACH transactions are classified but not yet acted on; bank-confirming
ACH lands with #69.
* feat(ach): bank-confirm ACH, drop CSV-date auto-clear (#69)
processPaymentCsv auto-cleared ACH the moment send_payment_on passed,
but the bank disagrees often enough to matter: settlement lags the send
date by up to 8 days, settled ACH can bounce and re-debit (Uline
$19,281.12, Heights $10,000.00 on the 5/26 overdrawn week), and voided
ACH that settled anyway returned via electronic credits invisible to a
Check-only feed. Move ACH to bank confirmation:
- processPaymentCsv: ACH rows keep their Stampli status; the send-date
auto-clear is removed. The bankConfirmed protection is unchanged, so
bank-cleared records still cannot be moved backward by the CSV.
- fetchBoaTransactions scans all payments (method filter dropped) and
processes ACH CCD lines: match by stored pmt_id first (reversals
reuse the original PMT id), then by the Stampli payment number
embedded in PMT INFO (internal spaces stripped, amount must agree),
then vendor + exact amount within send_payment_on -2..+14 days.
Settled debit -> Cleared/Cleared with dates, pmt_id persisted on the
record; credit reusing the pmt_id (or a unique same-amount return
credit against a bank-confirmed ACH) -> clear_status=Returned +
returned_date + history event. Ambiguity is unmatched, no write.
Existing DDB ACH records already Cleared by the old auto-clear are
unaffected: bank confirmation is additive and the CSV path never moves
a record backward.
Handler event contract extended (optional fromDate/toDate); cross-family
review required before merge.
* fix(fetchboa): close review findings — coverage gap, matcher corroboration, replay identity (#66)
Security-review gate (detector fan-out + verifier + GPT-4.1 cross-family)
blocked on F6 and confirmed six lower findings; all are closed here.
- F6 (HIGH): the default run now queries a trailing 3-day window
(today-3..today-1) so the Monday run covers Friday-Sunday postings the
previous-day cadence silently skipped. A per-run staleness sweep flags
never-bank-confirmed payments (ACH > 16 days, checks > 60 days) in the
summary and via console.error.
- F2: replay identity is {event, date, amount} — bankRef excluded (feeds
omit/reformat it between runs); a paid event dated on/before the
latest return in history is a replayed original, never a redeposit;
rows without a valid valueDate are routed to unmatched instead of
being applied under a substituted date; within a day, debits sort
before credits.
- F1: a check-number match with the wrong amount no longer falls
through to the amount fallback (altered-check/collapsed-posting is a
human-review case); the amount fallback requires digit-subsequence
corroboration between posting and candidate numbers; check_return
fallback requires a bank-confirmed candidate.
- F4: the ach_return amount fallback requires cleared_date within 45
days before the credit; an unattributable PMT id is an unmatched
alert, never an amount guess.
- F5: the pmt_id rung requires amount equality; mismatch is the
partial-reversal human case.
- F3: vendor prefix matching requires >= 10 normalized chars (short
names must match exactly); candidates with a conflicting stored
pmt_id are excluded; vendor+amount matches are audit-logged.
- F7: payment writes are conditioned on the snapshot's
status/clear_status and retried once against a fresh read; residual
conflicts are counted, not silently interleaved.
- F9/summary bounds: run summary pk is append-only
(boa_recon#<from>_<to>#<runAt>); unmatched list capped at 50, unknown
codes at 20 keys/16 chars, stale list at 50 (full counts kept).
- ReDoS: description bounded to 500 chars before classification;
CHECK/embedded-number regexes use bounded quantifiers.
- FBOA2-1: both BoA res.json() parses are wrapped so a malformed 200
throws a status-only error and cannot leak a body snippet.
Handler event contract extended (optional fromDate/toDate); cross-family
review required before merge.
* fix(boaRecon): add method=Check filter to matchElectronicReturn
Electronic returns only apply to checks, but the matcher was not
filtering by method, so an electronic return could incorrectly
match against a same-amount, bank-confirmed ACH record.
2026-07-21 22:07:37 -04:00
|
|
|
// ACH rows keep their Stampli status — the bank confirms settlement via
|
|
|
|
|
// fetchBoaTransactions (#69). The old send-date auto-clear marked ACH
|
|
|
|
|
// Cleared days before the debit settled (and past bounced settlements
|
|
|
|
|
// that re-debited later were invisible to it).
|
2026-04-10 17:31:27 -04:00
|
|
|
|
2026-04-09 14:59:39 -04:00
|
|
|
const pk = `payment#${checkNumber}`;
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
// Check if record already exists (for detecting new vs updated)
|
|
|
|
|
const { Item: existing } = await ddb.send(
|
2026-10-01 21:39:38 -04:00
|
|
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk } }),
|
2026-04-09 15:14:51 -04:00
|
|
|
);
|
|
|
|
|
|
2026-04-14 17:43:13 -04:00
|
|
|
// Don't overwrite status once the bank has confirmed it as Cleared
|
|
|
|
|
const bankConfirmed = existing?.clear_status === "Cleared";
|
|
|
|
|
if (bankConfirmed) {
|
|
|
|
|
status = "Cleared";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Status progression protection — never allow status to move backward
|
|
|
|
|
if (existing) {
|
|
|
|
|
const oldRank = statusRank[(existing.status || "").toLowerCase()] || 0;
|
|
|
|
|
const newRank = statusRank[status.toLowerCase()] || 0;
|
|
|
|
|
|
|
|
|
|
if (oldRank === 5) {
|
|
|
|
|
// Cleared is permanent — cannot be voided, cancelled, or anything else
|
|
|
|
|
status = existing.status;
|
|
|
|
|
} else if (newRank < oldRank && !cancelStatuses.includes(status.toLowerCase())) {
|
|
|
|
|
// Non-cancel status regression — keep the existing (higher) status
|
|
|
|
|
status = existing.status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
// 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"]);
|
|
|
|
|
|
|
|
|
|
// Decide the BoA action BEFORE writing DDB. If a row demands a BoA call
|
|
|
|
|
// we cannot make (no resolvable amount/issue date), reject it with the
|
|
|
|
|
// record untouched — writing first would flip the status and permanently
|
|
|
|
|
// close the transition gate, silently losing the cancel. First-seen rows
|
|
|
|
|
// that are already canceled are stored but never registered: add_Issue
|
|
|
|
|
// for a voided check would create an active issue with no cancel to follow.
|
|
|
|
|
const isCancelRow = cancelStatuses.includes(status.toLowerCase());
|
|
|
|
|
const boaEligible = method === "Check" && checkNumber.length <= 10;
|
|
|
|
|
const boaAmount = amount > 0 ? amount : parseAmount(existing?.amount_usd);
|
|
|
|
|
const boaIssueDate = toISODate(sendOn) || toISODate(existing?.send_payment_on);
|
|
|
|
|
const needsAdd = boaEligible && !existing && !isCancelRow;
|
|
|
|
|
const needsCancel =
|
|
|
|
|
boaEligible &&
|
|
|
|
|
existing &&
|
|
|
|
|
isCancelRow &&
|
|
|
|
|
!cancelStatuses.includes((existing.status || "").toLowerCase());
|
|
|
|
|
if ((needsAdd || needsCancel) && (!boaIssueDate || !(boaAmount > 0))) {
|
|
|
|
|
rejectedRows.push({
|
|
|
|
|
checkNumber,
|
|
|
|
|
field: needsAdd ? "add_Issue data" : "cancel_Issue data",
|
|
|
|
|
value: `issueDate=${boaIssueDate}, amount=${boaAmount}`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 14:27:34 -04:00
|
|
|
await ddb.send(
|
2026-04-09 14:59:39 -04:00
|
|
|
new UpdateCommand({
|
|
|
|
|
TableName: TABLE_NAME,
|
|
|
|
|
Key: { pk },
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
UpdateExpression: `SET ${sets.join(", ")}`,
|
2026-04-09 14:59:39 -04:00
|
|
|
ExpressionAttributeNames: {
|
|
|
|
|
"#method": "method",
|
|
|
|
|
"#status": "status",
|
|
|
|
|
},
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
ExpressionAttributeValues: values,
|
2026-10-01 21:39:38 -04:00
|
|
|
}),
|
2026-04-09 14:27:34 -04:00
|
|
|
);
|
2026-04-09 14:59:39 -04:00
|
|
|
count++;
|
2026-04-09 15:14:51 -04:00
|
|
|
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
if (needsAdd) {
|
2026-04-09 15:14:51 -04:00
|
|
|
newChecks.push({
|
|
|
|
|
checkNumber,
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
amount: boaAmount.toFixed(2),
|
|
|
|
|
issueDate: boaIssueDate,
|
2026-04-09 15:14:51 -04:00
|
|
|
});
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
} else if (needsCancel) {
|
2026-04-09 15:14:51 -04:00
|
|
|
cancelChecks.push({
|
|
|
|
|
checkNumber,
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
amount: boaAmount.toFixed(2),
|
|
|
|
|
issueDate: boaIssueDate,
|
2026-04-09 15:14:51 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Submit to CashPro if there are any new issues or cancels
|
|
|
|
|
if (newChecks.length || cancelChecks.length) {
|
2026-10-01 21:39:38 -04:00
|
|
|
const {
|
|
|
|
|
appId,
|
|
|
|
|
clientId,
|
|
|
|
|
token: clientSecret,
|
|
|
|
|
accountNumber,
|
|
|
|
|
companyId,
|
|
|
|
|
} = await getCheckMgmtCreds();
|
2026-04-09 15:14:51 -04:00
|
|
|
|
2026-04-13 16:24:20 -04:00
|
|
|
const bearerToken = await getAccessToken(appId, clientId, clientSecret);
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
const submitToBoA = async (items, action) => {
|
|
|
|
|
const issueList = items.map((item) => ({
|
|
|
|
|
accountNumber,
|
2026-04-13 16:24:20 -04:00
|
|
|
issueAction: action,
|
2026-04-09 15:14:51 -04:00
|
|
|
checkNumber: item.checkNumber,
|
|
|
|
|
amount: item.amount,
|
|
|
|
|
issueDate: item.issueDate,
|
2026-04-20 17:40:55 -04:00
|
|
|
payee: "",
|
2026-04-09 15:14:51 -04:00
|
|
|
}));
|
|
|
|
|
|
2026-04-20 17:40:55 -04:00
|
|
|
const timestamp = new Date().toISOString();
|
|
|
|
|
const checkNumbers = items.map((i) => i.checkNumber);
|
|
|
|
|
const totalAmount = items.reduce((sum, i) => sum + parseFloat(i.amount), 0);
|
|
|
|
|
|
2026-10-01 21:39:38 -04:00
|
|
|
const res = await fetch(`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${bearerToken}`,
|
|
|
|
|
companyId,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ issueList }),
|
|
|
|
|
});
|
2026-04-09 15:14:51 -04:00
|
|
|
|
2026-04-14 20:15:41 -04:00
|
|
|
const text = await res.text();
|
2026-04-20 17:40:55 -04:00
|
|
|
const headers = Object.fromEntries(res.headers.entries());
|
|
|
|
|
const transactionId =
|
|
|
|
|
headers["transactionid"] ||
|
|
|
|
|
headers["x-transactionid"] ||
|
|
|
|
|
headers["x-correlation-id"] ||
|
|
|
|
|
headers["x-cashpro-transaction-id"] ||
|
|
|
|
|
null;
|
|
|
|
|
|
|
|
|
|
const record = {
|
|
|
|
|
pk: `boa_txn#${timestamp}#${action}`,
|
|
|
|
|
timestamp,
|
|
|
|
|
action,
|
|
|
|
|
http_status: res.status,
|
|
|
|
|
transaction_id: transactionId,
|
|
|
|
|
check_numbers: checkNumbers,
|
|
|
|
|
total_amount: totalAmount.toFixed(2),
|
|
|
|
|
ttl: Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60,
|
|
|
|
|
};
|
2026-04-09 15:14:51 -04:00
|
|
|
|
|
|
|
|
if (!res.ok) {
|
2026-07-10 17:04:35 -04:00
|
|
|
// Do not log the raw response body/headers — the BoA CashPro response
|
|
|
|
|
// can carry account numbers/PII. Status + correlation id are enough to
|
|
|
|
|
// triage; the full body is retrievable from BoA support via txnId.
|
|
|
|
|
console.error(`BoA ${action} failed: HTTP ${res.status}, txnId=${transactionId}`);
|
2026-04-20 17:40:55 -04:00
|
|
|
record.success = false;
|
|
|
|
|
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record }));
|
2026-04-09 15:14:51 -04:00
|
|
|
throw new Error(`BoA ${action} failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:15:41 -04:00
|
|
|
const data = JSON.parse(text);
|
|
|
|
|
|
2026-04-20 17:40:55 -04:00
|
|
|
record.success = true;
|
|
|
|
|
record.processed_items = data.processedItems;
|
|
|
|
|
record.total_items = data.totalItems;
|
|
|
|
|
record.unprocessed_items = data.unprocessedItems;
|
|
|
|
|
record.message = data.issueList?.[0]?.message || null;
|
|
|
|
|
await ddb.send(new PutCommand({ TableName: TABLE_NAME, Item: record }));
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
console.log(
|
2026-10-01 21:39:38 -04:00
|
|
|
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed, txnId=${transactionId}`,
|
2026-04-09 15:14:51 -04:00
|
|
|
);
|
|
|
|
|
return data;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-07 20:44:14 -04:00
|
|
|
const BOA_BATCH_SIZE = 100;
|
|
|
|
|
|
|
|
|
|
const submitInBatches = async (items, action) => {
|
|
|
|
|
for (let i = 0; i < items.length; i += BOA_BATCH_SIZE) {
|
|
|
|
|
const batch = items.slice(i, i + BOA_BATCH_SIZE);
|
2026-10-01 21:39:38 -04:00
|
|
|
console.log(
|
|
|
|
|
`BoA ${action} batch ${Math.floor(i / BOA_BATCH_SIZE) + 1}: ${batch.length} items`,
|
|
|
|
|
);
|
2026-05-07 20:44:14 -04:00
|
|
|
await submitToBoA(batch, action);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
if (newChecks.length) {
|
2026-05-07 20:44:14 -04:00
|
|
|
await submitInBatches(newChecks, "add_Issue");
|
2026-04-09 15:14:51 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cancelChecks.length) {
|
2026-05-07 20:44:14 -04:00
|
|
|
await submitInBatches(cancelChecks, "cancel_Issue");
|
2026-04-09 15:14:51 -04:00
|
|
|
}
|
2026-04-09 14:27:34 -04:00
|
|
|
}
|
2026-04-09 13:29:28 -04:00
|
|
|
|
2026-04-09 14:27:34 -04:00
|
|
|
// Update metadata record
|
2026-04-09 13:29:28 -04:00
|
|
|
await ddb.send(
|
|
|
|
|
new PutCommand({
|
|
|
|
|
TableName: TABLE_NAME,
|
|
|
|
|
Item: {
|
2026-04-09 14:27:34 -04:00
|
|
|
pk: "metadata",
|
|
|
|
|
file_name: key.split("/").pop(),
|
|
|
|
|
last_updated: new Date().toISOString(),
|
2026-04-09 14:59:39 -04:00
|
|
|
last_file_count: count,
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
last_rejected_count: rejectedRows.length,
|
2026-04-09 13:29:28 -04:00
|
|
|
},
|
2026-10-01 21:39:38 -04:00
|
|
|
}),
|
2026-04-09 13:29:28 -04:00
|
|
|
);
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
console.log(
|
2026-10-01 21:39:38 -04:00
|
|
|
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled, ${rejectedRows.length} rejected`,
|
2026-04-09 15:14:51 -04:00
|
|
|
);
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
|
|
|
|
|
// 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) {
|
2026-10-01 21:39:38 -04:00
|
|
|
console.error(
|
|
|
|
|
`Rejected row: check ${logSafe(r.checkNumber)}, ${r.field}=${logSafe(r.value)}`,
|
|
|
|
|
);
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
}
|
|
|
|
|
throw new Error(
|
2026-10-01 21:39:38 -04:00
|
|
|
`${rejectedRows.length} of ${normalizedRows.length} rows rejected (bad dates or missing BoA data; ${count} valid rows processed)`,
|
fix(csv): M/D/YY date parsing with loud rejects; stop canceled rows zeroing stored fields (#72)
* 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)
* fix(csv): validate BoA registration data before writes; harden logging (security review)
Hardening from the /sh-security-review pass on this branch:
- BoA eligibility (resolvable amount + issue date) is now decided and
validated BEFORE the DDB upsert: a cancel-transition row we cannot act
on is rejected with the record untouched, so the transition gate stays
open for a later clean file instead of silently losing the cancel
forever (the skip guard previously ran after the status write)
- First-seen rows that are already canceled are stored but never
offered to add_Issue — registering a voided check as an active issue
created a live issue with no cancel to follow (worsened by the Amount
fallback making the previously-failing call succeed)
- isPlausibleSendYear window is now relative (year-7..year+2) instead
of hardcoded 2020-2035
- Untrusted CSV values are JSON-encoded and truncated before log
interpolation (quoted CSV cells carry newlines -> CloudWatch log-line
forgery, CWE-117)
- OAuth token-exchange failures no longer throw the raw BoA authn
response body (aligns with the PR #63 log-scrub posture)
Verified: unit smoke on the date module; full replay of the real
2026-07-21 export (1,197 rows) against live table state produces 1,197
upserts, 0 rejects, 0 spurious BoA submissions.
* fix(csv): comma-tolerant amount parsing in backfill (PR #72 review)
Number() on a hand-inserted comma-formatted string amount would NaN and
skip the check during backfill; hoist the CSV path's parseAmount to
module scope and share it. No live records are affected (all amount_usd
values are DDB Number type) — defensive consistency.
2026-07-21 20:28:13 -04:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
return {
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`,
|
|
|
|
|
};
|
2026-04-09 13:29:28 -04:00
|
|
|
};
|