ledgerflow-backend/infra/lib/ledgerflow-stack.js
Adam Moussa 59127d5ab8 Initial commit — LedgerFlow backend
Lambda-based serverless backend with Google SSO, purchase orders,
invoices, and X12 810 EDI generation for Amazon Payee Central.
Includes bill-to/ship-to address support from Coupa purchase-orders table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:19:38 -04:00

348 lines
15 KiB
JavaScript

// infra/lib/ledgerflow-stack.js
// AWS CDK Stack — deploys all LedgerFlow infrastructure
// Run: cd infra && npx cdk deploy
const cdk = require("aws-cdk-lib");
const lambda = require("aws-cdk-lib/aws-lambda");
const nodejsFn = require("aws-cdk-lib/aws-lambda-nodejs");
const apigwv2 = require("aws-cdk-lib/aws-apigatewayv2");
const integ = require("aws-cdk-lib/aws-apigatewayv2-integrations");
const auth = require("aws-cdk-lib/aws-apigatewayv2-authorizers");
const dynamo = require("aws-cdk-lib/aws-dynamodb");
const iam = require("aws-cdk-lib/aws-iam");
const s3 = require("aws-cdk-lib/aws-s3");
const s3deploy = require("aws-cdk-lib/aws-s3-deployment");
const cf = require("aws-cdk-lib/aws-cloudfront");
const origins = require("aws-cdk-lib/aws-cloudfront-origins");
const acm = require("aws-cdk-lib/aws-certificatemanager");
const route53 = require("aws-cdk-lib/aws-route53");
const targets = require("aws-cdk-lib/aws-route53-targets");
const logs = require("aws-cdk-lib/aws-logs");
const path = require("path");
class LedgerFlowStack extends cdk.Stack {
constructor(scope, id, props) {
super(scope, id, props);
const env = props?.env?.account ? "prod" : "dev";
// ── DynamoDB Tables ───────────────────────────────────────────────────────
const posTable = new dynamo.Table(this, "POsTable", {
tableName: "ledgerflow-pos",
partitionKey: { name: "id", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
pointInTimeRecovery: true,
});
posTable.addGlobalSecondaryIndex({
indexName: "poNumber-index",
partitionKey: { name: "poNumber", type: dynamo.AttributeType.STRING },
});
const invoicesTable = new dynamo.Table(this, "InvoicesTable", {
tableName: "ledgerflow-invoices",
partitionKey: { name: "id", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
pointInTimeRecovery: true,
});
invoicesTable.addGlobalSecondaryIndex({
indexName: "poId-index",
partitionKey: { name: "poId", type: dynamo.AttributeType.STRING },
});
invoicesTable.addGlobalSecondaryIndex({
indexName: "status-index",
partitionKey: { name: "status", type: dynamo.AttributeType.STRING },
});
const ediTxTable = new dynamo.Table(this, "EDITxTable", {
tableName: "ledgerflow-edi-transactions",
partitionKey: { name: "id", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
timeToLiveAttribute: "ttl", // auto-expire old TX records after 1 year
});
const sessionsTable = new dynamo.Table(this, "SessionsTable", {
tableName: "ledgerflow-sessions",
partitionKey: { name: "userId", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
timeToLiveAttribute: "ttl",
});
const settingsTable = new dynamo.Table(this, "SettingsTable", {
tableName: "ledgerflow-settings",
partitionKey: { name: "userId", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.RETAIN,
pointInTimeRecovery: true,
});
// ── S3 Buckets (EDI file exchange) ────────────────────────────────────────
const ediInputBucket = new s3.Bucket(this, "EDIInputBucket", {
bucketName: `ledgerflow-edi-input-${this.account}`,
removalPolicy: cdk.RemovalPolicy.RETAIN,
versioned: true,
lifecycleRules: [{ expiration: cdk.Duration.days(90), id: "expire-old-edi" }],
});
const ediOutputBucket = new s3.Bucket(this, "EDIOutputBucket", {
bucketName: `ledgerflow-edi-output-${this.account}`,
removalPolicy: cdk.RemovalPolicy.RETAIN,
versioned: true,
lifecycleRules: [{ expiration: cdk.Duration.days(365), id: "expire-old-output" }],
});
// ── Shared Lambda Environment ─────────────────────────────────────────────
const commonEnv = {
NODE_ENV: env,
AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
POS_TABLE: posTable.tableName,
INVOICES_TABLE: invoicesTable.tableName,
EDI_TX_TABLE: ediTxTable.tableName,
SESSIONS_TABLE: sessionsTable.tableName,
SETTINGS_TABLE: settingsTable.tableName,
EDI_INPUT_BUCKET: ediInputBucket.bucketName,
EDI_OUTPUT_BUCKET: ediOutputBucket.bucketName,
// Set these via SSM Parameter Store or Secrets Manager in production:
// GOOGLE_CLIENT_ID, EDI_PARTNERSHIP_ID, EDI_TRANSFORMER_ID,
// EDI_SENDER_ID, EDI_RECEIVER_ID, ALLOWED_DOMAINS, ALLOWED_ORIGIN
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || "",
ALLOWED_ORIGIN: process.env.ALLOWED_ORIGIN || "*",
ALLOWED_DOMAINS: process.env.ALLOWED_DOMAINS || "",
EDI_PARTNERSHIP_ID: process.env.EDI_PARTNERSHIP_ID || "",
EDI_TRANSFORMER_ID: process.env.EDI_TRANSFORMER_ID || "",
EDI_SENDER_ID: process.env.EDI_SENDER_ID || "",
EDI_RECEIVER_ID: process.env.EDI_RECEIVER_ID || "",
};
const lambdaDefaults = {
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64, // Graviton2 — faster + cheaper
memorySize: 512,
timeout: cdk.Duration.seconds(30),
logRetention: logs.RetentionDays.THIRTY_DAYS,
bundling: { minify: false, sourceMap: true },
};
// ── Lambda Functions (NodejsFunction — esbuild bundles deps) ─────────────
const authorizerFn = new nodejsFn.NodejsFunction(this, "AuthorizerFn", {
...lambdaDefaults,
functionName: "ledgerflow-authorizer",
entry: path.join(__dirname, "../../lambdas/authorizer/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "Google JWT Lambda Authorizer",
});
sessionsTable.grantWriteData(authorizerFn);
const authFn = new nodejsFn.NodejsFunction(this, "AuthFn", {
...lambdaDefaults,
functionName: "ledgerflow-auth",
entry: path.join(__dirname, "../../lambdas/auth/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "Auth endpoints (/auth/me, /auth/config, /auth/logout)",
});
sessionsTable.grantReadWriteData(authFn);
const posFn = new nodejsFn.NodejsFunction(this, "POsFn", {
...lambdaDefaults,
functionName: "ledgerflow-pos",
entry: path.join(__dirname, "../../lambdas/pos/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "Purchase Orders CRUD + DynamoDB import",
});
posTable.grantReadWriteData(posFn);
settingsTable.grantReadData(posFn);
// External purchase-orders table (Coupa POs) — grant read to POs and EDI lambdas
const purchaseOrdersTable = dynamo.Table.fromTableName(this, "ExternalPOTable", "purchase-orders");
purchaseOrdersTable.grantReadData(posFn);
const invoicesFn = new nodejsFn.NodejsFunction(this, "InvoicesFn", {
...lambdaDefaults,
functionName: "ledgerflow-invoices",
entry: path.join(__dirname, "../../lambdas/invoices/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "Invoices CRUD",
});
invoicesTable.grantReadWriteData(invoicesFn);
posTable.grantReadWriteData(invoicesFn); // needs to update PO billed amount
const ediFn = new nodejsFn.NodejsFunction(this, "EDIFn", {
...lambdaDefaults,
functionName: "ledgerflow-edi",
entry: path.join(__dirname, "../../lambdas/edi/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "AWS B2B EDI submission + transaction history",
timeout: cdk.Duration.seconds(60), // EDI calls can take a few seconds
});
ediTxTable.grantReadWriteData(ediFn);
invoicesTable.grantReadWriteData(ediFn);
settingsTable.grantReadData(ediFn);
purchaseOrdersTable.grantReadData(ediFn);
ediInputBucket.grantReadWrite(ediFn);
ediOutputBucket.grantReadWrite(ediFn);
const settingsFn = new nodejsFn.NodejsFunction(this, "SettingsFn", {
...lambdaDefaults,
functionName: "ledgerflow-settings",
entry: path.join(__dirname, "../../lambdas/settings/index.js"),
handler: "handler",
environment: { ...commonEnv },
description: "User settings (config, invCounter)",
});
settingsTable.grantReadWriteData(settingsFn);
// Allow EDI Lambda to call AWS B2B Data Interchange
ediFn.addToRolePolicy(new iam.PolicyStatement({
actions: [
"b2bi:StartTransformerJob",
"b2bi:GetTransformerJob",
"b2bi:ListTransformerJobs",
"b2bi:CreateTransformer",
"b2bi:GetTransformer",
],
resources: ["*"],
}));
// ── API Gateway HTTP API ──────────────────────────────────────────────────
const httpApi = new apigwv2.HttpApi(this, "LedgerFlowAPI", {
apiName: "ledgerflow-api",
description: "LedgerFlow B2B Accounting API",
corsPreflight: {
allowOrigins: [
process.env.ALLOWED_ORIGIN || "*",
"http://localhost:3000",
],
allowMethods: [apigwv2.CorsHttpMethod.ANY],
allowHeaders: ["Content-Type", "Authorization"],
maxAge: cdk.Duration.days(1),
},
});
// Lambda Authorizer (JWT)
const jwtAuthorizer = new auth.HttpLambdaAuthorizer("GoogleJWTAuthorizer", authorizerFn, {
authorizerName: "google-jwt",
responseTypes: [auth.HttpLambdaResponseType.SIMPLE],
identitySource: ["$request.header.Authorization"],
resultsCacheTtl: cdk.Duration.minutes(5),
});
// ── Route Definitions ─────────────────────────────────────────────────────
const apiMethods = [
apigwv2.HttpMethod.GET, apigwv2.HttpMethod.POST,
apigwv2.HttpMethod.PUT, apigwv2.HttpMethod.PATCH,
apigwv2.HttpMethod.DELETE,
];
const addRoutes = (routePath, fn, useAuth = true) => {
const integration = new integ.HttpLambdaIntegration(`${fn.node.id}-integ`, fn);
const opts = useAuth ? { authorizer: jwtAuthorizer } : {};
httpApi.addRoutes({ path: routePath, methods: apiMethods, integration, ...opts });
httpApi.addRoutes({ path: `${routePath}/{proxy+}`, methods: apiMethods, integration, ...opts });
};
// Public routes (no auth)
addRoutes("/auth/config", authFn, false);
addRoutes("/auth/me", authFn, false); // Google token is self-validating
addRoutes("/auth/logout", authFn, false);
// Protected routes
addRoutes("/pos", posFn);
addRoutes("/invoices", invoicesFn);
addRoutes("/edi", ediFn);
addRoutes("/settings", settingsFn);
// ── Frontend (S3 + CloudFront + Route 53 + ACM) ─────────────────────────
const domainName = "ledgerflow.seahaven.com";
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, "SeahavenZone", {
hostedZoneId: "Z06652411XKH89KTZD3XA",
zoneName: "seahaven.com",
});
const certificate = acm.Certificate.fromCertificateArn(this, "FrontendCert",
"arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3",
);
const siteBucket = new s3.Bucket(this, "FrontendBucket", {
bucketName: `ledgerflow-frontend-${this.account}`,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
const distribution = new cf.Distribution(this, "FrontendCDN", {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED,
},
domainNames: [domainName],
certificate,
defaultRootObject: "login.html",
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: "/login.html" },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: "/login.html" },
],
});
// Deploy frontend files to S3 and invalidate CloudFront
new s3deploy.BucketDeployment(this, "DeployFrontend", {
sources: [s3deploy.Source.asset(path.join(__dirname, "../../../ledgerflow_frontend"))],
destinationBucket: siteBucket,
distribution,
distributionPaths: ["/*"],
});
// DNS records
new route53.ARecord(this, "FrontendARecord", {
zone: hostedZone,
recordName: "ledgerflow",
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
new route53.AaaaRecord(this, "FrontendAAAARecord", {
zone: hostedZone,
recordName: "ledgerflow",
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
// ── Outputs ───────────────────────────────────────────────────────────────
new cdk.CfnOutput(this, "ApiUrl", {
value: httpApi.apiEndpoint,
description: "API Gateway endpoint URL — set as VITE_API_URL in the frontend",
exportName: "LedgerFlowApiUrl",
});
new cdk.CfnOutput(this, "FrontendUrl", {
value: `https://${domainName}`,
description: "Frontend URL",
});
new cdk.CfnOutput(this, "DistributionId", {
value: distribution.distributionId,
description: "CloudFront distribution ID",
});
new cdk.CfnOutput(this, "FrontendBucketName", { value: siteBucket.bucketName });
new cdk.CfnOutput(this, "EDIInputBucketName", { value: ediInputBucket.bucketName });
new cdk.CfnOutput(this, "EDIOutputBucketName", { value: ediOutputBucket.bucketName });
}
}
module.exports = { LedgerFlowStack };