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