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

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

399 lines
17 KiB
JavaScript

// 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(":", ""); }