payments-dashboard/src/notifyPayroll.js

188 lines
5.9 KiB
JavaScript
Raw Normal View History

import { RDSDataClient, ExecuteStatementCommand } from "@aws-sdk/client-rds-data";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const rds = new RDSDataClient();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const ssm = new SSMClient();
const CLUSTER_ARN = process.env.AURORA_CLUSTER_ARN;
const SECRET_ARN = process.env.AURORA_SECRET_ARN;
const DATABASE = "payroll";
const TABLE_NAME = process.env.TABLE_NAME;
const CHANNEL_ID = "C0AV5RBMYKU";
let cachedToken;
async function getSlackToken() {
if (cachedToken) return cachedToken;
const { Parameter } = await ssm.send(
new GetParameterCommand({ Name: process.env.SLACK_BOT_TOKEN_PARAM, WithDecryption: true })
);
cachedToken = Parameter.Value;
return cachedToken;
}
async function sql(query) {
const { records } = await rds.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
sql: query,
})
);
return records || [];
}
function extractValue(field) {
if (field.stringValue !== undefined) return field.stringValue;
if (field.longValue !== undefined) return field.longValue;
if (field.doubleValue !== undefined) return field.doubleValue;
if (field.booleanValue !== undefined) return field.booleanValue;
if (field.isNull) return null;
return null;
}
const formatCurrency = (v) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(v || 0));
const formatDate = (ts) => {
const d = new Date(ts);
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
};
async function getNotifiedPayrolls() {
const result = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "PAYROLL_NOTIFIED" } })
);
return result.Item?.checkDates || [];
}
async function markNotified(checkDate) {
const existing = await getNotifiedPayrolls();
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: { pk: "PAYROLL_NOTIFIED", checkDates: [...existing, checkDate] },
})
);
}
async function buildPayrollMessage(row) {
const checkDate = extractValue(row[0]);
const companyDebit = extractValue(row[1]);
const grossPay = extractValue(row[2]);
const netPay = extractValue(row[3]);
const employerTaxes = extractValue(row[4]);
const reimbursements = extractValue(row[5]);
const benefits = extractValue(row[6]);
const contractorRows = await sql(
`SELECT COALESCE(SUM(payments_wage_total), 0), COALESCE(SUM(payments_reimbursement), 0) FROM contractor_payments WHERE payments_date = '${checkDate}'`
);
const contractorWages = contractorRows.length > 0 ? extractValue(contractorRows[0][0]) : 0;
const contractorReimbursements = contractorRows.length > 0 ? extractValue(contractorRows[0][1]) : 0;
const contractorTotal = contractorWages + contractorReimbursements;
const totalGross = grossPay + contractorTotal;
const cashRequirement = companyDebit + contractorTotal + benefits;
const blocks = [
{
type: "header",
text: { type: "plain_text", text: `Payroll Processed — ${formatDate(checkDate)}` },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Check Date*\n${formatDate(checkDate)}` },
{ type: "mrkdwn", text: `*Total Gross Pay*\n${formatCurrency(totalGross)}` },
],
},
{ type: "divider" },
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employee Gross*\n${formatCurrency(grossPay)}` },
{ type: "mrkdwn", text: `*Contractor Gross*\n${formatCurrency(contractorTotal)}` },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employee Net Pay*\n${formatCurrency(netPay)}` },
{ type: "mrkdwn", text: `*Reimbursements*\n${formatCurrency(reimbursements)}` },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employer Taxes*\n${formatCurrency(employerTaxes)}` },
{ type: "mrkdwn", text: `*Benefits (401k)*\n${formatCurrency(benefits)}` },
],
},
{ type: "divider" },
{
type: "section",
text: {
type: "mrkdwn",
text: `:moneybag: *Total Bank Withdrawal on ${formatDate(checkDate)}: ${formatCurrency(cashRequirement)}*`,
},
},
];
return { checkDate, blocks };
}
export const handler = async () => {
const payrollRows = await sql(
"SELECT check_date, company_debit, gross_pay, net_pay, employer_taxes, reimbursements, benefits FROM payroll WHERE company_debit > 0 ORDER BY check_date DESC"
);
if (payrollRows.length === 0) {
console.log("No processed payrolls found");
return { statusCode: 200, body: "No payrolls" };
}
const notified = await getNotifiedPayrolls();
const newPayrolls = payrollRows.filter(
(row) => !notified.includes(extractValue(row[0]))
);
if (newPayrolls.length === 0) {
console.log("All payrolls already notified");
return { statusCode: 200, body: "Already notified" };
}
const token = await getSlackToken();
for (const row of newPayrolls) {
const { checkDate, blocks } = await buildPayrollMessage(row);
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: CHANNEL_ID,
blocks,
text: `Payroll processed for ${formatDate(checkDate)}`,
}),
});
const data = await res.json();
if (!data.ok) {
console.error("Slack post failed:", data.error);
throw new Error(`Slack API error: ${data.error}`);
}
await markNotified(checkDate);
console.log(`Notified for check date: ${checkDate}`);
}
return { statusCode: 200, body: `Notified ${newPayrolls.length} payroll(s)` };
};