payments-dashboard/template.yaml
Adam Moussa 85a35d5492
feat(fetchboa): intraday current-day runs, raw S3 archive, balance records
Same-day settlement visibility plus maximal data capture from the
reporting feed. The handler gains an allowlisted endpoint parameter
(EventBridge passes {"endpoint":"current-day"} on a new 16/19/22 UTC
weekday rule; the 9am previous-day sweep is unchanged and remains
authoritative). Every response's exact bytes archive to a new
Retain-protected bucket before classification, so the feed is
replayable and auditable even across parser changes. Summary rows
become per-date boa_balance# snapshots (latest-wins on run_at, no
TTL) instead of being discarded. The staleness sweep is gated to
previous-day runs so intraday runs don't re-alert the backlog three
times a day. History events carry a via:<endpoint> audit tag outside
the replay-idempotence identity. Fixtures are sanitized real API
captures; classification histograms assert against live-verified
counts.

Refs: #66, #69
2026-07-22 15:56:53 -04:00

1185 lines
40 KiB
YAML

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Payments Dashboard - S3 CSV ingestion to Slack App Home
Parameters:
DynamoDbCmkArn:
Type: AWS::SSM::Parameter::Value<String>
Default: /seahaven/dynamodb/cmk-arn
Description: >-
ARN of the shared customer-managed CMK (alias/seahaven-dynamodb) that
encrypts the PaymentsDashboard table. Functions that read/write the table
need kms:Decrypt/GenerateDataKey/DescribeKey on this key (the boundary
permits exactly these), or DynamoDB calls fail with AccessDeniedException.
Globals:
Function:
Runtime: nodejs24.x
Architectures:
- arm64
Timeout: 30
MemorySize: 256
PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary
Environment:
Variables:
TABLE_NAME: !Ref DashboardTable
# Access logging + default throttling on the implicit HTTP API (audit M-18).
HttpApi:
AccessLogSettings:
DestinationArn: !GetAtt ApiAccessLogGroup.Arn
Format: '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","requestTime":"$context.requestTime","method":"$context.httpMethod","routeKey":"$context.routeKey","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","integrationError":"$context.integrationErrorMessage"}'
DefaultRouteSettings:
ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100
Resources:
ApiAccessLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/apigateway/payments-dashboard
RetentionInDays: 90
# 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
# Gateway endpoints (audit M-22): keep S3/DynamoDB traffic off the NAT
# gateway — free, and removes per-GB NAT data-processing charges.
S3GatewayEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PublicRouteTable
- !Ref PrivateRouteTable
DynamoDbGatewayEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: !Ref Vpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.dynamodb
VpcEndpointType: Gateway
RouteTableIds:
- !Ref PublicRouteTable
- !Ref PrivateRouteTable
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:
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
# Raw archive of every BoA reporting API response (exact bytes, keyed
# raw/<endpoint>/<fromDate>_<toDate>/<runAt>.json). Replayable corpus for
# parser changes + audit trail. Retain: a template revert must never
# attempt to delete a bank-data bucket; decommission goes through the CFN
# decommission runbook (inventory, purge, deliberate deletion).
# Retain + fixed name = rollback-orphan hazard (same class as the RETAIN
# secret deadlock): if a failed deploy orphans the bucket, ADOPT it back
# with a CloudFormation resource import — never delete-and-recreate.
BoaRawBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub seahaven-payments-boa-raw-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
LifecycleConfiguration:
Rules:
- Id: expire-raw-responses
Status: Enabled
ExpirationInDays: 730
# Defense-in-depth for bank data: deny any non-TLS access. No Allow
# statements — access is IAM-only (the Lambda's PutObject grant); unlike
# PayrollEmailBucket there is no cross-service principal here. The Deny
# covers bucket-level actions too, which is safe because nothing is
# granted List/Get — revisit if read access is ever added.
BoaRawBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref BoaRawBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: DenyInsecureTransport
Effect: Deny
Principal: "*"
Action: s3:*
Resource:
- !GetAtt BoaRawBucket.Arn
- !Sub "${BoaRawBucket.Arn}/*"
Condition:
Bool:
aws:SecureTransport: "false"
DashboardTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: PaymentsDashboard
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
# SSE-KMS with the shared CMK (alias/seahaven-dynamodb, INFRA-95 / M-3).
# The table was migrated to this key out-of-band, so declaring it here
# reconciles the template drift (no-op against the live table). Consumer
# roles still need explicit kms perms below (SAM policies do not auto-add).
SSESpecification:
SSEEnabled: true
SSEType: KMS
KMSMasterKeyId: !Ref DynamoDbCmkArn
PayrollEmailBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub seahaven-payroll-emails-${AWS::AccountId}
PublicAccessBlockConfiguration:
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: ExpireEmails
Status: Enabled
ExpirationInDays: 30
PayrollEmailBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref PayrollEmailBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowSESPut
Effect: Allow
Principal:
Service: ses.amazonaws.com
Action: s3:PutObject
Resource: !Sub arn:aws:s3:::seahaven-payroll-emails-${AWS::AccountId}/*
Condition:
StringEquals:
AWS:SourceAccount: !Ref AWS::AccountId
PayrollEmailRule:
Type: AWS::SES::ReceiptRule
DependsOn: PayrollEmailBucketPolicy
Properties:
RuleSetName: INBOUND_MAIL
After: ExistingRuleSetWorkorderEmailRuleEA29F845-okepxcVarTfu
Rule:
Name: store-payroll-emails
Enabled: true
ScanEnabled: true
Recipients:
- payroll@int.seahaven.com
Actions:
- S3Action:
BucketName: !Ref PayrollEmailBucket
ObjectKeyPrefix: inbound/
PayrollBatchQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: payments-payroll-batch
DelaySeconds: 600
MessageRetentionPeriod: 86400
VisibilityTimeout: 60
RedrivePolicy:
deadLetterTargetArn: !GetAtt PayrollBatchDLQ.Arn
maxReceiveCount: 3
# Audit L-15: payroll-batch messages were lost after max receives. 14-day
# retention so a failure on Friday survives the weekend.
PayrollBatchDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: payments-payroll-batch-dlq
MessageRetentionPeriod: 1209600
PayrollBatchDLQAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-payroll-batch-dlq-messages
AlarmDescription: Failed payroll-batch messages landed in the DLQ
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- Name: QueueName
Value: !GetAtt PayrollBatchDLQ.QueueName
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
# ALARM-only notification by convention — no OK/recovery action
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# Async-invoke OnFailure DLQs (INFRA-41 / H-8). Reconciles the interim
# CLI-created queues into CloudFormation. Names are CFN-generated to avoid
# colliding with the live interim payments-<fn>-dlq queues (deleted after
# this deploy). 14-day retention mirrors the payments-payroll-batch DLQ so a
# Friday failure survives the weekend.
# Distinct -async-dlq name (not the live interim payments-<fn>-dlq) so this
# CFN queue does not collide with the queue being deleted post-deploy.
ProcessPaymentCsvDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: payments-processPaymentCsv-async-dlq
MessageRetentionPeriod: 1209600 # 14d, matches payments-payroll-batch-dlq
ProcessPayrollEmailDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: payments-processPayrollEmail-async-dlq
MessageRetentionPeriod: 1209600 # 14d, matches payments-payroll-batch-dlq
# ALARM-only Lambda Errors alarms (INFRA-41 / H-8). Threshold > 0 on the
# Errors Sum, no OK/recovery action by convention.
ProcessPaymentCsvErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPaymentCsv-errors
AlarmDescription: payments-processPaymentCsv invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPaymentCsvFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ProcessPayrollEmailErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPayrollEmail-errors
AlarmDescription: payments-processPayrollEmail invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPayrollEmailFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# ── Lambda Errors alarms (Wave 1) ──────────────────────────────────────────
# Clone of ProcessPaymentCsvErrorsAlarm for the remaining functions. AWS/Lambda
# Errors, Sum over 5m, threshold > 0, ALARM-only by convention.
SlackAppHomeErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-slackAppHome-errors
AlarmDescription: payments-slackAppHome invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref SlackAppHomeFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
FetchBoaTransactionsErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-fetchBoaTransactions-errors
AlarmDescription: payments-fetchBoaTransactions invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref FetchBoaTransactionsFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseReceiverErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseReceiver-errors
AlarmDescription: payments-expenseReceiver invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseReceiverFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseProcessorErrorsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseProcessor-errors
AlarmDescription: payments-expenseProcessor invocation errors
Namespace: AWS/Lambda
MetricName: Errors
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseProcessorFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# ── Lambda Throttles alarms (Wave 1) ───────────────────────────────────────
# AWS/Lambda Throttles, Sum over 5m, threshold > 0, ALARM-only. Throttling
# signals concurrency exhaustion / reserved-concurrency starvation.
ProcessPaymentCsvThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPaymentCsv-throttles
AlarmDescription: payments-processPaymentCsv invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPaymentCsvFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ProcessPayrollEmailThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPayrollEmail-throttles
AlarmDescription: payments-processPayrollEmail invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPayrollEmailFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
FetchBoaTransactionsThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-fetchBoaTransactions-throttles
AlarmDescription: payments-fetchBoaTransactions invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref FetchBoaTransactionsFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
SlackAppHomeThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-slackAppHome-throttles
AlarmDescription: payments-slackAppHome invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref SlackAppHomeFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseReceiverThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseReceiver-throttles
AlarmDescription: payments-expenseReceiver invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseReceiverFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseProcessorThrottlesAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseProcessor-throttles
AlarmDescription: payments-expenseProcessor invocations throttled
Namespace: AWS/Lambda
MetricName: Throttles
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseProcessorFunction
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# ── Lambda Duration alarms (Wave 1) ────────────────────────────────────────
# AWS/Lambda Duration (ms), Statistic Maximum over 5m. Thresholds are ~80% of
# each function's configured timeout — early warning before timeout-kills.
ProcessPaymentCsvDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPaymentCsv-duration
AlarmDescription: payments-processPaymentCsv approaching timeout (~80% of 120s)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPaymentCsvFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 96000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ProcessPayrollEmailDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPayrollEmail-duration
AlarmDescription: payments-processPayrollEmail approaching timeout (~80% of 60s)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref ProcessPayrollEmailFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 48000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
FetchBoaTransactionsDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-fetchBoaTransactions-duration
AlarmDescription: payments-fetchBoaTransactions approaching timeout (~80% of 60s)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref FetchBoaTransactionsFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 48000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseProcessorDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseProcessor-duration
AlarmDescription: payments-expenseProcessor approaching timeout (~80% of 15s)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseProcessorFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 12000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ExpenseReceiverDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-expenseReceiver-duration
AlarmDescription: payments-expenseReceiver approaching timeout (~80% of 5s)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref ExpenseReceiverFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 4000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
SlackAppHomeDurationAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-slackAppHome-duration
AlarmDescription: payments-slackAppHome approaching timeout (~80% of 30s default)
Namespace: AWS/Lambda
MetricName: Duration
Dimensions:
- Name: FunctionName
Value: !Ref SlackAppHomeFunction
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 24000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# ── DynamoDB alarms (Wave 1, SCOPE-CONFIRM) ────────────────────────────────
# AWS/DynamoDB throttle metrics for the PaymentsDashboard table. These metrics
# emit at the TableName dimension and only on the occurrence of a throttle
# event — none are currently present in CloudWatch (the table is
# PAY_PER_REQUEST, so sustained throttling is unlikely but possible during
# burst-capacity ramp). SystemErrors is intentionally not alarmed: AWS/DynamoDB
# SystemErrors does not emit at the TableName-only dimension, so it can never
# fire. Threshold > 0, Sum over 5m, ALARM-only.
DashboardTableReadThrottleAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-dashboard-table-read-throttle
AlarmDescription: PaymentsDashboard table read requests throttled
Namespace: AWS/DynamoDB
MetricName: ReadThrottleEvents
Dimensions:
- Name: TableName
Value: !Ref DashboardTable
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
DashboardTableWriteThrottleAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-dashboard-table-write-throttle
AlarmDescription: PaymentsDashboard table write requests throttled
Namespace: AWS/DynamoDB
MetricName: WriteThrottleEvents
Dimensions:
- Name: TableName
Value: !Ref DashboardTable
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# ── API Gateway (HTTP API v2) alarms (Wave 1, SCOPE-CONFIRM) ────────────────
# AWS/ApiGateway v2 metrics on the implicit ServerlessHttpApi (ApiId dim).
# v2 metric names are 4xx/5xx/Latency (not 4XXError/5XXError). 5xx and Latency
# alarm on the API itself; 4xx is mostly client-driven so its threshold is
# set above zero to avoid noise (Slack URL-verification / bad requests).
ApiGateway5xxAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-dashboard-api-5xx
AlarmDescription: payments-dashboard HTTP API returned 5xx responses
Namespace: AWS/ApiGateway
MetricName: 5xx
Dimensions:
- Name: ApiId
Value: !Ref ServerlessHttpApi
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ApiGateway4xxAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-dashboard-api-4xx
AlarmDescription: payments-dashboard HTTP API elevated 4xx responses
Namespace: AWS/ApiGateway
MetricName: 4xx
Dimensions:
- Name: ApiId
Value: !Ref ServerlessHttpApi
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 10
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ApiGatewayLatencyAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-dashboard-api-latency-p99
AlarmDescription: payments-dashboard HTTP API p99 latency elevated (>3s)
Namespace: AWS/ApiGateway
MetricName: Latency
Dimensions:
- Name: ApiId
Value: !Ref ServerlessHttpApi
ExtendedStatistic: p99
Period: 300
EvaluationPeriods: 1
Threshold: 3000
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
# Messages-present alarms on the async-invoke OnFailure DLQs, mirroring
# PayrollBatchDLQAlarm. Threshold > 0 on the visible-message count, ALARM-only.
ProcessPaymentCsvDLQAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPaymentCsv-async-dlq-messages
AlarmDescription: Failed processPaymentCsv async invocations landed in the DLQ
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- Name: QueueName
Value: !GetAtt ProcessPaymentCsvDLQ.QueueName
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
# ALARM-only notification by convention — no OK/recovery action
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ProcessPayrollEmailDLQAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: payments-processPayrollEmail-async-dlq-messages
AlarmDescription: Failed processPayrollEmail async invocations landed in the DLQ
Namespace: AWS/SQS
MetricName: ApproximateNumberOfMessagesVisible
Dimensions:
- Name: QueueName
Value: !GetAtt ProcessPayrollEmailDLQ.QueueName
Statistic: Maximum
Period: 300
EvaluationPeriods: 1
Threshold: 0
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
# ALARM-only notification by convention — no OK/recovery action
AlarmActions:
- !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:site-alerts
ProcessPayrollEmailLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/payments-processPayrollEmail
RetentionInDays: 60
ProcessPaymentCsvLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/payments-processPaymentCsv
RetentionInDays: 60
SlackAppHomeLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/lambda/payments-slackAppHome
RetentionInDays: 60
FetchBoaTransactionsLogGroup:
Type: AWS::Logs::LogGroup
Properties:
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:
FunctionName: payments-processPayrollEmail
Handler: src/processPayrollEmail.handler
Timeout: 60
EventInvokeConfig:
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 21600
DestinationConfig:
OnFailure:
Type: SQS
Destination: !GetAtt ProcessPayrollEmailDLQ.Arn
Environment:
Variables:
SLACK_BOT_TOKEN_SECRET_NAME: payments-dashboard/slack-bot-token
PAYROLL_CHANNEL_ID: C0AV5RBMYKU
PAYROLL_BATCH_QUEUE_URL: !Ref PayrollBatchQueue
Events:
EmailReceived:
Type: S3
Properties:
Bucket: !Ref PayrollEmailBucket
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: inbound/
PayrollBatch:
Type: SQS
Properties:
Queue: !GetAtt PayrollBatchQueue.Arn
BatchSize: 1
Policies:
- S3ReadPolicy:
BucketName: !Sub seahaven-payroll-emails-${AWS::AccountId}
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- kms:GenerateDataKey
- kms:DescribeKey
Resource: !Ref DynamoDbCmkArn
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/slack-bot-token-*
- SQSSendMessagePolicy:
QueueName: !GetAtt PayrollBatchQueue.QueueName
- SQSPollerPolicy:
QueueName: !GetAtt PayrollBatchQueue.QueueName
ProcessPaymentCsvFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-processPaymentCsv
Handler: src/processPaymentCsv.handler
Timeout: 120
EventInvokeConfig:
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 21600
DestinationConfig:
OnFailure:
Type: SQS
Destination: !GetAtt ProcessPaymentCsvDLQ.Arn
Environment:
Variables:
BOA_BASE_URL: https://api.bofa.com
BOA_CHECK_MGMT_SECRET_NAME: payments-dashboard/boa-check-mgmt
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet
SecurityGroupIds:
- !Ref LambdaSecurityGroup
Events:
CsvUpload:
Type: S3
Properties:
Bucket: !Ref PaymentsCsvBucket
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: suffix
Value: .csv
Policies:
- S3ReadPolicy:
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- kms:GenerateDataKey
- kms:DescribeKey
Resource: !Ref DynamoDbCmkArn
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/boa-check-mgmt-*
- 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_SECRET_NAME: payments-dashboard/slack-bot-token
SLACK_SIGNING_SECRET_NAME: payments-dashboard/slack-signing-secret
Events:
SlackEvent:
Type: HttpApi
Properties:
Path: /slack/events
Method: POST
Policies:
- DynamoDBReadPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- kms:DescribeKey
Resource: !Ref DynamoDbCmkArn
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource:
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/slack-bot-token-*
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/slack-signing-secret-*
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
Resource: "*"
FetchBoaTransactionsFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-fetchBoaTransactions
Handler: src/fetchBoaTransactions.handler
Timeout: 60
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet
SecurityGroupIds:
- !Ref LambdaSecurityGroup
Environment:
Variables:
BOA_BASE_URL: https://api.bofa.com
BOA_REPORTING_SECRET_NAME: payments-dashboard/boa-reporting
BOA_RAW_BUCKET: !Ref BoaRawBucket
Events:
DailySchedule:
Type: Schedule
Properties:
Schedule: cron(0 13 ? * MON-FRI *)
Description: Fetch BoA previous day transactions at 9am ET (13:00 UTC)
Enabled: true
# One rule for all intraday runs so they can be disabled as a unit
# (aws events disable-rule) without touching the authoritative 9am
# previous-day sweep. Fixed UTC: ~12/3/6pm ET in DST, 11/2/5pm in
# winter (accepted drift, documented in README).
IntradaySchedule:
Type: Schedule
Properties:
Schedule: cron(0 16,19,22 ? * MON-FRI *)
Description: Intraday BoA current-day sweep (~12pm/3pm/6pm ET)
Enabled: true
Input: '{"endpoint":"current-day"}'
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- Version: "2012-10-17"
Statement:
# Archive writes only: no read, no list, no other principal.
# Derived from the bucket resource so a rename can't silently
# detach the grant.
- Effect: Allow
Action: s3:PutObject
Resource: !Sub "${BoaRawBucket.Arn}/*"
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- kms:GenerateDataKey
- kms:DescribeKey
Resource: !Ref DynamoDbCmkArn
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:payments-dashboard/boa-reporting-*
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- 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
Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events
CsvBucket:
Description: S3 bucket for CSV uploads
Value: !Ref PaymentsCsvBucket
StaticOutboundIp:
Description: Static IP for BoA API whitelist
Value: !Ref NatEip
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