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:
commit
59127d5ab8
18 changed files with 9018 additions and 0 deletions
10
.env.example
Normal file
10
.env.example
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
cdk.out/
|
||||
.env
|
||||
*.js.map
|
||||
.DS_Store
|
||||
265
README.md
Normal file
265
README.md
Normal 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
16
infra/bin/app.js
Normal 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
13
infra/cdk.json
Normal 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
|
||||
}
|
||||
}
|
||||
348
infra/lib/ledgerflow-stack.js
Normal file
348
infra/lib/ledgerflow-stack.js
Normal 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
481
infra/package-lock.json
generated
Normal 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
19
infra/package.json
Normal 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
90
lambdas/auth/index.js
Normal 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
132
lambdas/authorizer/index.js
Normal 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
399
lambdas/edi/index.js
Normal 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
292
lambdas/invoices/index.js
Normal 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
285
lambdas/pos/index.js
Normal 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
60
lambdas/settings/index.js
Normal 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
6342
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal 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
223
shared/index.js
Normal 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
11
shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue