Add VPC/NAT for static IP, dedup payments by check number, SSM token

- VPC with NAT Gateway for static outbound IP (52.86.95.107) for BoA API
- Payments stored as individual DynamoDB items keyed by check number
- Slack bot token fetched from SSM at runtime instead of CF parameter
- Slack Lambda scans payment items instead of reading single blob
This commit is contained in:
Adam Moussa 2026-04-09 14:27:34 -04:00
parent f1c2063f52
commit 7150028bd3
5 changed files with 2069 additions and 39 deletions

1871
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-ssm": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0",
"csv-parse": "^5.5.0"
}

View file

@ -1,6 +1,6 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBDocumentClient, BatchWriteCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { parse } from "csv-parse/sync";
const s3 = new S3Client();
@ -31,15 +31,16 @@ export const handler = async (event) => {
return clean;
});
// Calculate
const parseAmount = (value) => {
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
return isNaN(num) ? 0 : num;
};
const payments = normalizedRows.map((row) => ({
pk: `payment#${(row["Check Number"] || "").trim()}`,
method: (row["Method"] || "").trim(),
payee: (row["Payee"] || "").trim(),
check_number: (row["Check Number"] || "").trim(),
invoice_numbers: (row["Invoice Numbers"] || "").trim(),
send_payment_on: (row["Send Payment On"] || "").trim(),
amount_usd: parseAmount(row["Amount in USD"]),
@ -47,28 +48,36 @@ export const handler = async (event) => {
company_subsidiary: (row["Company/Subsidiary"] || "").trim(),
}));
const grand_total = payments.reduce((sum, p) => sum + p.amount_usd, 0);
// Write payments in batches of 25 (DynamoDB BatchWrite limit)
const batches = [];
for (let i = 0; i < payments.length; i += 25) {
const batch = payments.slice(i, i + 25).map((item) => ({
PutRequest: { Item: item },
}));
batches.push(batch);
}
const dashboard = {
file_name: key.split("/").pop(),
row_count: payments.length,
grand_count: payments.length,
grand_total: Number(grand_total.toFixed(2)),
payments,
last_updated: new Date().toISOString(),
};
for (const batch of batches) {
await ddb.send(
new BatchWriteCommand({
RequestItems: { [TABLE_NAME]: batch },
})
);
}
// Save to DynamoDB
// Update metadata record
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: "current_dashboard",
...dashboard,
pk: "metadata",
file_name: key.split("/").pop(),
last_updated: new Date().toISOString(),
last_file_count: payments.length,
},
})
);
console.log(`Processed ${payments.length} payments from ${key}`);
return { statusCode: 200, body: `Processed ${payments.length} payments` };
console.log(`Upserted ${payments.length} payments from ${key}`);
return { statusCode: 200, body: `Upserted ${payments.length} payments` };
};

View file

@ -1,9 +1,23 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const ssm = new SSMClient();
const TABLE_NAME = process.env.TABLE_NAME;
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
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;
}
export const handler = async (event) => {
const body = JSON.parse(event.body || "{}");
@ -20,25 +34,39 @@ export const handler = async (event) => {
const userId = body.event.user;
// Get dashboard data from DynamoDB
const { Item: dashboard } = await ddb.send(
new GetCommand({
TableName: TABLE_NAME,
Key: { pk: "current_dashboard" },
})
// Get metadata
const { Item: metadata } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
);
if (!dashboard) {
return { statusCode: 200, body: JSON.stringify({ error: "No dashboard data" }) };
// Scan all payment items
const payments = [];
let lastKey;
do {
const result = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
if (!payments.length) {
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
}
// Build and publish the home view
const view = buildHomeView(dashboard);
const view = buildHomeView(payments, metadata);
const slackToken = await getSlackToken();
const res = await fetch("https://slack.com/api/views.publish", {
method: "POST",
headers: {
Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
Authorization: `Bearer ${slackToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ user_id: userId, view }),
@ -54,8 +82,7 @@ export const handler = async (event) => {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
function buildHomeView(dashboard) {
const payments = Array.isArray(dashboard.payments) ? dashboard.payments : [];
function buildHomeView(payments, metadata) {
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
@ -171,8 +198,8 @@ function buildHomeView(dashboard) {
{
type: "context",
elements: [
{ type: "mrkdwn", text: `*Last updated:* ${dashboard.last_updated || "N/A"}` },
{ type: "mrkdwn", text: `*Source:* ${dashboard.file_name || "N/A"}` },
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
],
},
{ type: "divider" },

View file

@ -2,12 +2,6 @@ AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Payments Dashboard - S3 CSV ingestion to Slack App Home
Parameters:
SlackBotToken:
Type: AWS::SSM::Parameter::Value<String>
Default: /payments-dashboard/slack-bot-token
NoEcho: true
Globals:
Function:
Runtime: nodejs20.x
@ -18,6 +12,103 @@ Globals:
TABLE_NAME: !Ref DashboardTable
Resources:
# VPC with private subnet + NAT Gateway for static outbound IP
Vpc:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.20.0.0/16
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: payments-dashboard-vpc
PrivateSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.20.1.0/24
AvailabilityZone: !Select [0, !GetAZs ""]
Tags:
- Key: Name
Value: payments-dashboard-private
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.20.2.0/24
AvailabilityZone: !Select [0, !GetAZs ""]
Tags:
- Key: Name
Value: payments-dashboard-public
InternetGateway:
Type: AWS::EC2::InternetGateway
VpcGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref Vpc
InternetGatewayId: !Ref InternetGateway
NatEip:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
NatGateway:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatEip.AllocationId
SubnetId: !Ref PublicSubnet
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
PublicRoute:
Type: AWS::EC2::Route
DependsOn: VpcGatewayAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnetRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref Vpc
PrivateRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway
PrivateSubnetRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PrivateSubnet
RouteTableId: !Ref PrivateRouteTable
LambdaSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Payments Dashboard Lambda outbound access
VpcId: !Ref Vpc
SecurityGroupEgress:
- IpProtocol: "-1"
CidrIp: 0.0.0.0/0
PaymentsCsvBucket:
Type: AWS::S3::Bucket
Properties:
@ -40,6 +131,11 @@ Resources:
Properties:
FunctionName: payments-processPaymentCsv
Handler: src/processPaymentCsv.handler
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet
SecurityGroupIds:
- !Ref LambdaSecurityGroup
Events:
CsvUpload:
Type: S3
@ -56,15 +152,28 @@ Resources:
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
Resource: "*"
SlackAppHomeFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-slackAppHome
Handler: src/slackAppHome.handler
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet
SecurityGroupIds:
- !Ref LambdaSecurityGroup
Environment:
Variables:
SLACK_BOT_TOKEN: !Ref SlackBotToken
SLACK_BOT_TOKEN_PARAM: /payments-dashboard/slack-bot-token
Events:
SlackEvent:
Type: HttpApi
@ -74,6 +183,16 @@ Resources:
Policies:
- DynamoDBReadPolicy:
TableName: !Ref DashboardTable
- SSMParameterReadPolicy:
ParameterName: payments-dashboard/slack-bot-token
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
Resource: "*"
Outputs:
SlackEventUrl:
@ -82,3 +201,6 @@ Outputs:
CsvBucket:
Description: S3 bucket for CSV uploads
Value: !Ref PaymentsCsvBucket
StaticOutboundIp:
Description: Static IP for BoA API whitelist
Value: !Ref NatEip