From 2ed6cb1cb5461bf07e357e67b978311f3872bfa8 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:40:37 -0400 Subject: [PATCH] Add invoice edit/delete with EDI modification tracking - PUT /invoices/:id now accepts lineItems, recalculates totals, and adjusts PO billed amounts when totals change - Invoices edited after EDI submission get ediStatus "modified_after_send" - DELETE relaxed from draft-only to any non-paid invoice - Update README to reflect new endpoint behavior Co-Authored-By: Claude Opus 4.6 --- README.md | 4 +-- lambdas/invoices/index.js | 57 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ad96821..a075c36 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ API Gateway (HTTP API) │ ├── GET /invoices list │ ├── POST /invoices create (bills against PO) │ ├── GET /invoices/:id get one - │ ├── PUT /invoices/:id update + │ ├── PUT /invoices/:id update (full edit with line items; marks modified_after_send if already EDI-submitted) │ ├── PATCH /invoices/:id/status status transition - │ └── DELETE /invoices/:id delete (drafts only) + │ └── DELETE /invoices/:id delete (any non-paid invoice; reverses PO billed amount) │ ├── /settings/** ← Settings Lambda │ ├── GET /settings get user settings diff --git a/lambdas/invoices/index.js b/lambdas/invoices/index.js index cd0c159..fd94e97 100644 --- a/lambdas/invoices/index.js +++ b/lambdas/invoices/index.js @@ -199,6 +199,59 @@ async function updateInvoice(id, event, user) { } }); + // 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 }, @@ -263,8 +316,8 @@ 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."); + if (existing.Item.status === "paid") { + return forbidden("Cannot delete a paid invoice"); } await db.send(new DeleteCommand({ TableName: INV_TABLE, Key: { id } })); -- 2.50.1