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>
This commit is contained in:
Adam Moussa 2026-04-02 18:19:38 -04:00
commit 59127d5ab8
18 changed files with 9018 additions and 0 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
ALLOWED_DOMAINS=acme.com
GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
ALLOWED_ORIGIN=https://accounting.acme.com
CDK_DEFAULT_ACCOUNT=
CDK_DEFAULT_REGION=us-east-1
EDI_PARTNERSHIP_ID=p-0123456789abcdef0
EDI_TRANSFORMER_ID=tr-0123456789abcdef0
EDI_SENDER_ID=ACMECORP
EDI_RECEIVER_ID=TRADEPARTNER

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
cdk.out/
.env
*.js.map
.DS_Store

265
README.md Normal file
View file

@ -0,0 +1,265 @@
# LedgerFlow Backend
Node.js serverless backend for the LedgerFlow B2B accounting app.
Runs entirely on AWS Lambda + API Gateway with Google SSO authentication.
---
## Architecture
```
Browser (Google Sign-In)
│
│ Bearer: Google ID Token
▼
API Gateway (HTTP API)
│
├── Lambda Authorizer ──── verifies Google JWT ──── DynamoDB (sessions)
│
├── POST /auth/me ← public, verifies token, returns user
├── GET /auth/config ← public, returns Google Client ID
│
├── /pos/** ← Purchase Orders Lambda
│ ├── GET /pos list (paginated)
│ ├── POST /pos create
│ ├── GET /pos/:id get one
│ ├── PUT /pos/:id update
│ ├── DELETE /pos/:id delete
│ ├── POST /pos/import bulk import
│ └── GET /pos/dynamo-scan proxy scan of customer's DynamoDB table
│
├── /invoices/** ← Invoices Lambda
│ ├── GET /invoices list
│ ├── POST /invoices create (bills against PO)
│ ├── GET /invoices/:id get one
│ ├── PUT /invoices/:id update
│ ├── PATCH /invoices/:id/status status transition
│ └── DELETE /invoices/:id delete (drafts only)
│
├── /settings/** ← Settings Lambda
│ ├── GET /settings get user settings
│ └── PUT /settings update settings
│
└── /edi/** ← EDI Lambda
├── POST /edi/submit submit to AWS B2B Data Interchange
├── POST /edi/preview preview X12 document (no submission)
├── GET /edi/transactions transaction history
├── GET /edi/transactions/:id one transaction
└── GET /edi/status/:isaControl poll for 997 ACK
DynamoDB Tables:
ledgerflow-pos Purchase Orders (app-managed)
ledgerflow-invoices Invoices
ledgerflow-edi-transactions EDI transaction log
ledgerflow-sessions Google auth sessions (TTL 7 days)
ledgerflow-settings User/company settings
External Tables:
purchase-orders Coupa POs (read-only, ship_to address for EDI)
S3 Buckets (EDI file exchange):
ledgerflow-edi-input-{account}
ledgerflow-edi-output-{account}
```
---
## Prerequisites
- Node.js 20+
- AWS CLI configured (`aws configure`)
- AWS CDK bootstrapped (`npx cdk bootstrap`)
- Google Cloud project with OAuth 2.0 credentials
- AWS B2B Data Interchange configured (optional for MVP)
---
## Setup
### 1. Install dependencies
```bash
npm install
cd infra && npm install
```
### 2. Configure environment
```bash
cp .env.example .env
# Edit .env with your values
```
Key variables:
| Variable | Where to get it |
|---|---|
| `GOOGLE_CLIENT_ID` | [Google Cloud Console](https://console.cloud.google.com/apis/credentials) → OAuth 2.0 Client ID |
| `ALLOWED_DOMAINS` | Your company's Google Workspace domain, e.g. `acme.com` |
| `ALLOWED_ORIGIN` | Your frontend URL, e.g. `https://app.acme.com` |
| `EDI_PARTNERSHIP_ID` | AWS B2B Data Interchange → Partnerships |
| `EDI_TRANSFORMER_ID` | AWS B2B Data Interchange → Transformers (X12 810) |
| `EDI_SENDER_ID` | Your ISA Sender ID (padded to 15 chars) |
| `EDI_RECEIVER_ID` | Your trading partner's ISA ID |
### 3. Set up Google OAuth
1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
2. Create a project (or use existing)
3. Enable the "Google Identity" API
4. Create an **OAuth 2.0 Client ID** → Web Application
5. Add your frontend domain to **Authorized JavaScript origins**
- `http://localhost:3000` (dev)
- `https://your-production-domain.com` (prod)
6. No redirect URIs needed — using [Google Identity Services](https://developers.google.com/identity/gsi/web) (new flow)
7. Copy the Client ID to `GOOGLE_CLIENT_ID`
### 4. Set up AWS B2B EDI (optional — app works without it)
1. Go to [AWS B2B Data Interchange](https://console.aws.amazon.com/b2bi/home)
2. Create a **Profile** (your company details)
3. Create a **Partnership** with your trading partner
4. Create a **Capability** → choose X12 / 810 Invoice
5. Create a **Transformer** for inbound/outbound mapping
6. Copy the Partnership ID and Transformer ID to your `.env`
### 5. Deploy
```bash
# Load env vars and deploy
export $(cat .env | xargs)
npm run deploy
```
CDK will output your **API Gateway URL**:
```
Outputs:
LedgerFlow.ApiUrl = https://abc123.execute-api.us-east-1.amazonaws.com
```
### 6. Connect the frontend
In your `accounting-app.html`, add before `</body>`:
```html
<script>
window.LEDGERFLOW_API_URL = "https://abc123.execute-api.us-east-1.amazonaws.com";
window.GOOGLE_CLIENT_ID = "your-client-id.apps.googleusercontent.com";
</script>
```
Or set these via your hosting environment (Cloudflare Pages, S3+CloudFront, etc.).
Replace `localStorage` calls in `accounting-app.html` with `window.LedgerFlowAPI.*` calls
(the API client is defined in `frontend-updates/login.html`).
---
## Local Development
For local testing, use AWS SAM or run Lambdas directly:
```bash
# Test a Lambda locally
node -e "
const fn = require('./lambdas/pos');
fn.handler({
requestContext: { http: { method: 'GET' } },
rawPath: '/pos',
queryStringParameters: {}
}, {}, { email: 'dev@test.com' }).then(console.log);
"
```
Or use [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/):
```bash
sam local start-api --template infra/template.yaml
```
---
## Security Notes
- **Google tokens** are verified on every request by the Lambda Authorizer — they cannot be forged
- **AWS credentials** never leave Lambda environment variables — the frontend only holds a Google ID token
- **DynamoDB access** is scoped per Lambda via least-privilege IAM roles (CDK handles this)
- **ALLOWED_DOMAINS** restricts login to your company's Google Workspace — set this in production
- **ALLOWED_ORIGIN** restricts CORS to your frontend domain — set this before going to production
- **Session TTL**: DynamoDB sessions auto-expire after 7 days of inactivity
---
## Extending
**Add a new Lambda route:**
1. Create `lambdas/myroute/index.js` exporting `handler`
2. Add to `infra/lib/ledgerflow-stack.js`:
```js
const myFn = new lambda.Function(this, "MyFn", { ...lambdaDefaults, code: lambda.Code.fromAsset("../lambdas/myroute"), ... });
addRoutes("/myroute", myFn);
```
3. Re-deploy: `npm run deploy`
**Add a new DynamoDB table:**
```js
const myTable = new dynamo.Table(this, "MyTable", {
tableName: "ledgerflow-mytable",
partitionKey: { name: "id", type: dynamo.AttributeType.STRING },
billingMode: dynamo.BillingMode.PAY_PER_REQUEST,
});
myTable.grantReadWriteData(myFn);
```
---
## File Structure
```
ledgerflow-backend/
├── lambdas/
│ ├── authorizer/ Google JWT Lambda Authorizer
│ │ └── index.js
│ ├── auth/ Auth endpoints (/auth/me, /auth/config)
│ │ └── index.js
│ ├── pos/ Purchase Orders CRUD + DynamoDB import
│ │ └── index.js
│ ├── invoices/ Invoices CRUD + PO balance tracking
│ │ └── index.js
│ └── edi/ AWS B2B EDI submission + transaction log
│ └── index.js
├── shared/
│ ├── index.js Shared utilities (responses, DynamoDB client, helpers)
│ └── package.json
├── infra/
│ ├── bin/app.js CDK entry point
│ ├── lib/ CDK stack (all AWS resources)
│ ├── cdk.json
│ └── package.json
├── .env.example
├── .gitignore
└── package.json
```
---
## EDI (X12 810) Details
The EDI lambda generates X12 004010 810 Invoice documents for Amazon Payee Central.
**Address handling:**
- **Bill-To (N1\*BT)**: Hardcoded to Amazon.com Services LLC, 410 Terry Ave N, Seattle WA 98109-5210
- **Ship-To (N1\*ST)**: Pulled from the external `purchase-orders` DynamoDB table via `ship_to` field (Coupa PO data)
- **Remit-To / Payee (N1\*RI, N1\*PE)**: From company settings
**Submit payload:**
```json
{
"invoiceId": "INV-0001-id",
"txType": "810",
"billTo": {
"name": "Override Name",
"address": "123 Main St, City, ST 12345, US"
}
}
```
`billTo` is optional — defaults to Amazon HQ if omitted.

16
infra/bin/app.js Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env node
// infra/bin/app.js
const cdk = require("aws-cdk-lib");
const { LedgerFlowStack } = require("../lib/ledgerflow-stack");
const app = new cdk.App();
new LedgerFlowStack(app, "LedgerFlow", {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION || "us-east-1",
},
description: "LedgerFlow B2B Accounting — Lambda + API Gateway + DynamoDB",
});
app.synth();

13
infra/cdk.json Normal file
View file

@ -0,0 +1,13 @@
{
"app": "node bin/app.js",
"watch": {
"include": ["**"],
"exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "tsconfig*.json", "package*.json", "yarn.lock", "node_modules", "test"]
},
"context": {
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
"@aws-cdk/core:stackRelativeExports": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true
}
}

View file

@ -0,0 +1,348 @@
// 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 };

481
infra/package-lock.json generated Normal file
View file

@ -0,0 +1,481 @@
{
"name": "ledgerflow-infra",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ledgerflow-infra",
"version": "1.0.0",
"dependencies": {
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0"
},
"devDependencies": {
"aws-cdk": "^2.130.0"
}
},
"node_modules/@aws-cdk/asset-awscli-v1": {
"version": "2.2.273",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz",
"integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.1.tgz",
"integrity": "sha512-We4bmHaowOPHr+IQR4/FyTGjRfjgBj4ICMjtqmJeBDWad3Q/6St12NT07leNtyuukv2qMhtSZJQorD8KpKTwRA==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "53.12.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.12.0.tgz",
"integrity": "sha512-xXB5Cu6uHQl8C0uYvS0gQjMwYGWQ/UcifssdzNOgTNxPky2r68mPXEJ1snvsoLhy44X9r2px0riRglIswKX4ug==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"jsonschema": "~1.4.1",
"semver": "^7.7.4"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.4.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
"version": "7.7.4",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk": {
"version": "2.1116.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1116.0.tgz",
"integrity": "sha512-CMB+FUnuuFDgsb3U3TId6JEKpA0vRErpjX2pMR44qxilisKyU8/Z4pJTH1KVRQtXQbLKIXpq73LUVkyIv1jmKQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/aws-cdk-lib": {
"version": "2.247.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.247.0.tgz",
"integrity": "sha512-jwGmLg3qycFx0G+uEhoqk6pzSg6BAiaCQpuUreHUE4BnrhcUEG202BZ+PL8oU943fDjlI/xuwaS+Icru3fecYQ==",
"bundleDependencies": [
"@balena/dockerignore",
"@aws-cdk/cloud-assembly-api",
"case",
"fs-extra",
"ignore",
"jsonschema",
"minimatch",
"punycode",
"semver",
"table",
"yaml",
"mime-types"
],
"license": "Apache-2.0",
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.273",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1",
"@aws-cdk/cloud-assembly-api": "^2.2.0",
"@aws-cdk/cloud-assembly-schema": "^53.0.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.3",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
"minimatch": "^10.2.3",
"punycode": "^2.3.1",
"semver": "^7.7.4",
"table": "^6.9.0",
"yaml": "1.10.3"
},
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"constructs": "^10.5.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
"version": "2.2.0",
"bundleDependencies": [
"jsonschema",
"semver"
],
"inBundle": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "~1.4.1",
"semver": "^7.7.4"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@aws-cdk/cloud-assembly-schema": ">=53.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": {
"version": "1.4.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": {
"version": "7.7.4",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
"version": "1.0.2",
"inBundle": true,
"license": "Apache-2.0"
},
"node_modules/aws-cdk-lib/node_modules/ajv": {
"version": "8.18.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/aws-cdk-lib/node_modules/ansi-regex": {
"version": "5.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/ansi-styles": {
"version": "4.3.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aws-cdk-lib/node_modules/astral-regex": {
"version": "2.0.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
"version": "4.0.4",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
"version": "5.0.5",
"inBundle": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/case": {
"version": "1.6.3",
"inBundle": true,
"license": "(MIT OR GPL-3.0-or-later)",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/aws-cdk-lib/node_modules/color-convert": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/color-name": {
"version": "1.1.4",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/emoji-regex": {
"version": "8.0.0",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/fast-deep-equal": {
"version": "3.1.3",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/fast-uri": {
"version": "3.1.0",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"inBundle": true,
"license": "BSD-3-Clause"
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
"version": "11.3.3",
"inBundle": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
"version": "4.2.11",
"inBundle": true,
"license": "ISC"
},
"node_modules/aws-cdk-lib/node_modules/ignore": {
"version": "5.3.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/json-schema-traverse": {
"version": "1.0.0",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
"version": "6.2.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/lodash.truncate": {
"version": "4.4.2",
"inBundle": true,
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/mime-db": {
"version": "1.52.0",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-types": {
"version": "2.1.35",
"inBundle": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/minimatch": {
"version": "10.2.5",
"inBundle": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/aws-cdk-lib/node_modules/punycode": {
"version": "2.3.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/aws-cdk-lib/node_modules/require-from-string": {
"version": "2.0.2",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/aws-cdk-lib/node_modules/semver": {
"version": "7.7.4",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/slice-ansi": {
"version": "4.0.0",
"inBundle": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
"is-fullwidth-code-point": "^3.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/aws-cdk-lib/node_modules/string-width": {
"version": "4.2.3",
"inBundle": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/strip-ansi": {
"version": "6.0.1",
"inBundle": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/aws-cdk-lib/node_modules/table": {
"version": "6.9.0",
"inBundle": true,
"license": "BSD-3-Clause",
"dependencies": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
"slice-ansi": "^4.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/universalify": {
"version": "2.0.1",
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/yaml": {
"version": "1.10.3",
"inBundle": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/constructs": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.6.0.tgz",
"integrity": "sha512-TxHOnBO5zMo/G76ykzGF/wMpEHu257TbWiIxP9K0Yv/+t70UzgBQiTqjkAsWOPC6jW91DzJI0+ehQV6xDRNBuQ==",
"license": "Apache-2.0",
"peer": true
}
}
}

19
infra/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "ledgerflow-infra",
"version": "1.0.0",
"private": true,
"main": "bin/app.js",
"scripts": {
"synth": "cdk synth",
"deploy": "cdk deploy --require-approval never",
"destroy": "cdk destroy",
"diff": "cdk diff"
},
"dependencies": {
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0"
},
"devDependencies": {
"aws-cdk": "^2.130.0"
}
}

90
lambdas/auth/index.js Normal file
View file

@ -0,0 +1,90 @@
// lambdas/auth/index.js
// Public (unauthenticated) endpoints:
// GET /auth/me — returns user profile from Google token (client calls after login)
// GET /auth/config — returns Google Client ID so the frontend can init Google Sign-In
// POST /auth/logout — clears server-side session record
const { OAuth2Client } = require("google-auth-library");
const { DynamoDBDocumentClient, DeleteCommand } = require("@aws-sdk/lib-dynamodb");
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { ok, unauthorized, serverError, CORS_HEADERS, parseBody } = require("@ledgerflow/shared");
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const SESSIONS_TABLE = process.env.SESSIONS_TABLE || "ledgerflow-sessions";
const googleClient = new OAuth2Client(GOOGLE_CLIENT_ID);
const dynamo = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" })
);
exports.handler = async (event) => {
// CORS preflight
if (event.requestContext?.http?.method === "OPTIONS") {
return { statusCode: 200, headers: CORS_HEADERS, body: "" };
}
const method = event.requestContext?.http?.method || event.httpMethod;
const path = event.rawPath || event.path || "";
try {
// ── GET /auth/config ────────────────────────────────────────────────────
// Returns public config the frontend needs to initialize Google Sign-In
if (method === "GET" && path.endsWith("/config")) {
return ok({
googleClientId: GOOGLE_CLIENT_ID,
// The frontend redirects here after Google login
// Using Google's newer Identity Services (one-tap / button flow)
// No redirect URI needed — token is returned directly to the JS callback
});
}
// ── GET /auth/me ────────────────────────────────────────────────────────
// Validates Bearer token and returns user profile
// Called immediately after Google Sign-In succeeds on the frontend
if (method === "GET" && path.endsWith("/me")) {
const authHeader = event.headers?.authorization || event.headers?.Authorization || "";
if (!authHeader.startsWith("Bearer ")) return unauthorized("Missing token");
const token = authHeader.slice(7);
const ticket = await googleClient.verifyIdToken({
idToken: token,
audience: GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload) return unauthorized("Invalid token");
return ok({
userId: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
domain: payload.hd || null, // Google Workspace hosted domain
});
}
// ── POST /auth/logout ───────────────────────────────────────────────────
// Removes server-side session (best-effort)
if (method === "POST" && path.endsWith("/logout")) {
const authHeader = event.headers?.authorization || event.headers?.Authorization || "";
if (authHeader.startsWith("Bearer ")) {
const token = authHeader.slice(7);
try {
const ticket = await googleClient.verifyIdToken({ idToken: token, audience: GOOGLE_CLIENT_ID });
const payload = ticket.getPayload();
if (payload?.sub) {
await dynamo.send(new DeleteCommand({ TableName: SESSIONS_TABLE, Key: { userId: payload.sub } }));
}
} catch {
// Best-effort; token may already be expired
}
}
return ok({ message: "Logged out" });
}
return { statusCode: 404, headers: CORS_HEADERS, body: JSON.stringify({ error: "Not Found" }) };
} catch (err) {
console.error("[AUTH]", err);
return serverError("Auth error", err);
}
};

132
lambdas/authorizer/index.js Normal file
View file

@ -0,0 +1,132 @@
// lambdas/authorizer/index.js
// Lambda Authorizer — validates Google ID tokens sent as Bearer tokens
// Attached to every protected API Gateway route
const { OAuth2Client } = require("google-auth-library");
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, GetCommand, PutCommand } = require("@aws-sdk/lib-dynamodb");
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const ALLOWED_DOMAINS = (process.env.ALLOWED_DOMAINS || "").split(",").filter(Boolean); // e.g. "yourcompany.com"
const SESSIONS_TABLE = process.env.SESSIONS_TABLE || "ledgerflow-sessions";
const googleClient = new OAuth2Client(GOOGLE_CLIENT_ID);
const dynamo = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" }),
{ marshallOptions: { removeUndefinedValues: true } }
);
// ─── Main Handler ────────────────────────────────────────────────────────────
exports.handler = async (event) => {
// Allow CORS preflight through without auth
const method = event.requestContext?.http?.method || event.httpMethod;
if (method === "OPTIONS") {
return allowPolicy("preflight", {});
}
const token = extractToken(event);
if (!token) {
console.warn("[AUTH] No token provided");
return denyPolicy("anonymous", "No token provided");
}
try {
const payload = await verifyGoogleToken(token);
// Optional: restrict to specific Google Workspace domains
if (ALLOWED_DOMAINS.length > 0) {
const domain = payload.email?.split("@")[1];
if (!ALLOWED_DOMAINS.includes(domain)) {
console.warn(`[AUTH] Domain not allowed: ${domain}`);
return denyPolicy(payload.sub, "Domain not authorized");
}
}
// Persist/update session record for audit trail
await upsertSession(payload);
console.info(`[AUTH] Granted: ${payload.email}`);
return allowPolicy(payload.sub, {
userId: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
});
} catch (err) {
console.error("[AUTH] Token verification failed:", err.message);
return denyPolicy("unknown", err.message);
}
};
// ─── Token Extraction ────────────────────────────────────────────────────────
function extractToken(event) {
// API Gateway v2 (HTTP API) passes headers differently
const authHeader =
event.headers?.authorization ||
event.headers?.Authorization ||
"";
if (authHeader.startsWith("Bearer ")) {
return authHeader.slice(7);
}
// Also accept token in query string for WebSocket / SSE use cases
return event.queryStringParameters?.token || null;
}
// ─── Google Token Verification ───────────────────────────────────────────────
async function verifyGoogleToken(idToken) {
const ticket = await googleClient.verifyIdToken({
idToken,
audience: GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload) throw new Error("Empty token payload");
if (!payload.email_verified) throw new Error("Email not verified by Google");
return payload;
}
// ─── Session Persistence ─────────────────────────────────────────────────────
async function upsertSession(payload) {
try {
await dynamo.send(new PutCommand({
TableName: SESSIONS_TABLE,
Item: {
userId: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
lastSeen: new Date().toISOString(),
// TTL: auto-expire session record after 7 days of inactivity
ttl: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60),
},
}));
} catch (err) {
// Non-fatal — don't block auth if session write fails
console.warn("[AUTH] Session upsert failed:", err.message);
}
}
// ─── IAM Policy Builders ─────────────────────────────────────────────────────
function allowPolicy(principalId, context = {}) {
return {
isAuthorized: true,
context, // passed to downstream Lambdas via event.requestContext.authorizer.lambda
};
}
function denyPolicy(principalId, reason = "") {
return {
isAuthorized: false,
context: { reason },
};
}

399
lambdas/edi/index.js Normal file
View file

@ -0,0 +1,399 @@
// lambdas/edi/index.js
// AWS B2B EDI Integration
// POST /edi/submit — submit an invoice as an X12 810 (or other TX set)
// GET /edi/transactions — list EDI transaction history
// GET /edi/transactions/:id — get one transaction + status
// GET /edi/status/:isaControl — poll for acknowledgment (997/999)
const { B2biClient, CreateTransformerCommand, StartTransformerJobCommand } = require("@aws-sdk/client-b2bi");
const {
ScanCommand, GetCommand, PutCommand, UpdateCommand,
} = require("@aws-sdk/lib-dynamodb");
const {
ok, created, badRequest, notFound, serverError,
getDocClient, TABLES, genId, parseBody, require_fields, handler,
parsePagination, paginatedResponse, decodeCursor,
} = require("@ledgerflow/shared");
const EDI_TX_TABLE = TABLES.EDI_TX;
const INV_TABLE = TABLES.INVOICES;
const AWS_REGION = process.env.AWS_REGION || "us-east-1";
const PARTNERSHIP_ID = process.env.EDI_PARTNERSHIP_ID;
const TRANSFORMER_ID = process.env.EDI_TRANSFORMER_ID; // pre-configured X12 810 transformer
const ISA_SENDER_ID = process.env.EDI_SENDER_ID || "999999999 "; // 15 chars padded
const ISA_RECEIVER_ID = process.env.EDI_RECEIVER_ID || "AMAZON ";
const DEFAULT_BILL_TO = {
name: "Amazon.com Services LLC",
address: "410 Terry Avenue North, Seattle, WA 98109-5210, United States",
};
let _b2biClient = null;
function getB2BiClient() {
if (!_b2biClient) _b2biClient = new B2biClient({ region: AWS_REGION });
return _b2biClient;
}
// ─── Router ──────────────────────────────────────────────────────────────────
exports.handler = handler(async (event, _ctx, user) => {
const method = event.requestContext?.http?.method || event.httpMethod;
const rawPath = event.rawPath || event.path || "";
const segments = rawPath.replace(/^\/edi\/?/, "").split("/").filter(Boolean);
const resource = segments[0];
const id = segments[1] || null;
const qs = event.queryStringParameters || {};
if (method === "POST" && resource === "submit") return submitEDI(event, user);
if (method === "GET" && resource === "transactions" && !id) return listTransactions(qs);
if (method === "GET" && resource === "transactions" && id) return getTransaction(id);
if (method === "GET" && resource === "status" && id) return pollStatus(id);
if (method === "POST" && resource === "preview") return previewEDI(event, user);
return { statusCode: 405, body: JSON.stringify({ error: "Method Not Allowed" }) };
});
// ─── Submit EDI Transaction ───────────────────────────────────────────────────
async function submitEDI(event, user) {
const body = parseBody(event);
require_fields(body, ["invoiceId", "txType"]);
const db = getDocClient();
const txType = body.txType; // "810", "850", etc.
const partnerId = body.partnerId || ISA_RECEIVER_ID.trim();
const SUPPORTED_TX = ["810", "850", "855", "856"];
if (!SUPPORTED_TX.includes(txType)) {
return badRequest(`Unsupported TX type: ${txType}. Supported: ${SUPPORTED_TX.join(", ")}`);
}
// Fetch invoice and company settings
const [invResult, settingsResult] = await Promise.all([
db.send(new GetCommand({ TableName: INV_TABLE, Key: { id: body.invoiceId } })),
user?.userId ? db.send(new GetCommand({ TableName: TABLES.SETTINGS, Key: { userId: user.userId } })) : null,
]);
if (!invResult.Item) return notFound("Invoice");
const invoice = invResult.Item;
const settings = settingsResult?.Item?.config || {};
const company = settings.company || {};
const ediCfg = settings.edi || {};
if (invoice.ediStatus === "submitted") {
return badRequest("Invoice already submitted via EDI");
}
// Fetch PO for ship_to address
const po = invoice.poNumber ? await fetchPOByNumber(db, invoice.poNumber) : null;
const shipTo = po?.ship_to || null;
const billTo = body.billTo || ediCfg.billTo || DEFAULT_BILL_TO;
// Build X12 document — use sender/receiver from user's EDI settings, fall back to env vars
const isaControl = generateISAControl();
const gsControl = String(Math.floor(Math.random() * 99999)).padStart(5, "0");
const stControl = "0001";
const now = new Date();
const ediDoc = buildX12(txType, invoice, {
isaControl, gsControl, stControl, now,
senderId: ediCfg.senderId || ISA_SENDER_ID,
receiverId: ediCfg.receiverId || ISA_RECEIVER_ID,
company, shipTo, billTo,
});
const txId = genId("EDI");
const txRecord = {
id: txId,
invoiceId: invoice.id,
invoiceNumber: invoice.number,
txType,
partnerId,
isaControl,
gsControl,
ediDocument: ediDoc,
status: "pending",
createdBy: user?.email || "system",
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
// ── Call AWS B2B Data Interchange ─────────────────────────────────────────
try {
if (PARTNERSHIP_ID && TRANSFORMER_ID) {
const b2bi = getB2BiClient();
// StartTransformerJob: converts our JSON invoice to X12 EDI
// and delivers to the partner via the configured capability
const jobResult = await b2bi.send(new StartTransformerJobCommand({
inputFile: {
bucketName: process.env.EDI_INPUT_BUCKET,
key: `inbound/${txId}.json`,
},
outputLocation: {
bucketName: process.env.EDI_OUTPUT_BUCKET,
prefix: `outbound/${txId}/`,
},
transformerId: TRANSFORMER_ID,
clientToken: txId,
}));
txRecord.b2biJobId = jobResult.transformerJobId;
txRecord.status = "processing";
} else {
// No B2B credentials configured — record as simulated submission
txRecord.status = "submitted";
txRecord.note = "Simulated — configure EDI_PARTNERSHIP_ID and EDI_TRANSFORMER_ID env vars for live submission";
}
} catch (err) {
console.error("[EDI] B2Bi call failed:", err);
txRecord.status = "failed";
txRecord.error = err.message;
}
// Persist EDI transaction record
await db.send(new PutCommand({ TableName: EDI_TX_TABLE, Item: txRecord }));
// Update invoice EDI status
await db.send(new UpdateCommand({
TableName: INV_TABLE,
Key: { id: invoice.id },
UpdateExpression: "SET ediStatus = :s, ediTxId = :txId, ediISA = :isa, updatedAt = :now",
ExpressionAttributeValues: {
":s": txRecord.status === "failed" ? "failed" : "submitted",
":txId": txId,
":isa": isaControl,
":now": now.toISOString(),
},
}));
return created({
transactionId: txId,
isaControl,
status: txRecord.status,
message: txRecord.note || `${txType} transaction submitted to AWS B2B EDI`,
...(txRecord.b2biJobId && { b2biJobId: txRecord.b2biJobId }),
});
}
// ─── Preview / Download EDI (no submission) ─────────────────────────────────
async function previewEDI(event, user) {
const body = parseBody(event);
require_fields(body, ["invoiceId", "txType"]);
const db = getDocClient();
const [invResult, settingsResult] = await Promise.all([
db.send(new GetCommand({ TableName: INV_TABLE, Key: { id: body.invoiceId } })),
user?.userId ? db.send(new GetCommand({ TableName: TABLES.SETTINGS, Key: { userId: user.userId } })) : null,
]);
if (!invResult.Item) return notFound("Invoice");
const invoice = invResult.Item;
const settings = settingsResult?.Item?.config || {};
const company = settings.company || {};
const ediCfg = settings.edi || {};
// Fetch PO for ship_to address
const po = invoice.poNumber ? await fetchPOByNumber(db, invoice.poNumber) : null;
const shipTo = po?.ship_to || null;
const billTo = body.billTo || ediCfg.billTo || DEFAULT_BILL_TO;
const isaControl = generateISAControl();
const gsControl = String(Math.floor(Math.random() * 99999)).padStart(5, "0");
const now = new Date();
const ediDoc = buildX12(body.txType, invoice, {
isaControl, gsControl, stControl: "0001", now,
senderId: ediCfg.senderId || ISA_SENDER_ID,
receiverId: ediCfg.receiverId || ISA_RECEIVER_ID,
company, shipTo, billTo,
});
return ok({ ediDocument: ediDoc, invoiceNumber: invoice.number, txType: body.txType });
}
// ─── List Transactions ────────────────────────────────────────────────────────
async function listTransactions(qs) {
const { limit, cursor } = parsePagination(qs);
const db = getDocClient();
const result = await db.send(new ScanCommand({
TableName: EDI_TX_TABLE,
Limit: limit,
ExclusiveStartKey: decodeCursor(cursor),
}));
const items = (result.Items || []).sort((a, b) => b.createdAt?.localeCompare(a.createdAt) || 0);
return ok(paginatedResponse(items, result.LastEvaluatedKey));
}
// ─── Get Transaction ──────────────────────────────────────────────────────────
async function getTransaction(id) {
const db = getDocClient();
const result = await db.send(new GetCommand({ TableName: EDI_TX_TABLE, Key: { id } }));
if (!result.Item) return notFound("EDI Transaction");
return ok(result.Item);
}
// ─── Poll Status ──────────────────────────────────────────────────────────────
async function pollStatus(isaControl) {
// In production: query the B2Bi API for 997/999 functional acknowledgment
// For now, scan our transaction table
const db = getDocClient();
const result = await db.send(new ScanCommand({
TableName: EDI_TX_TABLE,
FilterExpression: "isaControl = :isa",
ExpressionAttributeValues: { ":isa": isaControl },
Limit: 1,
}));
if (!result.Count) return notFound("EDI Transaction with ISA control " + isaControl);
return ok(result.Items[0]);
}
// ─── Fetch PO by Number ──────────────────────────────────────────────────────
const PO_TABLE = process.env.PURCHASE_ORDERS_TABLE || "purchase-orders";
async function fetchPOByNumber(db, poNumber) {
const result = await db.send(new GetCommand({
TableName: PO_TABLE,
Key: { po_number: poNumber },
}));
return result.Item || null;
}
// Parse a combined address string like "35 National Drive, Glastonbury, CT 06033-1211, United States"
// into { street, city, state, zip, country } for N3/N4 segments.
function parseAddress(addrStr) {
if (!addrStr) return null;
const parts = addrStr.split(",").map(s => s.trim());
if (parts.length < 3) return { street: addrStr, city: "", state: "", zip: "", country: "US" };
const street = parts[0];
const city = parts[1];
// "CT 06033-1211" or "WA 99224"
const stateZip = (parts[2] || "").split(/\s+/);
const state = stateZip[0] || "";
const zip = stateZip.slice(1).join(" ") || "";
const country = parts[3] || "US";
return { street, city, state, zip, country: country === "United States" ? "US" : country };
}
// ─── X12 Document Builder ─────────────────────────────────────────────────────
// Generates a minimal but valid X12 EDI document for the given transaction type.
// In production, AWS B2B Data Interchange handles the actual mapping via transformers.
function buildX12(txType, invoice, opts) {
const { isaControl, gsControl, stControl, now, senderId, receiverId, company, shipTo, billTo } = opts;
const d = formatDate(now); // YYMMDD
const t = formatTime(now); // HHMM
const d8 = formatDate8(now); // YYYYMMDD
const companyName = (company?.name || "").substring(0, 60);
const taxId = company?.taxId || "";
const currency = company?.currency || "USD";
const segments = [];
// ISA — Interchange Control Header
segments.push([
"ISA", "00", " ", "00", " ",
"ZZ", senderId.padEnd(15), "ZZ", receiverId.padEnd(15),
d, t, "^", "00401", isaControl, "0", "P", ":"
].join("*") + "~");
// GS — Functional Group Header
const gsFunctionId = txType === "810" ? "IN" : txType === "850" ? "PO" : "XX";
segments.push(["GS", gsFunctionId, senderId.trim(), receiverId.trim(), d8, t, gsControl, "X", "004010"].join("*") + "~");
// ST — Transaction Set Header
segments.push(["ST", txType, stControl].join("*") + "~");
if (txType === "810") {
// BIG — Beginning Segment for Invoice
segments.push(["BIG", d8, invoice.number, invoice.issueDate?.replace(/-/g, "") || d8, invoice.poNumber || ""].join("*") + "~");
// CUR — Currency
segments.push(["CUR", "BT", currency, "", "PE", currency].join("*") + "~");
// N1*BT — Bill-To
segments.push(["N1", "BT", billTo?.name || "AMAZON.COM", "92", receiverId.trim()].join("*") + "~");
if (billTo?.address) {
const addr = parseAddress(billTo.address);
if (addr) {
segments.push(["N3", addr.street].join("*") + "~");
segments.push(["N4", addr.city, addr.state, addr.zip, addr.country].join("*") + "~");
}
}
// N1*ST — Ship-To (from purchase order)
segments.push(["N1", "ST", shipTo?.name || "AMAZON.COM", "92", receiverId.trim()].join("*") + "~");
if (shipTo?.address) {
const addr = parseAddress(shipTo.address);
if (addr) {
segments.push(["N3", addr.street].join("*") + "~");
segments.push(["N4", addr.city, addr.state, addr.zip, addr.country].join("*") + "~");
}
}
// N1*RI — Remit-To (your company)
segments.push(["N1", "RI", companyName || "SELLER", "92", senderId.trim()].join("*") + "~");
// N1*PE — Payee (your company + tax ID)
if (taxId) {
segments.push(["N1", "PE", companyName || "SELLER", "FI", taxId].join("*") + "~");
} else {
segments.push(["N1", "PE", companyName || "SELLER", "92", senderId.trim()].join("*") + "~");
}
// ITD — Terms of Sale
const termsDays = parseTermsDays(invoice.terms);
segments.push(["ITD", "01", "3", "", "", String(termsDays), "", "", "", "", "", invoice.terms || "Net 30"].join("*") + "~");
// IT1 — Baseline Item Data (one per line item, with PO reference)
invoice.lineItems?.forEach((li, i) => {
segments.push(["IT1", String(i + 1), String(li.qty), "EA", String(li.unitPrice.toFixed(2)), "PE", "PO", invoice.poNumber || ""].join("*") + "~");
segments.push(["PID", "F", "", "", "", (li.description || "").substring(0, 80)].join("*") + "~");
});
// TDS — Total Monetary Value Summary (in cents)
segments.push(["TDS", String(Math.round(invoice.total * 100))].join("*") + "~");
// TXI — Tax Information
if (invoice.tax > 0) {
segments.push(["TXI", "TX", String(invoice.tax.toFixed(2)), String(invoice.taxRate)].join("*") + "~");
}
// CAD — Carrier Detail (stub, required by some Amazon flows)
// SAC — Service/Allowance/Charge (omitted unless applicable)
} else {
// Stub for other TX types — extend as needed
segments.push(["BGN", "00", invoice.number, d8, t].join("*") + "~");
}
// SE — Transaction Set Trailer (count of segments from ST to SE inclusive)
const segCount = segments.length - 2 + 1; // exclude ISA, GS; include SE itself
segments.push(["SE", String(segCount), stControl].join("*") + "~");
// GE — Functional Group Trailer
segments.push(["GE", "1", gsControl].join("*") + "~");
// IEA — Interchange Control Trailer
segments.push(["IEA", "1", isaControl].join("*") + "~");
return segments.join("\n");
}
function parseTermsDays(terms) {
if (!terms) return 30;
const match = terms.match(/(\d+)/);
if (match) return parseInt(match[1], 10);
if (terms.toLowerCase().includes("receipt")) return 0;
return 30;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function generateISAControl() {
return String(Math.floor(Math.random() * 999999999)).padStart(9, "0");
}
function formatDate(d) { return d.toISOString().slice(2, 10).replace(/-/g, ""); }
function formatDate8(d) { return d.toISOString().slice(0, 10).replace(/-/g, ""); }
function formatTime(d) { return d.toISOString().slice(11, 16).replace(":", ""); }

292
lambdas/invoices/index.js Normal file
View file

@ -0,0 +1,292 @@
// lambdas/invoices/index.js
// Invoices API
// GET /invoices — list (paginated, filterable by status/poId)
// POST /invoices — create invoice (bills against a PO)
// GET /invoices/:id — get one invoice
// PUT /invoices/:id — update invoice
// PATCH /invoices/:id/status — update status only (draft→pending→paid)
// DELETE /invoices/:id — delete (drafts only)
const {
ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand,
} = require("@aws-sdk/lib-dynamodb");
const {
ok, created, noContent, badRequest, notFound, conflict, forbidden, serverError,
getDocClient, TABLES, genId, parseBody, require_fields, handler,
parsePagination, paginatedResponse, decodeCursor,
} = require("@ledgerflow/shared");
const INV_TABLE = TABLES.INVOICES;
const POS_TABLE = TABLES.POS;
// ─── Router ──────────────────────────────────────────────────────────────────
exports.handler = handler(async (event, _ctx, user) => {
const method = event.requestContext?.http?.method || event.httpMethod;
const rawPath = event.rawPath || event.path || "";
const segments = rawPath.replace(/^\/invoices\/?/, "").split("/").filter(Boolean);
const id = segments[0] || null;
const sub = segments[1] || null;
const qs = event.queryStringParameters || {};
if (!id) {
if (method === "GET") return listInvoices(qs);
if (method === "POST") return createInvoice(event, user);
} else {
if (method === "GET" && !sub) return getInvoice(id);
if (method === "PUT" && !sub) return updateInvoice(id, event, user);
if (method === "PATCH" && sub === "status") return updateStatus(id, event, user);
if (method === "DELETE" && !sub) return deleteInvoice(id, user);
}
return { statusCode: 405, body: JSON.stringify({ error: "Method Not Allowed" }) };
});
// ─── List ─────────────────────────────────────────────────────────────────────
async function listInvoices(qs) {
const { limit, cursor } = parsePagination(qs);
const db = getDocClient();
const params = { TableName: INV_TABLE, Limit: limit, ExclusiveStartKey: decodeCursor(cursor) };
const filters = [];
const names = {};
const values = {};
if (qs.status) {
filters.push("#status = :status");
names["#status"] = "status"; values[":status"] = qs.status;
}
if (qs.poId) {
filters.push("poId = :poId");
values[":poId"] = qs.poId;
}
if (qs.vendor) {
filters.push("contains(vendor, :vendor)");
values[":vendor"] = qs.vendor;
}
if (filters.length) {
params.FilterExpression = filters.join(" AND ");
if (Object.keys(names).length) params.ExpressionAttributeNames = names;
if (Object.keys(values).length) params.ExpressionAttributeValues = values;
}
const result = await db.send(new ScanCommand(params));
// Sort newest first
const items = (result.Items || []).sort((a, b) => b.createdAt?.localeCompare(a.createdAt || "") || 0);
return ok(paginatedResponse(items, result.LastEvaluatedKey));
}
// ─── Get One ──────────────────────────────────────────────────────────────────
async function getInvoice(id) {
const db = getDocClient();
const result = await db.send(new GetCommand({ TableName: INV_TABLE, Key: { id } }));
if (!result.Item) return notFound("Invoice");
return ok(result.Item);
}
// ─── Create ───────────────────────────────────────────────────────────────────
async function createInvoice(event, user) {
const body = parseBody(event);
require_fields(body, ["number", "vendor", "lineItems"]);
if (!Array.isArray(body.lineItems) || body.lineItems.length === 0) {
return badRequest("lineItems must be a non-empty array");
}
const db = getDocClient();
const now = new Date().toISOString();
// Validate & calculate line items
const lineItems = body.lineItems.map((li, i) => {
if (!li.description) throw { statusCode: 400, message: `lineItems[${i}].description required` };
const qty = parseFloat(li.qty || li.quantity || 1);
const unitPrice = parseFloat(li.unitPrice || li.price || 0);
return { description: li.description, qty, unitPrice, total: qty * unitPrice };
});
const subtotal = lineItems.reduce((s, li) => s + li.total, 0);
const taxRate = parseFloat(body.taxRate ?? 0);
const tax = subtotal * (taxRate / 100);
const total = subtotal + tax;
// If billing against a PO, verify it exists and has remaining balance
let poRecord = null;
if (body.poId) {
const poResult = await db.send(new GetCommand({ TableName: POS_TABLE, Key: { id: body.poId } }));
if (!poResult.Item) return notFound("Referenced Purchase Order");
poRecord = poResult.Item;
const remaining = poRecord.amount - (poRecord.billed || 0);
if (total > remaining + 0.01) {
return badRequest(
`Invoice total ${fmt(total)} exceeds PO remaining balance ${fmt(remaining)} for ${poRecord.poNumber}`
);
}
}
// Prevent duplicate invoice numbers
const existing = await db.send(new ScanCommand({
TableName: INV_TABLE,
FilterExpression: "#num = :num",
ExpressionAttributeNames: { "#num": "number" },
ExpressionAttributeValues: { ":num": body.number.trim() },
Limit: 1,
}));
if (existing.Count > 0) return conflict(`Invoice number ${body.number} already exists`);
const id = genId("INV");
const invoice = {
id,
number: body.number.trim(),
vendor: body.vendor.trim(),
poId: body.poId || null,
poNumber: poRecord?.poNumber || body.poNumber || null,
issueDate: body.issueDate || now.split("T")[0],
dueDate: body.dueDate || null,
terms: body.terms || "Net 30",
lineItems,
subtotal,
taxRate,
tax,
total,
notes: body.notes || "",
status: body.status === "draft" ? "draft" : "pending",
ediStatus: "not_submitted",
createdBy: user?.email || "system",
createdAt: now,
updatedAt: now,
};
await db.send(new PutCommand({ TableName: INV_TABLE, Item: invoice }));
// Update PO billed amount
if (poRecord) {
await db.send(new UpdateCommand({
TableName: POS_TABLE,
Key: { id: body.poId },
UpdateExpression: "SET billed = billed + :amt, updatedAt = :now",
ExpressionAttributeValues: { ":amt": total, ":now": now },
}));
}
return created(invoice);
}
// ─── Update ───────────────────────────────────────────────────────────────────
async function updateInvoice(id, event, user) {
const body = parseBody(event);
const db = getDocClient();
const existing = await db.send(new GetCommand({ TableName: INV_TABLE, Key: { id } }));
if (!existing.Item) return notFound("Invoice");
if (existing.Item.status === "paid") return forbidden("Cannot edit a paid invoice");
const UPDATABLE = ["vendor", "issueDate", "dueDate", "terms", "notes"];
const now = new Date().toISOString();
const expressions = ["#updatedAt = :now"];
const names = { "#updatedAt": "updatedAt" };
const values = { ":now": now };
UPDATABLE.forEach(key => {
if (body[key] !== undefined) {
expressions.push(`#${key} = :${key}`);
names[`#${key}`] = key;
values[`:${key}`] = body[key];
}
});
const result = await db.send(new UpdateCommand({
TableName: INV_TABLE,
Key: { id },
UpdateExpression: "SET " + expressions.join(", "),
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ReturnValues: "ALL_NEW",
}));
return ok(result.Attributes);
}
// ─── Status Update ────────────────────────────────────────────────────────────
const VALID_TRANSITIONS = {
draft: ["pending", "cancelled"],
pending: ["paid", "cancelled", "draft"],
paid: [],
cancelled: [],
};
async function updateStatus(id, event, user) {
const { status } = parseBody(event);
if (!status) return badRequest("status required");
const db = getDocClient();
const existing = await db.send(new GetCommand({ TableName: INV_TABLE, Key: { id } }));
if (!existing.Item) return notFound("Invoice");
const current = existing.Item.status;
const allowed = VALID_TRANSITIONS[current] || [];
if (!allowed.includes(status)) {
return badRequest(`Cannot transition invoice from '${current}' to '${status}'`);
}
const now = new Date().toISOString();
const updateExpr = ["#status = :status", "#updatedAt = :now"];
const names = { "#status": "status", "#updatedAt": "updatedAt" };
const values = { ":status": status, ":now": now };
if (status === "paid") {
updateExpr.push("#paidAt = :paidAt", "#paidBy = :paidBy");
names["#paidAt"] = "paidAt"; names["#paidBy"] = "paidBy";
values[":paidAt"] = now; values[":paidBy"] = user?.email || "system";
}
const result = await db.send(new UpdateCommand({
TableName: INV_TABLE,
Key: { id },
UpdateExpression: "SET " + updateExpr.join(", "),
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ReturnValues: "ALL_NEW",
}));
return ok(result.Attributes);
}
// ─── Delete ───────────────────────────────────────────────────────────────────
async function deleteInvoice(id, user) {
const db = getDocClient();
const existing = await db.send(new GetCommand({ TableName: INV_TABLE, Key: { id } }));
if (!existing.Item) return notFound("Invoice");
if (existing.Item.status !== "draft") {
return forbidden("Only draft invoices can be deleted. Cancel it first.");
}
await db.send(new DeleteCommand({ TableName: INV_TABLE, Key: { id } }));
// Reverse PO billed amount if invoice was tied to a PO
if (existing.Item.poId) {
await getDocClient().send(new UpdateCommand({
TableName: POS_TABLE,
Key: { id: existing.Item.poId },
UpdateExpression: "SET billed = billed - :amt, updatedAt = :now",
ExpressionAttributeValues: {
":amt": existing.Item.total,
":now": new Date().toISOString(),
},
}));
}
return noContent();
}
// ─── Helper ───────────────────────────────────────────────────────────────────
function fmt(n) {
return "$" + parseFloat(n || 0).toFixed(2);
}

285
lambdas/pos/index.js Normal file
View file

@ -0,0 +1,285 @@
// lambdas/pos/index.js
// Purchase Orders API
// GET /pos — list all POs (paginated, filterable)
// POST /pos — create a single PO manually
// GET /pos/:id — get one PO
// PUT /pos/:id — full update
// PATCH /pos/:id — partial update (e.g. status change)
// DELETE /pos/:id — delete
// POST /pos/import — bulk import (from DynamoDB scan proxy or pasted JSON)
// GET /pos/dynamo-scan — live scan of the customer's own DynamoDB PO table
const {
ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand,
} = require("@aws-sdk/lib-dynamodb");
const {
ok, created, noContent, badRequest, notFound, conflict, serverError,
getDocClient, TABLES, genId, parseBody, require_fields, handler,
parsePagination, paginatedResponse, decodeCursor,
} = require("@ledgerflow/shared");
const TABLE = TABLES.POS;
// ─── Router ──────────────────────────────────────────────────────────────────
exports.handler = handler(async (event, _ctx, user) => {
const method = event.requestContext?.http?.method || event.httpMethod;
const rawPath = event.rawPath || event.path || "";
const segments = rawPath.replace(/^\/pos\/?/, "").split("/").filter(Boolean);
const id = segments[0] || null;
const sub = segments[1] || null;
const qs = event.queryStringParameters || {};
// POST /pos/import
if (method === "POST" && id === "import") return bulkImport(event, user);
// GET /pos/dynamo-scan
if (method === "GET" && id === "dynamo-scan") return dynamoScan(event, user);
if (!id) {
if (method === "GET") return listPOs(qs, user);
if (method === "POST") return createPO(event, user);
} else {
if (method === "GET") return getPO(id);
if (method === "PUT") return updatePO(id, event, user, false);
if (method === "PATCH") return updatePO(id, event, user, true);
if (method === "DELETE") return deletePO(id, user);
}
return { statusCode: 405, body: JSON.stringify({ error: "Method Not Allowed" }) };
});
// ─── List ─────────────────────────────────────────────────────────────────────
async function listPOs(qs, user) {
const { limit, cursor } = parsePagination(qs);
const db = getDocClient();
const params = {
TableName: TABLE,
Limit: limit,
ExclusiveStartKey: decodeCursor(cursor),
};
// Filter by status if provided
if (qs.status) {
params.FilterExpression = "#s = :s";
params.ExpressionAttributeNames = { "#s": "status" };
params.ExpressionAttributeValues = { ":s": qs.status.toLowerCase() };
}
const result = await db.send(new ScanCommand(params));
return ok(paginatedResponse(result.Items || [], result.LastEvaluatedKey, result.Count));
}
// ─── Get One ──────────────────────────────────────────────────────────────────
async function getPO(id) {
const db = getDocClient();
const result = await db.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!result.Item) return notFound("Purchase Order");
return ok(result.Item);
}
// ─── Create ───────────────────────────────────────────────────────────────────
async function createPO(event, user) {
const body = parseBody(event);
require_fields(body, ["poNumber", "vendor", "amount"]);
const db = getDocClient();
const id = genId("PO");
const now = new Date().toISOString();
const item = {
id,
poNumber: body.poNumber.trim(),
vendor: body.vendor.trim(),
amount: parseFloat(body.amount),
billed: 0,
status: (body.status || "open").toLowerCase(),
issueDate: body.issueDate || now.split("T")[0],
dueDate: body.dueDate || null,
notes: body.notes || "",
source: "manual",
createdBy: user?.email || "system",
createdAt: now,
updatedAt: now,
};
// Prevent duplicate PO numbers
const existing = await db.send(new ScanCommand({
TableName: TABLE,
FilterExpression: "poNumber = :p",
ExpressionAttributeValues: { ":p": item.poNumber },
Limit: 1,
}));
if (existing.Count > 0) return conflict(`PO number ${item.poNumber} already exists`);
await db.send(new PutCommand({ TableName: TABLE, Item: item }));
return created(item);
}
// ─── Update ───────────────────────────────────────────────────────────────────
async function updatePO(id, event, user, partial) {
const body = parseBody(event);
const db = getDocClient();
const existing = await db.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!existing.Item) return notFound("Purchase Order");
const UPDATABLE = ["vendor", "amount", "status", "issueDate", "dueDate", "notes", "poNumber"];
const updates = partial ? body : Object.fromEntries(UPDATABLE.map(k => [k, body[k] ?? existing.Item[k]]));
const expressions = [];
const names = {};
const values = { ":updatedAt": new Date().toISOString(), ":updatedBy": user?.email || "system" };
UPDATABLE.forEach(key => {
if (updates[key] !== undefined) {
expressions.push(`#${key} = :${key}`);
names[`#${key}`] = key;
values[`:${key}`] = key === "amount" ? parseFloat(updates[key]) : updates[key];
}
});
expressions.push("#updatedAt = :updatedAt", "#updatedBy = :updatedBy");
names["#updatedAt"] = "updatedAt"; names["#updatedBy"] = "updatedBy";
const result = await db.send(new UpdateCommand({
TableName: TABLE,
Key: { id },
UpdateExpression: "SET " + expressions.join(", "),
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
ReturnValues: "ALL_NEW",
}));
return ok(result.Attributes);
}
// ─── Delete ───────────────────────────────────────────────────────────────────
async function deletePO(id, user) {
const db = getDocClient();
const existing = await db.send(new GetCommand({ TableName: TABLE, Key: { id } }));
if (!existing.Item) return notFound("Purchase Order");
await db.send(new DeleteCommand({ TableName: TABLE, Key: { id } }));
return noContent();
}
// ─── Bulk Import ──────────────────────────────────────────────────────────────
async function bulkImport(event, user) {
const body = parseBody(event);
if (!Array.isArray(body.items) || body.items.length === 0) {
return badRequest("items must be a non-empty array");
}
if (body.items.length > 500) return badRequest("Maximum 500 items per import");
const db = getDocClient();
const now = new Date().toISOString();
const fieldMap = body.fieldMap || {}; // Custom field mapping from client config
const mapField = (item, key, fallback) =>
item[fieldMap[key] || key] ?? item[fallback] ?? null;
const results = { imported: 0, skipped: 0, errors: [] };
// Process in batches of 25 (DynamoDB TransactWrite limit)
const batchSize = 25;
for (let i = 0; i < body.items.length; i += batchSize) {
const batch = body.items.slice(i, i + batchSize);
await Promise.all(batch.map(async (raw, idx) => {
try {
const poNumber = mapField(raw, "poNumber", "po_number")?.toString()?.trim();
const vendor = mapField(raw, "vendor", "vendor_name")?.toString()?.trim();
const amount = parseFloat(mapField(raw, "amount", "total_amount") || 0);
if (!poNumber || !vendor) {
results.errors.push({ index: i + idx, error: "Missing poNumber or vendor" });
results.skipped++;
return;
}
// Skip if already exists
const existing = await db.send(new ScanCommand({
TableName: TABLE,
FilterExpression: "poNumber = :p",
ExpressionAttributeValues: { ":p": poNumber },
Limit: 1,
}));
if (existing.Count > 0) { results.skipped++; return; }
const item = {
id: genId("PO"),
poNumber,
vendor,
amount,
billed: 0,
status: (mapField(raw, "status", "status") || "open").toLowerCase(),
issueDate: mapField(raw, "issueDate", "issue_date") || now.split("T")[0],
dueDate: mapField(raw, "dueDate", "due_date") || null,
notes: raw.notes || raw.description || "",
source: body.source || "import",
createdBy: user?.email || "system",
createdAt: now,
updatedAt: now,
};
await db.send(new PutCommand({ TableName: TABLE, Item: item }));
results.imported++;
} catch (err) {
results.errors.push({ index: i + idx, error: err.message });
results.skipped++;
}
}));
}
return ok({ message: `Import complete`, ...results });
}
// ─── Live DynamoDB Scan (Customer's Own Table) ───────────────────────────────
// Proxies a scan against the customer's configured DynamoDB table.
// The customer's table name/field config is stored in their settings.
async function dynamoScan(event, user) {
const qs = event.queryStringParameters || {};
const filterStatus = qs.status;
const limit = Math.min(parseInt(qs.limit || "50"), 200);
// Retrieve customer's DynamoDB config from the settings table
const db = getDocClient();
const settingsResult = await db.send(new GetCommand({ TableName: TABLES.SETTINGS, Key: { userId: user.userId } }));
const dynamoCfg = settingsResult.Item?.config?.dynamo;
if (!dynamoCfg?.table) return badRequest("No DynamoDB table configured — update your settings first");
const params = { TableName: dynamoCfg.table, Limit: limit };
if (filterStatus) {
params.FilterExpression = "#s = :s";
params.ExpressionAttributeNames = { "#s": "status" };
params.ExpressionAttributeValues = { ":s": filterStatus };
}
try {
const result = await db.send(new ScanCommand(params));
// Map customer's field names to LedgerFlow's expected shape
const fields = dynamoCfg.fields || {};
const items = (result.Items || []).map(item => ({
poNumber: item[fields.poNumber || "po_number"] || "",
vendor: item[fields.vendor || "vendor_name"] || "",
amount: parseFloat(item[fields.amount || "total_amount"]) || 0,
status: item[fields.status || "status"] || "open",
issueDate: item[fields.issueDate || "issue_date"] || "",
dueDate: item[fields.dueDate || "due_date"] || "",
_raw: item,
}));
return ok({ items, count: result.Count });
} catch (err) {
if (err.name === "ResourceNotFoundException") return notFound(`Table ${dynamoCfg.table}`);
return serverError(`DynamoDB scan failed: ${err.message}`, err);
}
}

60
lambdas/settings/index.js Normal file
View file

@ -0,0 +1,60 @@
// lambdas/settings/index.js
// User settings CRUD — stores config (dynamo, edi, company) and invCounter per user
const { GetCommand, PutCommand } = require("@aws-sdk/lib-dynamodb");
const { getDocClient, TABLES, ok, notFound, badRequest, serverError, parseBody, handler } = require("@ledgerflow/shared");
const TABLE = () => TABLES.SETTINGS;
// Default config shape (mirrors frontend defaults)
const DEFAULT_CONFIG = {
dynamo: { region: "us-east-1", table: "", key: "", secret: "", fields: { poNumber: "po_number", vendor: "vendor_name", amount: "total_amount", status: "status", issueDate: "issue_date", dueDate: "due_date" }, connected: false },
edi: { region: "us-east-1", partnershipId: "", key: "", secret: "", senderId: "", receiverId: "", transformerId: "", connected: false },
company: { name: "", taxId: "", terms: "Net 30", currency: "USD", invPrefix: "INV-", taxRate: 0 },
};
async function getSettings(event, user) {
const doc = getDocClient();
const result = await doc.send(new GetCommand({ TableName: TABLE(), Key: { userId: user.userId } }));
if (!result.Item) {
return ok({ config: DEFAULT_CONFIG, invCounter: 1 });
}
return ok({ config: result.Item.config, invCounter: result.Item.invCounter });
}
async function putSettings(event, user) {
const body = parseBody(event);
if (!body.config && body.invCounter === undefined) {
return badRequest("Request body must include config and/or invCounter");
}
const doc = getDocClient();
// Merge with existing settings so partial updates work
const existing = await doc.send(new GetCommand({ TableName: TABLE(), Key: { userId: user.userId } }));
const current = existing.Item || { config: DEFAULT_CONFIG, invCounter: 1 };
const item = {
userId: user.userId,
config: body.config !== undefined ? body.config : current.config,
invCounter: body.invCounter !== undefined ? body.invCounter : current.invCounter,
updatedAt: new Date().toISOString(),
};
await doc.send(new PutCommand({ TableName: TABLE(), Item: item }));
return ok({ config: item.config, invCounter: item.invCounter });
}
// ── Router ────────────────────────────────────────────────────────────────────
exports.handler = handler(async (event, context, user) => {
if (!user) return { statusCode: 401, body: "Unauthorized" };
const method = event.requestContext?.http?.method || event.httpMethod;
switch (method) {
case "GET": return getSettings(event, user);
case "PUT": return putSettings(event, user);
default: return badRequest(`Method ${method} not allowed on /settings`);
}
});

6342
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "ledgerflow-backend",
"version": "1.0.0",
"description": "LedgerFlow B2B Accounting — AWS Lambda backend",
"private": true,
"workspaces": [
"lambdas/*",
"shared"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"deploy": "cd infra && npx aws-cdk deploy --all",
"deploy:dev": "cd infra && npx aws-cdk deploy --all --context env=dev",
"destroy": "cd infra && npx aws-cdk destroy --all",
"test": "jest --passWithNoTests"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.136",
"@types/node": "^20.0.0",
"aws-cdk": "^2.130.0",
"aws-cdk-lib": "^2.130.0",
"constructs": "^10.3.0",
"esbuild": "^0.27.7",
"jest": "^29.7.0",
"typescript": "^5.4.0"
}
}

223
shared/index.js Normal file
View file

@ -0,0 +1,223 @@
// @ledgerflow/shared/index.js
// Shared utilities for all Lambda functions
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient } = require("@aws-sdk/lib-dynamodb");
// ─── HTTP Response Helpers ───────────────────────────────────────────────────
const ALLOWED_ORIGINS = [
process.env.ALLOWED_ORIGIN || "*",
"http://localhost:3000",
];
function getCorsHeaders(event) {
const origin = event?.headers?.origin || event?.headers?.Origin || "";
const matched = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
return {
"Access-Control-Allow-Origin": matched,
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
"Content-Type": "application/json",
};
}
// Static fallback for responses that don't have event context
const CORS_HEADERS = {
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
"Content-Type": "application/json",
};
function ok(body, statusCode = 200) {
return {
statusCode,
headers: CORS_HEADERS,
body: JSON.stringify(body),
};
}
function created(body) {
return ok(body, 201);
}
function noContent() {
return { statusCode: 204, headers: CORS_HEADERS, body: "" };
}
function badRequest(message, details = null) {
return {
statusCode: 400,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Bad Request", message, details }),
};
}
function unauthorized(message = "Unauthorized") {
return {
statusCode: 401,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Unauthorized", message }),
};
}
function forbidden(message = "Forbidden") {
return {
statusCode: 403,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Forbidden", message }),
};
}
function notFound(resource = "Resource") {
return {
statusCode: 404,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Not Found", message: `${resource} not found` }),
};
}
function conflict(message) {
return {
statusCode: 409,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Conflict", message }),
};
}
function serverError(message = "Internal server error", err = null) {
if (err) console.error("[ERROR]", err);
return {
statusCode: 500,
headers: CORS_HEADERS,
body: JSON.stringify({ error: "Internal Server Error", message }),
};
}
// ─── DynamoDB Client ─────────────────────────────────────────────────────────
let _docClient = null;
function getDocClient() {
if (!_docClient) {
const base = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
_docClient = DynamoDBDocumentClient.from(base, {
marshallOptions: { removeUndefinedValues: true },
});
}
return _docClient;
}
// ─── Table Names ─────────────────────────────────────────────────────────────
const TABLES = {
POS: process.env.POS_TABLE || "ledgerflow-pos",
INVOICES: process.env.INVOICES_TABLE || "ledgerflow-invoices",
EDI_TX: process.env.EDI_TX_TABLE || "ledgerflow-edi-transactions",
SESSIONS: process.env.SESSIONS_TABLE || "ledgerflow-sessions",
SETTINGS: process.env.SETTINGS_TABLE || "ledgerflow-settings",
};
// ─── Pagination Helper ───────────────────────────────────────────────────────
function parsePagination(queryParams = {}) {
const limit = Math.min(parseInt(queryParams.limit || "50", 10), 200);
const cursor = queryParams.cursor || null;
return { limit, cursor };
}
function paginatedResponse(items, lastKey, total = null) {
return {
items,
cursor: lastKey ? Buffer.from(JSON.stringify(lastKey)).toString("base64") : null,
...(total !== null && { total }),
};
}
function decodeCursor(cursor) {
if (!cursor) return undefined;
try {
return JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
} catch {
return undefined;
}
}
// ─── ID Generation ───────────────────────────────────────────────────────────
function genId(prefix = "") {
const ts = Date.now().toString(36);
const rand = Math.random().toString(36).slice(2, 8);
return `${prefix}${ts}${rand}`.toUpperCase();
}
// ─── Input Validation ────────────────────────────────────────────────────────
function parseBody(event) {
if (!event.body) return {};
try {
return JSON.parse(event.body);
} catch {
throw new ValidationError("Invalid JSON body");
}
}
class ValidationError extends Error {
constructor(message, details = null) {
super(message);
this.name = "ValidationError";
this.details = details;
}
}
function require_fields(obj, fields) {
const missing = fields.filter((f) => !obj[f] && obj[f] !== 0);
if (missing.length) {
throw new ValidationError(`Missing required fields: ${missing.join(", ")}`);
}
}
// ─── Lambda Handler Wrapper ──────────────────────────────────────────────────
// Catches errors, handles OPTIONS preflight, extracts auth context
function handler(fn) {
return async (event, context) => {
const corsHeaders = getCorsHeaders(event);
// CORS preflight
if (event.requestContext?.http?.method === "OPTIONS" || event.httpMethod === "OPTIONS") {
return { statusCode: 200, headers: corsHeaders, body: "" };
}
// Attach authenticated user from authorizer context
const user = event.requestContext?.authorizer?.lambda || null;
try {
const response = await fn(event, context, user);
// Override CORS origin with the matched request origin
if (response?.headers) response.headers["Access-Control-Allow-Origin"] = corsHeaders["Access-Control-Allow-Origin"];
return response;
} catch (err) {
if (err instanceof ValidationError) {
return badRequest(err.message, err.details);
}
console.error("[UNHANDLED]", err);
return serverError("An unexpected error occurred", err);
}
};
}
module.exports = {
// Response
ok, created, noContent, badRequest, unauthorized, forbidden,
notFound, conflict, serverError,
CORS_HEADERS,
// DynamoDB
getDocClient, TABLES,
// Pagination
parsePagination, paginatedResponse, decodeCursor,
// Util
genId, parseBody, require_fields, handler,
ValidationError,
};

11
shared/package.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "@ledgerflow/shared",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.540.0",
"@aws-sdk/lib-dynamodb": "^3.540.0",
"@aws-sdk/client-b2bi": "^3.540.0",
"google-auth-library": "^9.7.0"
}
}