mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 06:33:11 +00:00
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:
parent
f1c2063f52
commit
7150028bd3
5 changed files with 2069 additions and 39 deletions
1871
package-lock.json
generated
Normal file
1871
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-dynamodb": "^3.600.0",
|
"@aws-sdk/client-dynamodb": "^3.600.0",
|
||||||
"@aws-sdk/client-s3": "^3.600.0",
|
"@aws-sdk/client-s3": "^3.600.0",
|
||||||
|
"@aws-sdk/client-ssm": "^3.600.0",
|
||||||
"@aws-sdk/lib-dynamodb": "^3.600.0",
|
"@aws-sdk/lib-dynamodb": "^3.600.0",
|
||||||
"csv-parse": "^5.5.0"
|
"csv-parse": "^5.5.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
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";
|
import { parse } from "csv-parse/sync";
|
||||||
|
|
||||||
const s3 = new S3Client();
|
const s3 = new S3Client();
|
||||||
|
|
@ -31,15 +31,16 @@ export const handler = async (event) => {
|
||||||
return clean;
|
return clean;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate
|
|
||||||
const parseAmount = (value) => {
|
const parseAmount = (value) => {
|
||||||
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
|
const num = parseFloat(String(value || "0").replace(/,/g, "").trim());
|
||||||
return isNaN(num) ? 0 : num;
|
return isNaN(num) ? 0 : num;
|
||||||
};
|
};
|
||||||
|
|
||||||
const payments = normalizedRows.map((row) => ({
|
const payments = normalizedRows.map((row) => ({
|
||||||
|
pk: `payment#${(row["Check Number"] || "").trim()}`,
|
||||||
method: (row["Method"] || "").trim(),
|
method: (row["Method"] || "").trim(),
|
||||||
payee: (row["Payee"] || "").trim(),
|
payee: (row["Payee"] || "").trim(),
|
||||||
|
check_number: (row["Check Number"] || "").trim(),
|
||||||
invoice_numbers: (row["Invoice Numbers"] || "").trim(),
|
invoice_numbers: (row["Invoice Numbers"] || "").trim(),
|
||||||
send_payment_on: (row["Send Payment On"] || "").trim(),
|
send_payment_on: (row["Send Payment On"] || "").trim(),
|
||||||
amount_usd: parseAmount(row["Amount in USD"]),
|
amount_usd: parseAmount(row["Amount in USD"]),
|
||||||
|
|
@ -47,28 +48,36 @@ export const handler = async (event) => {
|
||||||
company_subsidiary: (row["Company/Subsidiary"] || "").trim(),
|
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 = {
|
for (const batch of batches) {
|
||||||
file_name: key.split("/").pop(),
|
await ddb.send(
|
||||||
row_count: payments.length,
|
new BatchWriteCommand({
|
||||||
grand_count: payments.length,
|
RequestItems: { [TABLE_NAME]: batch },
|
||||||
grand_total: Number(grand_total.toFixed(2)),
|
})
|
||||||
payments,
|
);
|
||||||
last_updated: new Date().toISOString(),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Save to DynamoDB
|
// Update metadata record
|
||||||
await ddb.send(
|
await ddb.send(
|
||||||
new PutCommand({
|
new PutCommand({
|
||||||
TableName: TABLE_NAME,
|
TableName: TABLE_NAME,
|
||||||
Item: {
|
Item: {
|
||||||
pk: "current_dashboard",
|
pk: "metadata",
|
||||||
...dashboard,
|
file_name: key.split("/").pop(),
|
||||||
|
last_updated: new Date().toISOString(),
|
||||||
|
last_file_count: payments.length,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Processed ${payments.length} payments from ${key}`);
|
console.log(`Upserted ${payments.length} payments from ${key}`);
|
||||||
return { statusCode: 200, body: `Processed ${payments.length} payments` };
|
return { statusCode: 200, body: `Upserted ${payments.length} payments` };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
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 ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
|
||||||
|
const ssm = new SSMClient();
|
||||||
const TABLE_NAME = process.env.TABLE_NAME;
|
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) => {
|
export const handler = async (event) => {
|
||||||
const body = JSON.parse(event.body || "{}");
|
const body = JSON.parse(event.body || "{}");
|
||||||
|
|
@ -20,25 +34,39 @@ export const handler = async (event) => {
|
||||||
|
|
||||||
const userId = body.event.user;
|
const userId = body.event.user;
|
||||||
|
|
||||||
// Get dashboard data from DynamoDB
|
// Get metadata
|
||||||
const { Item: dashboard } = await ddb.send(
|
const { Item: metadata } = await ddb.send(
|
||||||
new GetCommand({
|
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
|
||||||
TableName: TABLE_NAME,
|
|
||||||
Key: { pk: "current_dashboard" },
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!dashboard) {
|
// Scan all payment items
|
||||||
return { statusCode: 200, body: JSON.stringify({ error: "No dashboard data" }) };
|
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
|
// 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", {
|
const res = await fetch("https://slack.com/api/views.publish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
|
Authorization: `Bearer ${slackToken}`,
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ user_id: userId, view }),
|
body: JSON.stringify({ user_id: userId, view }),
|
||||||
|
|
@ -54,8 +82,7 @@ export const handler = async (event) => {
|
||||||
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildHomeView(dashboard) {
|
function buildHomeView(payments, metadata) {
|
||||||
const payments = Array.isArray(dashboard.payments) ? dashboard.payments : [];
|
|
||||||
|
|
||||||
const formatCurrency = (value) =>
|
const formatCurrency = (value) =>
|
||||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
||||||
|
|
@ -171,8 +198,8 @@ function buildHomeView(dashboard) {
|
||||||
{
|
{
|
||||||
type: "context",
|
type: "context",
|
||||||
elements: [
|
elements: [
|
||||||
{ type: "mrkdwn", text: `*Last updated:* ${dashboard.last_updated || "N/A"}` },
|
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
|
||||||
{ type: "mrkdwn", text: `*Source:* ${dashboard.file_name || "N/A"}` },
|
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: "divider" },
|
{ type: "divider" },
|
||||||
|
|
|
||||||
136
template.yaml
136
template.yaml
|
|
@ -2,12 +2,6 @@ AWSTemplateFormatVersion: '2010-09-09'
|
||||||
Transform: AWS::Serverless-2016-10-31
|
Transform: AWS::Serverless-2016-10-31
|
||||||
Description: Payments Dashboard - S3 CSV ingestion to Slack App Home
|
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:
|
Globals:
|
||||||
Function:
|
Function:
|
||||||
Runtime: nodejs20.x
|
Runtime: nodejs20.x
|
||||||
|
|
@ -18,6 +12,103 @@ Globals:
|
||||||
TABLE_NAME: !Ref DashboardTable
|
TABLE_NAME: !Ref DashboardTable
|
||||||
|
|
||||||
Resources:
|
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:
|
PaymentsCsvBucket:
|
||||||
Type: AWS::S3::Bucket
|
Type: AWS::S3::Bucket
|
||||||
Properties:
|
Properties:
|
||||||
|
|
@ -40,6 +131,11 @@ Resources:
|
||||||
Properties:
|
Properties:
|
||||||
FunctionName: payments-processPaymentCsv
|
FunctionName: payments-processPaymentCsv
|
||||||
Handler: src/processPaymentCsv.handler
|
Handler: src/processPaymentCsv.handler
|
||||||
|
VpcConfig:
|
||||||
|
SubnetIds:
|
||||||
|
- !Ref PrivateSubnet
|
||||||
|
SecurityGroupIds:
|
||||||
|
- !Ref LambdaSecurityGroup
|
||||||
Events:
|
Events:
|
||||||
CsvUpload:
|
CsvUpload:
|
||||||
Type: S3
|
Type: S3
|
||||||
|
|
@ -56,15 +152,28 @@ Resources:
|
||||||
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
|
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
|
||||||
- DynamoDBCrudPolicy:
|
- DynamoDBCrudPolicy:
|
||||||
TableName: !Ref DashboardTable
|
TableName: !Ref DashboardTable
|
||||||
|
- Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ec2:CreateNetworkInterface
|
||||||
|
- ec2:DescribeNetworkInterfaces
|
||||||
|
- ec2:DeleteNetworkInterface
|
||||||
|
Resource: "*"
|
||||||
|
|
||||||
SlackAppHomeFunction:
|
SlackAppHomeFunction:
|
||||||
Type: AWS::Serverless::Function
|
Type: AWS::Serverless::Function
|
||||||
Properties:
|
Properties:
|
||||||
FunctionName: payments-slackAppHome
|
FunctionName: payments-slackAppHome
|
||||||
Handler: src/slackAppHome.handler
|
Handler: src/slackAppHome.handler
|
||||||
|
VpcConfig:
|
||||||
|
SubnetIds:
|
||||||
|
- !Ref PrivateSubnet
|
||||||
|
SecurityGroupIds:
|
||||||
|
- !Ref LambdaSecurityGroup
|
||||||
Environment:
|
Environment:
|
||||||
Variables:
|
Variables:
|
||||||
SLACK_BOT_TOKEN: !Ref SlackBotToken
|
SLACK_BOT_TOKEN_PARAM: /payments-dashboard/slack-bot-token
|
||||||
Events:
|
Events:
|
||||||
SlackEvent:
|
SlackEvent:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
|
|
@ -74,6 +183,16 @@ Resources:
|
||||||
Policies:
|
Policies:
|
||||||
- DynamoDBReadPolicy:
|
- DynamoDBReadPolicy:
|
||||||
TableName: !Ref DashboardTable
|
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:
|
Outputs:
|
||||||
SlackEventUrl:
|
SlackEventUrl:
|
||||||
|
|
@ -82,3 +201,6 @@ Outputs:
|
||||||
CsvBucket:
|
CsvBucket:
|
||||||
Description: S3 bucket for CSV uploads
|
Description: S3 bucket for CSV uploads
|
||||||
Value: !Ref PaymentsCsvBucket
|
Value: !Ref PaymentsCsvBucket
|
||||||
|
StaticOutboundIp:
|
||||||
|
Description: Static IP for BoA API whitelist
|
||||||
|
Value: !Ref NatEip
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue