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>
399 lines
17 KiB
JavaScript
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(":", ""); }
|