Merge expense-approval-bot into payments-dashboard

Port the Slack reaction-driven expense routing workflow (receiver +
processor) from expense-approval-bot into this stack as JavaScript ESM.
Secrets copied to payments-dashboard/ prefix in Secrets Manager.
This commit is contained in:
Adam Moussa 2026-05-12 12:18:38 -04:00
parent 7268c21a2a
commit b8709abae4
6 changed files with 399 additions and 3 deletions

View file

@ -1,6 +1,6 @@
# Payments Dashboard
AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, processes Gusto payroll confirmation emails into Slack notifications, and surfaces an outstanding-payments dashboard in Slack.
AWS SAM application that ingests payment CSVs, syncs check data with Bank of America CashPro APIs, processes Gusto payroll confirmation emails into Slack notifications, surfaces an outstanding-payments dashboard in Slack, and routes expense approvals through a Slack reaction-driven workflow.
## Architecture
@ -9,7 +9,32 @@ AWS SAM application that ingests payment CSVs, syncs check data with Bank of Ame
- **FetchBoaTransactions** — Scheduled Lambda (weekdays 9am ET). Calls the CashPro Previous Day Transaction Inquiry API and matches cleared/returned checks back to DynamoDB records.
- **SlackAppHome** — Lambda behind API Gateway. Renders the payments dashboard on the Slack App Home tab with outstanding aging buckets and drill-down modals.
ProcessPaymentCsv, FetchBoaTransactions, and SlackAppHome run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting). ProcessPayrollEmail runs outside the VPC.
- **ExpenseReceiver** — Lambda behind API Gateway (`POST /slack/expense-events`). Verifies the Slack signing secret (HMAC-SHA256), handles URL verification challenges, and async-invokes ExpenseProcessor. Runs outside VPC.
- **ExpenseProcessor** — Async Lambda invoked by ExpenseReceiver. Processes `:white_check_mark:` reactions to advance expense messages through a four-stage Slack channel pipeline: Submitted → Processed → Authorized → Matched. Runs outside VPC.
ProcessPaymentCsv, FetchBoaTransactions, and SlackAppHome run inside a VPC with a NAT Gateway for a static outbound IP (required by BoA IP whitelisting). ProcessPayrollEmail, ExpenseReceiver, and ExpenseProcessor run outside the VPC.
## Expense Approval Bot
Reaction-driven workflow that routes expense submissions through four Slack channels. A separate Slack app ("Expense Approval Bot") posts to a **Submitted** channel. Users react with :white_check_mark: to advance the message to the next stage.
**Channel pipeline:**
| Stage | Channel ID | Action on :white_check_mark: |
|-------|-----------|------------------------------|
| Submitted | `C0AQ2AWLNEN` | Thread reply on original, copy to Processed |
| Processed | `C0APLSGABAB` | Delete from Processed, post to Authorized |
| Authorized | `C0AQ09CDJH4` | Delete from Authorized, post to Matched |
| Matched | `C0APYUM1JFP` | Terminal stage (no further routing) |
**Architecture:** Two Lambdas — ExpenseReceiver (HTTP endpoint, signature verification, async invoke) and ExpenseProcessor (business logic). This is the same receiver/processor pattern used for Slack's 3-second timeout requirement.
**Secrets (Secrets Manager):**
| Secret | Purpose |
|--------|---------|
| `payments-dashboard/expense-slack-token` | Slack Bot token for the Expense Approval Bot app |
| `payments-dashboard/expense-slack-signing-secret` | Slack signing secret for request verification |
## Payroll Email Pipeline

107
package-lock.json generated
View file

