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>
292 lines
11 KiB
JavaScript
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);
|
|
}
|