ledgerflow-backend/lambdas/invoices/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

292 lines
11 KiB
JavaScript

// 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);
}