@ -9,7 +9,9 @@
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1045.0",
"@aws-sdk/client-lambda": "^3.1045.0",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/client-secrets-manager": "^3.1045.0",
"@aws-sdk/client-sqs": "^3.1045.0",
"@aws-sdk/client-ssm": "^3.1045.0",
"@aws-sdk/lib-dynamodb": "^3.1045.0",
@ -272,6 +274,61 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-lambda": {
"version": "3.1045.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1045.0.tgz",
"integrity": "sha512-9EDPinh03XanJQssTBdTY+9E7PkyQ0NLLJiaOAM71/g4DI+0OZboGqhX7KKizwUGqKkj0paKEAwgWaMLgEkQFQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.8",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@aws-sdk/middleware-host-header": "^3.972.10",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.11",
"@aws-sdk/middleware-user-agent": "^3.972.38",
"@aws-sdk/region-config-resolver": "^3.972.13",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@aws-sdk/util-user-agent-browser": "^3.972.10",
"@aws-sdk/util-user-agent-node": "^3.973.24",
"@smithy/config-resolver": "^4.4.17",
"@smithy/core": "^3.23.17",
"@smithy/eventstream-serde-browser": "^4.2.14",
"@smithy/eventstream-serde-config-resolver": "^4.3.14",
"@smithy/eventstream-serde-node": "^4.2.14",
"@smithy/fetch-http-handler": "^5.3.17",
"@smithy/hash-node": "^4.2.14",
"@smithy/invalid-dependency": "^4.2.14",
"@smithy/middleware-content-length": "^4.2.14",
"@smithy/middleware-endpoint": "^4.4.32",
"@smithy/middleware-retry": "^4.5.7",
"@smithy/middleware-serde": "^4.2.20",
"@smithy/middleware-stack": "^4.2.14",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/node-http-handler": "^4.6.1",
"@smithy/protocol-http": "^5.3.14",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
"@smithy/util-defaults-mode-browser": "^4.3.49",
"@smithy/util-defaults-mode-node": "^4.2.54",
"@smithy/util-endpoints": "^3.4.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.6",
"@smithy/util-stream": "^4.5.25",
"@smithy/util-utf8": "^4.2.2",
"@smithy/util-waiter": "^4.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1045.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz",
@ -338,6 +395,56 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-secrets-manager": {
"version": "3.1045.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1045.0.tgz",
"integrity": "sha512-ceXmaTE/3j7bHgVzUrpL/ECjQQ+aE/x8wNbblC/SIb020OxYRMj0DscFimnI5kEjutGHQ+A68bbX2A+bZuAMEA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.8",
"@aws-sdk/credential-provider-node": "^3.972.39",
"@aws-sdk/middleware-host-header": "^3.972.10",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.11",
"@aws-sdk/middleware-user-agent": "^3.972.38",
"@aws-sdk/region-config-resolver": "^3.972.13",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@aws-sdk/util-user-agent-browser": "^3.972.10",
"@aws-sdk/util-user-agent-node": "^3.973.24",
"@smithy/config-resolver": "^4.4.17",
"@smithy/core": "^3.23.17",
"@smithy/fetch-http-handler": "^5.3.17",
"@smithy/hash-node": "^4.2.14",
"@smithy/invalid-dependency": "^4.2.14",
"@smithy/middleware-content-length": "^4.2.14",
"@smithy/middleware-endpoint": "^4.4.32",
"@smithy/middleware-retry": "^4.5.7",
"@smithy/middleware-serde": "^4.2.20",
"@smithy/middleware-stack": "^4.2.14",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/node-http-handler": "^4.6.1",
"@smithy/protocol-http": "^5.3.14",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
"@smithy/util-defaults-mode-browser": "^4.3.49",
"@smithy/util-defaults-mode-node": "^4.2.54",
"@smithy/util-endpoints": "^3.4.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.6",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sqs": {
"version": "3.1045.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1045.0.tgz",

View file

@ -2,13 +2,15 @@
"name": "payments-dashboard",
"version": "1.0.0",
"type": "module",
"description": "Payments CSV ingestion to Slack App Home dashboard",
"description": "Payments CSV ingestion, Slack App Home dashboard, and expense approval routing",
"files": [
"src/"
],
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.1045.0",
"@aws-sdk/client-lambda": "^3.1045.0",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/client-secrets-manager": "^3.1045.0",
"@aws-sdk/client-sqs": "^3.1045.0",
"@aws-sdk/client-ssm": "^3.1045.0",
"@aws-sdk/lib-dynamodb": "^3.1045.0",

120
src/expenseProcessor.js Normal file
View file

@ -0,0 +1,120 @@
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const secrets = new SecretsManagerClient();
const EXPENSE_BOT_TOKEN_SECRET_NAME = process.env.EXPENSE_BOT_TOKEN_SECRET_NAME;
let cachedToken;
async function getBotToken() {
if (cachedToken) return cachedToken;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_BOT_TOKEN_SECRET_NAME })
);
cachedToken = SecretString;
return cachedToken;
}
const SUBMITTED_CHANNEL = "C0AQ2AWLNEN";
const STAGES = {
[SUBMITTED_CHANNEL]: { next: "C0APLSGABAB", label: "Processed" },
C0APLSGABAB: { next: "C0AQ09CDJH4", label: "Authorized" },
C0AQ09CDJH4: { next: "C0APYUM1JFP", label: "Matched" },
};
const REACT_HINT_RE = /_React_ :white_check_mark: _to advance to \w+_/;
async function slackGet(method, token, params) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`https://slack.com/api/${method}?${qs}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (!data.ok) throw new Error(`${method} failed: ${data.error}`);
return data;
}
async function slackPost(method, token, body) {
const res = await fetch(`https://slack.com/api/${method}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) throw new Error(`${method} failed: ${data.error}`);
return data;
}
export const handler = async (event) => {
if (event.reaction !== "white_check_mark") {
console.log("Not white_check_mark reaction, skipping");
return;
}
const fromChannel = event.item.channel;
const messageTs = event.item.ts;
const route = STAGES[fromChannel];
if (!route) {
console.log(`Channel ${fromChannel} not in STAGES, skipping`);
return;
}
const toChannel = route.next;
const label = route.label;
const isOrigin = fromChannel === SUBMITTED_CHANNEL;
const token = await getBotToken();
const history = await slackGet("conversations.history", token, {
channel: fromChannel,
latest: messageTs,
limit: "1",
inclusive: "true",
});
const originalText = history.messages[0].text;
const permalinkResp = await slackGet("chat.getPermalink", token, {
channel: fromChannel,
message_ts: messageTs,
});
const permalink = permalinkResp.permalink;
const text = originalText.replace(REACT_HINT_RE, "").trim();
const nextStage = STAGES[toChannel];
const nextLabel = nextStage ? nextStage.label : null;
const reactLine = nextLabel
? `\n\n_React_ :white_check_mark: _to advance to ${nextLabel}_`
: "";
const permalinkLine = isOrigin
? `\n\n:paperclip: *Original Submission:* <${permalink}|View Original Message>`
: "";
const fullText = `${text}${permalinkLine}${reactLine}`;
await slackPost("chat.postMessage", token, {
channel: toChannel,
text: fullText,
mrkdwn: true,
unfurl_links: false,
unfurl_media: false,
});
if (isOrigin) {
await slackPost("chat.postMessage", token, {
channel: fromChannel,
thread_ts: messageTs,
text: `➡️ Advanced to ${label}`,
});
console.log(`Advanced user submission to ${label} (origin preserved)`);
} else {
await slackPost("chat.delete", token, {
channel: fromChannel,
ts: messageTs,
});
console.log(`Advanced to ${label} and deleted previous copy`);
}
};

