// 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]; } }); // Handle line items update (recalculate totals) if (body.lineItems && Array.isArray(body.lineItems) && body.lineItems.length > 0) { 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 ?? existing.Item.taxRate ?? 0); const tax = subtotal * (taxRate / 100); const total = subtotal + tax; // If linked to a PO, check remaining balance (excluding current invoice's amount) if (existing.Item.poId) { const poResult = await db.send(new GetCommand({ TableName: POS_TABLE, Key: { id: existing.Item.poId } })); if (poResult.Item) { const billedWithoutThis = (poResult.Item.billed || 0) - existing.Item.total; const remaining = poResult.Item.amount - billedWithoutThis; if (total > remaining + 0.01) { return badRequest( `Invoice total ${fmt(total)} exceeds PO remaining balance ${fmt(remaining)} for ${poResult.Item.poNumber}` ); } // Update PO billed amount: remove old total, add new total const diff = total - existing.Item.total; if (Math.abs(diff) > 0.001) { await db.send(new UpdateCommand({ TableName: POS_TABLE, Key: { id: existing.Item.poId }, UpdateExpression: "SET billed = billed + :diff, updatedAt = :now", ExpressionAttributeValues: { ":diff": diff, ":now": now }, })); } } } expressions.push("#lineItems = :lineItems", "#subtotal = :subtotal", "#taxRate = :taxRate", "#tax = :tax", "#total = :total"); names["#lineItems"] = "lineItems"; values[":lineItems"] = lineItems; names["#subtotal"] = "subtotal"; values[":subtotal"] = subtotal; names["#taxRate"] = "taxRate"; values[":taxRate"] = taxRate; names["#tax"] = "tax"; values[":tax"] = tax; names["#total"] = "total"; values[":total"] = total; } // If invoice was already submitted via EDI, mark as modified after send if (existing.Item.ediStatus === "submitted") { expressions.push("#ediStatus = :ediStatus"); names["#ediStatus"] = "ediStatus"; values[":ediStatus"] = "modified_after_send"; } 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 === "paid") { return forbidden("Cannot delete a paid invoice"); } 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); }