2026-04-09 13:29:28 -04:00
|
|
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|
|
|
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
2026-04-09 15:14:51 -04:00
|
|
|
import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
|
|
|
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
|
2026-04-09 13:29:28 -04:00
|
|
|
import { parse } from "csv-parse/sync";
|
|
|
|
|
|
|
|
|
|
const s3 = new S3Client();
|
|
|
|
|
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
2026-04-09 15:14:51 -04:00
|
|
|
const ssm = new SSMClient();
|
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;
|
|
|
|
|
|
|
|
|
|
async function getSSMParam(name) {
|
|
|
|
|
const { Parameter } = await ssm.send(
|
|
|
|
|
new GetParameterCommand({ Name: name, WithDecryption: true })
|
|
|
|
|
);
|
|
|
|
|
return Parameter.Value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert MM/DD/YYYY to YYYY-MM-DD
|
|
|
|
|
function toISODate(mdyDate) {
|
|
|
|
|
const parts = String(mdyDate).split("/");
|
|
|
|
|
if (parts.length !== 3) return null;
|
|
|
|
|
const [mm, dd, yyyy] = parts;
|
|
|
|
|
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
|
|
|
|
|
}
|
2026-04-09 13:29:28 -04:00
|
|
|
|
|
|
|
|
export const handler = async (event) => {
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parseAmount = (value) => {
|
|
|
|
|
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
|
|
|
|
|
return isNaN(num) ? 0 : num;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
const cancelStatuses = ["voided", "cancelled"];
|
|
|
|
|
const newChecks = [];
|
|
|
|
|
const cancelChecks = [];
|
|
|
|
|
|
|
|
|
|
// 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-09 15:14:51 -04:00
|
|
|
const method = (row["Method"] || "").trim();
|
|
|
|
|
const status = (row["Status"] || "").trim();
|
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(
|
|
|
|
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk } })
|
|
|
|
|
);
|
|
|
|
|
|
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 },
|
|
|
|
|
UpdateExpression: `
|
|
|
|
|
SET #method = :method,
|
|
|
|
|
payee = :payee,
|
|
|
|
|
check_number = :check_number,
|
|
|
|
|
invoice_numbers = :invoice_numbers,
|
|
|
|
|
send_payment_on = :send_payment_on,
|
|
|
|
|
amount_usd = :amount_usd,
|
|
|
|
|
#status = :status,
|
|
|
|
|
company_subsidiary = :company_subsidiary
|
|
|
|
|
`,
|
|
|
|
|
ExpressionAttributeNames: {
|
|
|
|
|
"#method": "method",
|
|
|
|
|
"#status": "status",
|
|
|
|
|
},
|
|
|
|
|
ExpressionAttributeValues: {
|
2026-04-09 15:14:51 -04:00
|
|
|
":method": method,
|
2026-04-09 14:59:39 -04:00
|
|
|
":payee": (row["Payee"] || "").trim(),
|
|
|
|
|
":check_number": checkNumber,
|
|
|
|
|
":invoice_numbers": (row["Invoice Numbers"] || "").trim(),
|
|
|
|
|
":send_payment_on": (row["Send Payment On"] || "").trim(),
|
|
|
|
|
":amount_usd": parseAmount(row["Amount in USD"]),
|
2026-04-09 15:14:51 -04:00
|
|
|
":status": status,
|
2026-04-09 14:59:39 -04:00
|
|
|
":company_subsidiary": (row["Company/Subsidiary"] || "").trim(),
|
|
|
|
|
},
|
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
|
|
|
|
|
|
|
|
// Only process checks for CashPro
|
|
|
|
|
if (method !== "Check" || !checkNumber) continue;
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
// New check → issue
|
|
|
|
|
newChecks.push({
|
|
|
|
|
checkNumber,
|
|
|
|
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
|
|
|
|
issueDate: toISODate(row["Send Payment On"] || ""),
|
|
|
|
|
});
|
|
|
|
|
} else if (
|
|
|
|
|
cancelStatuses.includes(status.toLowerCase()) &&
|
|
|
|
|
!cancelStatuses.includes((existing.status || "").toLowerCase())
|
|
|
|
|
) {
|
|
|
|
|
// Existing check now voided/cancelled → cancel
|
|
|
|
|
cancelChecks.push({
|
|
|
|
|
checkNumber,
|
|
|
|
|
amount: parseAmount(row["Amount in USD"]).toFixed(2),
|
|
|
|
|
issueDate: toISODate(row["Send Payment On"] || ""),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Submit to CashPro if there are any new issues or cancels
|
|
|
|
|
if (newChecks.length || cancelChecks.length) {
|
|
|
|
|
const [apiToken, accountNumber, companyId] = await Promise.all([
|
|
|
|
|
getSSMParam(process.env.BOA_API_TOKEN_PARAM),
|
|
|
|
|
getSSMParam(process.env.BOA_ACCOUNT_NUMBER_PARAM),
|
|
|
|
|
getSSMParam(process.env.BOA_COMPANY_ID_PARAM),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const submitToBoA = async (items, action) => {
|
|
|
|
|
const issueList = items.map((item) => ({
|
|
|
|
|
accountNumber,
|
|
|
|
|
checkNumber: item.checkNumber,
|
|
|
|
|
amount: item.amount,
|
|
|
|
|
issueAction: action,
|
|
|
|
|
issueDate: item.issueDate,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`${BOA_BASE_URL}/cashpro/checkmanagement/v1/check-issues`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${apiToken}`,
|
|
|
|
|
companyId,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ issueList }),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
console.error(`BoA ${action} error ${res.status}:`, JSON.stringify(data));
|
|
|
|
|
throw new Error(`BoA ${action} failed: ${res.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`BoA ${action}: ${data.processedItems}/${data.totalItems} processed, ${data.unprocessedItems} failed`
|
|
|
|
|
);
|
|
|
|
|
return data;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (newChecks.length) {
|
|
|
|
|
await submitToBoA(newChecks, "add_issue");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cancelChecks.length) {
|
|
|
|
|
await submitToBoA(cancelChecks, "cancel_issue");
|
|
|
|
|
}
|
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,
|
2026-04-09 13:29:28 -04:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-09 15:14:51 -04:00
|
|
|
console.log(
|
|
|
|
|
`Upserted ${count} payments, ${newChecks.length} issued, ${cancelChecks.length} cancelled`
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
statusCode: 200,
|
|
|
|
|
body: `Upserted ${count}, issued ${newChecks.length}, cancelled ${cancelChecks.length}`,
|
|
|
|
|
};
|
2026-04-09 13:29:28 -04:00
|
|
|
};
|