79
src/expenseReceiver.js Normal file
View file

@ -0,0 +1,79 @@
import crypto from "node:crypto";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const secrets = new SecretsManagerClient();
const lambda = new LambdaClient();
const EXPENSE_PROCESSOR_FN = process.env.EXPENSE_PROCESSOR_FN;
const EXPENSE_SIGNING_SECRET_NAME = process.env.EXPENSE_SIGNING_SECRET_NAME;
let cachedSigningSecret;
async function getSigningSecret() {
if (cachedSigningSecret) return cachedSigningSecret;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_SIGNING_SECRET_NAME })
);
cachedSigningSecret = SecretString;
return cachedSigningSecret;
}
function verifySignature(body, timestamp, signature, secret) {
if (!timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const base = `v0:${timestamp}:${body}`;
const expected =
"v0=" + crypto.createHmac("sha256", secret).update(base).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
export const handler = async (event) => {
let body = event.body || "";
if (event.isBase64Encoded) {
body = Buffer.from(body, "base64").toString("utf-8");
}
const headers = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
const timestamp = headers["x-slack-request-timestamp"] || "";
const signature = headers["x-slack-signature"] || "";
const secret = await getSigningSecret();
if (!verifySignature(body, timestamp, signature, secret)) {
console.log("Signature verification failed");
return { statusCode: 401, body: "unauthorized" };
}
const payload = JSON.parse(body);
if (payload.type === "url_verification") {
return {
statusCode: 200,
headers: { "Content-Type": "text/plain" },
body: payload.challenge || "",
};
}
if (payload.type === "event_callback") {
await lambda.send(
new InvokeCommand({
FunctionName: EXPENSE_PROCESSOR_FN,
InvocationType: "Event",
Payload: JSON.stringify(payload.event),
})
);
}
return { statusCode: 200, body: "" };
};

View file

@ -207,6 +207,18 @@ Resources:
LogGroupName: /aws/lambda/payments-fetchBoaTransactions
RetentionInDays: 60
ExpenseReceiverLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/payments-expenseReceiver
RetentionInDays: 60
ExpenseProcessorLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/payments-expenseProcessor
RetentionInDays: 60
ProcessPayrollEmailFunction:
Type: AWS::Serverless::Function
Properties:
@ -381,6 +393,48 @@ Resources:
- ec2:DeleteNetworkInterface
Resource: "*"
ExpenseProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-expenseProcessor
Handler: src/expenseProcessor.handler
Timeout: 15
Environment:
Variables:
EXPENSE_BOT_TOKEN_SECRET_NAME: payments-dashboard/expense-slack-token
Policies:
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/expense-slack-token-*
ExpenseReceiverFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-expenseReceiver
Handler: src/expenseReceiver.handler
Timeout: 5
Environment:
Variables:
EXPENSE_PROCESSOR_FN: !Ref ExpenseProcessorFunction
EXPENSE_SIGNING_SECRET_NAME: payments-dashboard/expense-slack-signing-secret
Events:
ExpenseSlackEvent:
Type: HttpApi
Properties:
Path: /slack/expense-events
Method: POST
Policies:
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt ExpenseProcessorFunction.Arn
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/expense-slack-signing-secret-*
Outputs:
SlackEventUrl:
Description: URL to set as the Slack app Request URL
@ -394,3 +448,12 @@ Outputs:
PayrollEmailBucket:
Description: S3 bucket for inbound payroll emails from SES
Value: !Ref PayrollEmailBucket
ExpenseSlackEventsUrl:
Description: URL for Expense Approval Bot Slack Event Subscriptions
Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/expense-events
ExpenseProcessorFunctionArn:
Description: Expense Processor Lambda ARN
Value: !GetAtt ExpenseProcessorFunction.Arn
ExpenseReceiverFunctionArn:
Description: Expense Receiver Lambda ARN
Value: !GetAtt ExpenseReceiverFunction.Arn