mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 07:43:12 +00:00
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:
parent
7268c21a2a
commit
b8709abae4
6 changed files with 399 additions and 3 deletions
29
README.md
29
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
|
||||
|
||||
|
|
|
|||
107
package-lock.json
generated
107
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
120
src/expenseProcessor.js
Normal 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
79
src/expenseReceiver.js
Normal 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: "" };
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue