diff --git a/README.md b/README.md index 2914d35..4ca505c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 7954de8..e427643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3221749..b26dce3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/expenseProcessor.js b/src/expenseProcessor.js new file mode 100644 index 0000000..0c852d8 --- /dev/null +++ b/src/expenseProcessor.js @@ -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`); + } +}; diff --git a/src/expenseReceiver.js b/src/expenseReceiver.js new file mode 100644 index 0000000..e81cf59 --- /dev/null +++ b/src/expenseReceiver.js @@ -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: "" }; +}; diff --git a/template.yaml b/template.yaml index adada67..6b95031 100644 --- a/template.yaml +++ b/template.yaml @@ -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