2 * @file routes/invoices.js
3 * @module routes/invoices
4 * @description Invoice management API endpoints with comprehensive billing features. Handles
5 * invoice CRUD operations, line items with product catalog integration, automatic stock
6 * management, payment tracking, and void/delete operations with proper audit trails.
9 * - Invoice lifecycle management (draft → issued → paid/void)
10 * - Line items with product catalog integration
11 * - Automatic stock deduction on invoice creation
12 * - Stock adjustments on invoice updates
13 * - Restocking on void/delete operations
14 * - Low stock notifications (min_stock threshold)
15 * - Multi-tenant data isolation
16 * - Advanced search (invoice number, customer, amount, fuzzy matching)
17 * - Date range and payment status filtering
18 * - Pagination for large datasets
20 * **Invoice Workflow:**
21 * 1. Draft Creation - Create invoice with items (POST /)
22 * 2. Stock Deduction - Products automatically decremented
23 * 3. Min Stock Check - Notifications if below threshold
24 * 4. Issue Invoice - Update status to 'issued'
25 * 5. Payment - Update payment_status to 'paid'
26 * 6. Void (if needed) - Restock items, mark void with audit trail
28 * **Stock Management:**
29 * - Products with is_stock=true trigger stock adjustments
30 * - Invoice item quantity reduces stock on creation
31 * - Invoice updates compute delta and adjust accordingly
32 * - Void/delete operations restore stock levels
33 * - Min stock notifications prevent stockouts
35 * **Invoice Number Format:**
36 * - Generated format: INV-000123 (zero-padded 6 digits)
37 * - Searchable by full format, numeric ID, or fuzzy text
38 * - Computed from invoice_id for consistency
41 * - All routes require authentication (authenticateToken)
42 * - Multi-tenant filtering on all queries
43 * - Admin-only for delete operations
44 * - Void requires ownership validation
45 * - Transaction-based operations prevent data corruption
48 * - invoices table (invoice_id, customer_id, tenant_id, amount, status, payment_status)
49 * - invoice_items table (item_id, invoice_id, product_id, quantity, unit_price, total)
50 * - products table (product_id, stock_quantity, min_stock, is_stock)
51 * - customers table (customer_id, name, tenant_id)
52 * - notifications table (stock alerts)
54 * @requires ../services/db
55 * @requires ../middleware/auth
56 * @requires ../middleware/adminOnly
57 * @requires ../middleware/tenant
58 * @author IBG MSP Development Team
61 * @see {@link module:routes/contracts} for recurring billing
62 * @see {@link module:routes/products} for product catalog
63 * @see {@link module:routes/customers} for customer management
66const express = require('express');
67const router = express.Router();
68const crypto = require('crypto');
69const pool = require('../services/db');
70const authenticateToken = require('../middleware/auth');
71const requireAdmin = require('../middleware/adminOnly');
72const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
74// Apply authentication and tenant context to all routes
75router.use(authenticateToken, setTenantContext);
78 * @api {get} /invoices List Invoices
79 * @apiName GetInvoices
81 * @apiDescription Retrieves paginated list of invoices with comprehensive search and filtering
82 * capabilities. Supports fuzzy search on invoice numbers (INV-000123), customer names, amounts,
83 * and descriptions. Date range filtering and payment status filtering available.
85 * **Search Capabilities:**
86 * - Invoice number: "INV-000123", "123", "000123" (all match invoice_id 123)
87 * - Customer name: Fuzzy ILIKE search
88 * - Amount: Partial numeric match
89 * - Currency, status, description: Fuzzy matching
90 * - Exact invoice_id match for numeric input
92 * **Tenant Filtering:**
93 * Automatically filters invoices by authenticated user's tenant. Root MSP users see all
94 * tenant invoices. Regular users see only their tenant's invoices.
96 * **Response Format:**
97 * Each invoice includes customer_name and tenant_name from joined tables for display.
98 * Total count provided for pagination UI.
99 * @apiHeader {string} Authorization Bearer JWT token
100 * @apiParam {number} [page=1] Page number for pagination
101 * @apiParam {number} [limit=10] Items per page (max recommended: 100)
102 * @apiParam {string} [search] Search term (invoice number, customer, amount, description)
103 * @apiParam {string} [date_from] Filter invoices issued on or after this date (YYYY-MM-DD)
104 * @apiParam {string} [date_to] Filter invoices issued on or before this date (YYYY-MM-DD)
105 * @apiParam {string} [payment_status] Filter by payment status: 'paid', 'pending', 'overdue', or 'all'
106 * @apiSuccess {Object[]} invoices Array of invoice objects
107 * @apiSuccess {number} invoices.invoice_id Unique invoice ID
108 * @apiSuccess {number} invoices.customer_id Associated customer ID
109 * @apiSuccess {number} invoices.tenant_id Associated tenant ID
110 * @apiSuccess {number} invoices.amount Total invoice amount
111 * @apiSuccess {string} invoices.currency Currency code (USD, EUR, etc.)
112 * @apiSuccess {string} invoices.status Invoice status (draft, issued, void)
113 * @apiSuccess {string} invoices.payment_status Payment status (pending, paid, overdue)
114 * @apiSuccess {string} invoices.issued_date Date invoice was issued (ISO 8601)
115 * @apiSuccess {string} invoices.due_date Payment due date (ISO 8601)
116 * @apiSuccess {string} invoices.description Invoice description/notes
117 * @apiSuccess {String} invoices.customer_name Customer name (joined)
118 * @apiSuccess {String} invoices.tenant_name Tenant name (joined)
119 * @apiSuccess {Number} total Total count of invoices matching filters (for pagination)
121 * @apiError {Number} 500 Server error during invoice retrieval
123 * @apiExample {curl} Example Request (Search):
125 * -H "Authorization: Bearer eyJhbGc..." \
126 * "https://api.everydaytech.au/invoices?search=INV-000123&page=1&limit=20"
128 * @apiExample {curl} Example Request (Date Range):
130 * -H "Authorization: Bearer eyJhbGc..." \
131 * "https://api.everydaytech.au/invoices?date_from=2024-01-01&date_to=2024-12-31&payment_status=paid"
133 * @apiExample {json} Success Response:
143 * "status": "issued",
144 * "payment_status": "pending",
145 * "issued_date": "2024-03-01T00:00:00.000Z",
146 * "due_date": "2024-03-31T23:59:59.000Z",
147 * "description": "Monthly managed services",
148 * "customer_name": "Acme Corporation",
149 * "tenant_name": "MSP Alpha"
156 * @see {@link module:routes/invoices.getInvoice} for retrieving single invoice with items
157 * @see {@link module:routes/invoices.createInvoice} for creating invoices
159router.get('/', async (req, res) => {
160 const page = parseInt(req.query.page, 10) || 1;
161 const limit = parseInt(req.query.limit, 10) || 10;
162 const offset = (page - 1) * limit;
163 const search = req.query.search;
164 const dateFrom = req.query.date_from;
165 const dateTo = req.query.date_to;
166 const paymentStatus = req.query.payment_status;
169 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'i');
172 SELECT i.*, c.name as customer_name, t.name AS tenant_name
174 LEFT JOIN customers c ON i.customer_id = c.customer_id AND i.tenant_id = c.tenant_id
175 LEFT JOIN tenants t ON i.tenant_id = t.tenant_id
177 let countQuery = 'SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.customer_id AND i.tenant_id = c.tenant_id';
178 const params = [...tenantParams];
179 let where = tenantClause ? `WHERE ${tenantClause}` : '';
180 let paramIndex = nextParamIndex;
182 // Robust search handling: match invoice_number (INV-000123), invoice_id, or generic text
183 if (search && String(search).trim().length > 0) {
184 const raw = String(search).trim();
187 // Generic fuzzy param
188 params.push(`%${raw}%`);
189 const fuzzyIdx = paramIndex++;
191 `c.name ILIKE $${fuzzyIdx}`,
192 `i.description ILIKE $${fuzzyIdx}`,
193 `i.currency ILIKE $${fuzzyIdx}`,
194 `i.status ILIKE $${fuzzyIdx}`,
195 `CONCAT('INV-', LPAD(CAST(i.invoice_id AS TEXT), 6, '0')) ILIKE $${fuzzyIdx}`,
196 `CAST(i.amount AS TEXT) LIKE $${fuzzyIdx}`
199 // If looks like INV-000123, try matching invoice_id exactly too
200 const invMatch = raw.match(/^inv[\-\s]*0*(\d+)$/i);
202 const idVal = parseInt(invMatch[1], 10);
203 if (!Number.isNaN(idVal)) {
205 const idIdx = paramIndex++;
206 clauses.push(`i.invoice_id = $${idIdx}`);
208 } else if (/^\d+$/.test(raw)) {
209 // Pure numeric -> also try invoice_id exact
210 const idVal = parseInt(raw, 10);
212 const idIdx = paramIndex++;
213 clauses.push(`i.invoice_id = $${idIdx}`);
216 const searchClause = `(${clauses.join(' OR ')})`;
217 where += where ? ` AND ${searchClause}` : `WHERE ${searchClause}`;
221 params.push(dateFrom);
222 where += where ? ` AND i.issued_date >= $${paramIndex}` : `WHERE i.issued_date >= $${paramIndex}`;
228 where += where ? ` AND i.issued_date <= $${paramIndex}` : `WHERE i.issued_date <= $${paramIndex}`;
232 if (paymentStatus && paymentStatus !== 'all') {
233 params.push(paymentStatus);
234 where += where ? ` AND i.payment_status = $${paramIndex}` : `WHERE i.payment_status = $${paramIndex}`;
239 query += ` ${where}`;
240 countQuery += ` ${where}`;
243 query += ` ORDER BY i.issued_date DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
244 params.push(limit, offset);
246 // For count query, use all params except the trailing LIMIT/OFFSET
247 const countParams = params.slice(0, -2);
248 // Debug logs for search behavior
249 console.log('[Invoices] Query WHERE:', where);
250 console.log('[Invoices] Count params:', countParams);
251 console.log('[Invoices] Query params:', params);
253 const [totalRes, result] = await Promise.all([
254 pool.query(countQuery, countParams),
255 pool.query(query, params)
258 res.json({ invoices: result.rows, total: parseInt(totalRes.rows[0].count, 10) });
260 console.error('Error fetching invoices:', err);
261 res.status(500).json({ error: 'Server error', details: err.message });
266 * @api {post} /invoices Create Invoice
267 * @apiName CreateInvoice
269 * @apiDescription Creates a new invoice with line items and automatic stock management. Validates
270 * all items have positive quantities and non-negative prices. Calculates totals automatically if
271 * not provided. Decrements stock for products with is_stock=true. Generates low stock notifications
272 * when inventory falls below min_stock threshold.
274 * **Stock Management:**
275 * - Products with is_stock=true: Stock reduced by item quantity
276 * - Insufficient stock: Stock set to 0 with warning message
277 * - Min stock check: Notification created if new stock < min_stock
278 * - Non-stock products (services): No stock adjustments
281 * - Must include at least one item
282 * - All quantities must be > 0
283 * - All unit prices must be >= 0
284 * - Product IDs must exist in products table
287 * - Subtotal = sum(quantity * unit_price) for all items
288 * - Total defaults to subtotal (can include tax/discounts in future)
289 * - Line totals rounded to 2 decimal places
291 * **Transaction Safety:**
292 * All operations in database transaction. If any step fails (invalid product, stock issues),
293 * entire invoice creation rolled back.
294 * @apiHeader {string} Authorization Bearer JWT token
295 * @apiHeader {string} Content-Type application/json
296 * @apiParam {number} customer_id Customer ID (required)
297 * @apiParam {string} [currency=USD] Currency code
298 * @apiParam {string} [status=draft] Invoice status (draft, issued)
299 * @apiParam {string} [issued_date] Issue date (ISO 8601, defaults to NULL)
300 * @apiParam {string} [due_date] Due date (ISO 8601, defaults to NULL)
301 * @apiParam {string} [description] Invoice description/notes
302 * @apiParam {number} [subtotal] Subtotal before tax/discounts (calculated if omitted)
303 * @apiParam {number} [tax_total=0] Total tax amount
304 * @apiParam {number} [total] Final total (defaults to calculated subtotal)
305 * @apiParam {Object[]} items Array of line items (required, min 1)
306 * @apiParam {number} [items.product_id] Product ID (NULL for custom items)
307 * @apiParam {string} items.description Line item description (required)
308 * @apiParam {number} items.quantity Quantity (required, must be > 0)
309 * @apiParam {number} items.unit_price Unit price (required, must be >= 0)
310 * @apiSuccess {Object} invoice Created invoice object
311 * @apiSuccess {number} invoice.invoice_id Generated invoice ID
312 * @apiSuccess {Object[]} invoice.items Array of created line items
313 * @apiSuccess {string[]} warnings Array of warning messages (e.g., insufficient stock)
315 * @apiError {Number} 400 Invoice must include at least one item
316 * @apiError {Number} 400 All items must have quantity > 0
317 * @apiError {Number} 400 Unit price must be >= 0
318 * @apiError {Number} 400 Product id {id} not found
319 * @apiError {Number} 409 Duplicate constraint violation
320 * @apiError {Number} 500 Failed to create invoice
322 * @apiExample {curl} Example Request:
324 * -H "Authorization: Bearer eyJhbGc..." \
325 * -H "Content-Type: application/json" \
329 * "status": "issued",
330 * "issued_date": "2024-03-01",
331 * "due_date": "2024-03-31",
332 * "description": "Monthly managed services",
336 * "description": "Managed Endpoint Protection",
341 * "product_id": null,
342 * "description": "One-time setup fee",
344 * "unit_price": 499.00
348 * "https://api.everydaytech.au/invoices"
350 * @apiExample {json} Success Response:
351 * HTTP/1.1 201 Created
359 * "status": "issued",
360 * "issued_date": "2024-03-01T00:00:00.000Z",
361 * "due_date": "2024-03-31T23:59:59.000Z",
367 * "description": "Managed Endpoint Protection",
369 * "unit_price": 5.99,
375 * "product_id": null,
376 * "description": "One-time setup fee",
378 * "unit_price": 499.00,
386 * @apiExample {json} Success Response (With Stock Warning):
387 * HTTP/1.1 201 Created
389 * "invoice": { ... },
391 * "Product Managed Endpoint Protection had insufficient stock (30), set to 0"
396 * @see {@link module:routes/products} for product catalog
397 * @see {@link module:routes/invoices.updateInvoice} for updating invoices
399router.post('/', async (req, res) => {
400 const { customer_id, currency = 'AUD', status = 'draft', issued_date = null, due_date = null, description = null, subtotal = 0, tax_rate = 10.00, tax_total = 0, total = 0, items = [] } = req.body;
402 const client = await pool.connect();
406 if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'Invoice must include at least one item' });
407 for (const it of items) {
408 if (Number(it.quantity) <= 0) return res.status(400).json({ error: 'All items must have quantity > 0' });
409 if (Number(it.unit_price) < 0) return res.status(400).json({ error: 'Unit price must be >= 0' });
412 await client.query('BEGIN');
414 // calculate totals if not provided
415 let calculatedSubtotal = 0;
416 for (const it of items) {
417 const qty = Number(it.quantity || 0);
418 const unit = Number(it.unit_price || 0);
419 const lineSubtotal = Number((qty * unit).toFixed(2));
420 calculatedSubtotal += lineSubtotal;
423 const finalSubtotal = subtotal || calculatedSubtotal;
424 // Calculate GST (10% for AUD)
425 const taxRateDecimal = Number(tax_rate) / 100;
426 const finalTaxAmount = Number((finalSubtotal * taxRateDecimal).toFixed(2));
427 const finalTotal = total || Number((finalSubtotal + finalTaxAmount).toFixed(2));
429 // Generate payment token for public payment links
430 const paymentToken = crypto.randomBytes(32).toString('hex');
432 // insert invoice and persist tenant_id if available
433 const tenantId = req.tenant?.id || null;
434 console.log('[Invoices][POST] tenant:', req.tenant, 'customer_id:', customer_id, 'items:', items.length, 'subtotal:', finalSubtotal, 'tax:', finalTaxAmount, 'total:', finalTotal);
435 const invRes = await client.query(
436 `INSERT INTO invoices (customer_id, tenant_id, amount, currency, status, issued_date, due_date, description, subtotal, tax_rate, tax_amount, total, payment_token)
437 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
438 [customer_id, tenantId, finalTotal, currency, status, issued_date, due_date, description, finalSubtotal, tax_rate, finalTaxAmount, finalTotal, paymentToken]
440 const invoice = invRes.rows[0];
442 // insert items and decrement stock if needed
443 for (const it of items) {
444 const qty = Number(it.quantity || 0);
445 const unit = Number(it.unit_price || 0);
446 const lineTotal = Number((qty * unit).toFixed(2));
448 // validate product if provided
450 const pRes = await client.query('SELECT product_id, name, is_stock, stock_quantity, min_stock FROM products WHERE product_id = $1', [it.product_id]);
451 if (pRes.rows.length === 0) {
452 await client.query('ROLLBACK');
453 return res.status(400).json({ error: `Product id ${it.product_id} not found` });
455 const p = pRes.rows[0];
456 // only adjust stock for stock-managed products
458 const newStock = Math.max(0, Number(p.stock_quantity) - qty);
459 if (Number(p.stock_quantity) < qty) {
460 warnings.push(`Product ${p.name} had insufficient stock (${p.stock_quantity}), set to 0`);
462 await client.query('UPDATE products SET stock_quantity = $1 WHERE product_id = $2', [newStock, it.product_id]);
464 if (p.min_stock !== null && newStock < Number(p.min_stock)) {
465 await client.query('INSERT INTO notifications (user_id, title, message, type) VALUES ($1,$2,$3,$4)', [null, `Low stock: ${p.name}`, `Stock for product ${p.name} is low (${newStock} < ${p.min_stock})`, 'stock']);
471 `INSERT INTO invoice_items (invoice_id, product_id, description, quantity, unit_price, total) VALUES ($1,$2,$3,$4,$5,$6)`,
472 [invoice.invoice_id, it.product_id || null, it.description || null, qty, unit, lineTotal]
476 await client.query('COMMIT');
478 // fetch items to return
479 const itemsRes = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [invoice.invoice_id]);
480 invoice.items = itemsRes.rows;
481 const payload = { invoice, warnings };
482 res.status(201).json(payload);
484 await client.query('ROLLBACK');
485 console.error('Error creating invoice with items:', err);
486 if (err && err.code === '23505') {
487 return res.status(409).json({ error: 'Duplicate constraint', details: err.detail || err.message });
489 res.status(500).json({ error: 'Failed to create invoice', details: err.message });
496 * @api {get} /invoices/:id Get Invoice
497 * @apiName GetInvoice
499 * @apiDescription Retrieves a single invoice by ID with all line items included. Enforces
500 * tenant isolation through customer relationship to support legacy invoices with NULL tenant_id.
503 * Each invoice includes an items array with product details, quantities, unit prices, and
504 * line totals. Products may be stock-managed or non-stock (services, one-time items).
506 * **Tenant Validation:**
507 * Invoice must belong to customer within user's tenant scope. Root MSP users can access
508 * all invoices. Regular users limited to their tenant.
509 * @apiHeader {string} Authorization Bearer JWT token
510 * @apiParam {number} id Invoice ID (in URL path)
511 * @apiSuccess {number} invoice_id Unique invoice ID
512 * @apiSuccess {number} customer_id Associated customer ID
513 * @apiSuccess {number} tenant_id Associated tenant ID (may be NULL for legacy invoices)
514 * @apiSuccess {number} amount Total invoice amount
515 * @apiSuccess {string} currency Currency code (USD, EUR, etc.)
516 * @apiSuccess {string} status Invoice status (draft, issued, void)
517 * @apiSuccess {string} payment_status Payment status (pending, paid, overdue)
518 * @apiSuccess {string} issued_date Date invoice was issued (ISO 8601)
519 * @apiSuccess {string} due_date Payment due date (ISO 8601)
520 * @apiSuccess {string} description Invoice description/notes
521 * @apiSuccess {string} created_at Timestamp when invoice was created
522 * @apiSuccess {string} updated_at Last update timestamp
523 * @apiSuccess {Object[]} items Array of invoice line items
524 * @apiSuccess {number} items.item_id Unique line item ID
525 * @apiSuccess {number} items.invoice_id Parent invoice ID
526 * @apiSuccess {number} items.product_id Product ID (NULL for custom items)
527 * @apiSuccess {String} items.description Line item description
528 * @apiSuccess {Number} items.quantity Quantity ordered
529 * @apiSuccess {Number} items.unit_price Price per unit
530 * @apiSuccess {Number} items.total Line total (quantity * unit_price)
532 * @apiError {Number} 404 Invoice not found (or not in user's tenant)
533 * @apiError {Number} 500 Server error during retrieval
535 * @apiExample {curl} Example Request:
537 * -H "Authorization: Bearer eyJhbGc..." \
538 * "https://api.everydaytech.au/invoices/123"
540 * @apiExample {json} Success Response:
548 * "status": "issued",
549 * "payment_status": "pending",
550 * "issued_date": "2024-03-01T00:00:00.000Z",
551 * "due_date": "2024-03-31T23:59:59.000Z",
552 * "description": "Monthly managed services",
553 * "created_at": "2024-03-01T10:30:00.000Z",
554 * "updated_at": "2024-03-01T10:30:00.000Z",
560 * "description": "Managed Endpoint Protection",
562 * "unit_price": 5.99,
569 * "description": "Network Monitoring Service",
571 * "unit_price": 999.50,
578 * @see {@link module:routes/invoices.createInvoice} for creating invoices
579 * @see {@link module:routes/invoices.updateInvoice} for updating invoices
581router.get('/:id', async (req, res) => {
582 const id = req.params.id;
584 // Enforce tenant scope using customer tenant (covers older invoices with NULL tenant_id)
585 const { clause, params } = getTenantFilter(req, 'c');
586 const baseParams = [id, ...params];
587 const where = clause ? `AND ${clause}` : '';
589 const result = await pool.query(`
592 LEFT JOIN customers c ON i.customer_id = c.customer_id
593 WHERE i.invoice_id = $1 ${where}
595 if (result.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
596 const invoice = result.rows[0];
597 const itemsRes = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
598 invoice.items = itemsRes.rows;
601 console.error('Error fetching invoice:', err);
602 res.status(500).json({ error: 'Server error', details: err.message });
607 * @api {put} /invoices/:id Update Invoice
608 * @apiName UpdateInvoice
610 * @apiDescription Updates an invoice with optional line items replacement. Supports two update modes:
611 * 1. **Full items replacement** (when items array provided): Replaces all line items, calculates
612 * stock deltas, adjusts inventory accordingly, updates totals
613 * 2. **Field updates only** (no items array): Updates invoice metadata fields only
615 * **Stock Delta Calculation:**
616 * When items provided, system computes per-product changes:
617 * - Existing quantity: Sum of all existing items for each product
618 * - New quantity: Sum of all new items for each product
619 * - Delta = new - existing (positive reduces stock, negative restocks)
620 * - Stock-managed products (is_stock=true) updated accordingly
622 * **Min Stock Notifications:**
623 * If stock falls below min_stock threshold after update, notification created for low stock alert.
625 * **Tenant Security:**
626 * Invoice must belong to user's tenant. Tenant filter applied on both validation and update.
628 * **Transaction Safety:**
629 * All operations in transaction. Rollback on any error (invalid product, constraint violations).
630 * @apiHeader {string} Authorization Bearer JWT token
631 * @apiHeader {string} Content-Type application/json
632 * @apiParam {number} id Invoice ID (in URL path)
633 * @apiParam {object[]} [items] Array of line items (if provided, replaces all existing items)
634 * @apiParam {number} [items.product_id] Product ID (NULL for custom items)
635 * @apiParam {string} items.description Line item description
636 * @apiParam {number} items.quantity Quantity (must be > 0)
637 * @apiParam {number} items.unit_price Unit price (must be >= 0)
638 * @apiParam {string} [currency] Currency code
639 * @apiParam {string} [status] Invoice status (draft, issued, void)
640 * @apiParam {string} [payment_status] Payment status (pending, paid, overdue)
641 * @apiParam {string} [issued_date] Issue date (ISO 8601)
642 * @apiParam {string} [due_date] Due date (ISO 8601)
643 * @apiParam {string} [description] Invoice description/notes
644 * @apiParam {number} [amount] Total amount (auto-calculated when items provided)
645 * @apiSuccess {Object} invoice Updated invoice object
646 * @apiSuccess {Object[]} [invoice.items] Line items array (included when items were updated)
647 * @apiSuccess {string[]} [warnings] Array of warning messages (stock issues, etc.)
648 * @apiError {Number} 400 Product id {id} not found
649 * @apiError {Number} 400 No fields to update (when no items and no field changes)
650 * @apiError {Number} 404 Invoice not found (or not in user's tenant)
651 * @apiError {Number} 500 Failed to update invoice
653 * @apiExample {curl} Example Request (Update Items):
655 * -H "Authorization: Bearer eyJhbGc..." \
656 * -H "Content-Type: application/json" \
658 * "status": "issued",
662 * "description": "Managed Endpoint Protection",
668 * "https://api.everydaytech.au/invoices/123"
670 * @apiExample {curl} Example Request (Update Fields Only):
672 * -H "Authorization: Bearer eyJhbGc..." \
673 * -H "Content-Type: application/json" \
675 * "status": "issued",
676 * "payment_status": "paid",
677 * "description": "Monthly services - paid via check"
679 * "https://api.everydaytech.au/invoices/123"
681 * @apiExample {json} Success Response (With Items):
690 * "status": "issued",
691 * "updated_at": "2024-03-11T15:30:00.000Z",
697 * "description": "Managed Endpoint Protection",
699 * "unit_price": 5.99,
707 * @apiExample {json} Success Response (Fields Only):
711 * "status": "issued",
712 * "payment_status": "paid",
713 * "description": "Monthly services - paid via check",
714 * "updated_at": "2024-03-11T15:30:00.000Z"
718 * @see {@link module:routes/invoices.createInvoice} for creating invoices
719 * @see {@link module:routes/invoices.voidInvoice} for voiding invoices
721router.put('/:id', async (req, res) => {
722 const id = req.params.id;
723 const { items, ...fields } = req.body;
725 // Get tenant filter for security
726 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'inv');
728 // If items provided, handle full items replace and adjust stock accordingly
729 if (Array.isArray(items)) {
730 const client = await pool.connect();
733 await client.query('BEGIN');
735 // First verify the invoice belongs to this tenant
737 const checkQuery = `SELECT invoice_id FROM invoices inv WHERE invoice_id = $1 AND ${tenantClause}`;
738 const checkParams = [id, ...tenantParams];
739 const checkRes = await client.query(checkQuery, checkParams);
740 if (checkRes.rows.length === 0) {
741 await client.query('ROLLBACK');
743 return res.status(404).json({ error: 'Invoice not found' });
747 // load existing items grouped by product_id
748 const existingRes = await client.query('SELECT product_id, SUM(quantity) as qty FROM invoice_items WHERE invoice_id = $1 GROUP BY product_id', [id]);
749 const existingMap = new Map();
750 for (const r of existingRes.rows) {
751 existingMap.set(r.product_id ? String(r.product_id) : 'null', Number(r.qty));
754 // compute new totals per product
755 const newMap = new Map();
757 for (const it of items) {
758 const pidKey = it.product_id ? String(it.product_id) : 'null';
759 newMap.set(pidKey, (newMap.get(pidKey) || 0) + Number(it.quantity || 0));
760 totalAmount += Number((Number(it.quantity || 0) * Number(it.unit_price || 0)).toFixed(2));
763 // for each product compute delta = new - existing and adjust stock
764 for (const [pidKey, newQty] of newMap.entries()) {
765 if (pidKey === 'null') continue;
766 const pid = Number(pidKey);
767 const existingQty = existingMap.get(pidKey) || 0;
768 const delta = newQty - existingQty; // positive means reduce stock
769 if (delta === 0) continue;
770 const pRes = await client.query('SELECT name, is_stock, stock_quantity, min_stock FROM products WHERE product_id = $1', [pid]);
771 if (pRes.rows.length === 0) {
772 await client.query('ROLLBACK');
773 return res.status(400).json({ error: `Product id ${pid} not found` });
775 const p = pRes.rows[0];
778 const newStock = Math.max(0, Number(p.stock_quantity) - delta);
779 if (Number(p.stock_quantity) < delta) warnings.push(`Product ${p.name} had insufficient stock (${p.stock_quantity}), set to 0`);
780 await client.query('UPDATE products SET stock_quantity = $1 WHERE product_id = $2', [newStock, pid]);
781 if (p.min_stock !== null && newStock < Number(p.min_stock)) {
782 await client.query('INSERT INTO notifications (user_id, title, message, type) VALUES ($1,$2,$3,$4)', [null, `Low stock: ${p.name}`, `Stock for product ${p.name} is low (${newStock} < ${p.min_stock})`, 'stock']);
785 // delta < 0 -> increase stock (restock)
786 const inc = Math.abs(delta);
787 await client.query('UPDATE products SET stock_quantity = stock_quantity + $1 WHERE product_id = $2', [inc, pid]);
792 // replace items - simple approach: delete existing and insert new
793 await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
794 for (const it of items) {
795 const qty = Number(it.quantity || 0);
796 const unit = Number(it.unit_price || 0);
797 const lineTotal = Number((qty * unit).toFixed(2));
798 await client.query('INSERT INTO invoice_items (invoice_id, product_id, description, quantity, unit_price, total) VALUES ($1,$2,$3,$4,$5,$6)', [id, it.product_id || null, it.description || null, qty, unit, lineTotal]);
801 // update invoice amount and other fields
802 const updateFields = { ...fields };
804 // Calculate GST: subtotal is the sum of line items, then add 10% GST
805 const subtotal = totalAmount;
806 const taxRate = updateFields.tax_rate !== undefined ? Number(updateFields.tax_rate) : 10.00;
807 const taxAmount = Number((subtotal * (taxRate / 100)).toFixed(2));
808 const total = Number((subtotal + taxAmount).toFixed(2));
810 updateFields.subtotal = subtotal;
811 updateFields.tax_rate = taxRate;
812 updateFields.tax_amount = taxAmount;
813 updateFields.amount = total;
814 updateFields.total = total;
819 for (const k in updateFields) {
820 cols.push(`${k} = $${idx}`);
821 vals.push(updateFields[k]);
825 // Build WHERE clause with tenant filtering
826 let whereClause = `invoice_id = $${idx}`;
831 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => {
832 return `$${idx + parseInt(num) - 1}`;
834 whereClause += ` AND ${adjustedClause}`;
835 vals.push(...tenantParams);
838 const sql = `UPDATE invoices inv SET ${cols.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE ${whereClause} RETURNING *`;
839 const updRes = await client.query(sql, vals);
841 await client.query('COMMIT');
843 const invoice = updRes.rows[0];
844 const itemsRes = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
845 invoice.items = itemsRes.rows;
846 res.json({ invoice, warnings });
848 await client.query('ROLLBACK');
849 console.error('Error updating invoice with items:', err);
850 res.status(500).json({ error: 'Failed to update invoice items', details: err.message });
857 // fallback: update invoice fields only
858 const fieldsToUpdate = fields;
862 for (const key in fieldsToUpdate) {
863 cols.push(`${key} = $${idx}`);
864 vals.push(fieldsToUpdate[key]);
867 if (cols.length === 0) return res.status(400).json({ error: 'No fields to update' });
869 // Build WHERE clause with tenant filtering
870 let whereClause = `invoice_id = $${idx}`;
875 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => {
876 return `$${idx + parseInt(num) - 1}`;
878 whereClause += ` AND ${adjustedClause}`;
879 vals.push(...tenantParams);
882 const sql = `UPDATE invoices inv SET ${cols.join(', ')} , updated_at = CURRENT_TIMESTAMP WHERE ${whereClause} RETURNING *`;
885 const result = await pool.query(sql, vals);
886 if (result.rows.length === 0) {
887 return res.status(404).json({ error: 'Invoice not found' });
889 res.json(result.rows[0]);
891 console.error('Error updating invoice:', err);
892 res.status(500).json({ error: 'Failed to update invoice', details: err.message });
896// ===== VOID AND DELETE ROUTES =====
899 * @api {post} /invoices/:id/void Void Invoice
900 * @apiName VoidInvoice
902 * @apiDescription Marks invoice as void with audit trail and restocks all line items. Prevents
903 * voiding fully paid invoices (use refund flow instead). Idempotent operation - calling on
904 * already-void invoice returns success.
906 * **Voiding Process:**
907 * 1. Validate invoice exists and belongs to user's tenant
908 * 2. Check invoice not fully paid (payment_status != 'paid')
909 * 3. Restock all stock-managed products (is_stock=true)
910 * 4. Mark invoice status = 'void'
911 * 5. Record void_reason, voided_at timestamp, voided_by user
912 * 6. Return updated invoice with items
914 * **Stock Restoration:**
915 * For each line item with product_id where product.is_stock=true:
916 * - Stock quantity increased by item quantity
917 * - Reverses stock deduction from invoice creation
920 * System automatically adds audit columns on first void:
921 * - void_reason (TEXT): Reason provided or NULL
922 * - voided_at (TIMESTAMP): When invoice was voided
923 * - voided_by (INTEGER): User ID who performed void
926 * If invoice already void, returns current state without error or changes.
929 * - Requires authentication
930 * - Tenant isolation enforced through customer relationship
931 * - Cannot void fully paid invoices (payment_status='paid')
932 * @apiHeader {string} Authorization Bearer JWT token
933 * @apiHeader {string} Content-Type application/json
934 * @apiParam {number} id Invoice ID (in URL path)
935 * @apiParam {string} [reason] Reason for voiding (max 1000 chars, optional)
936 * @apiSuccess {number} invoice_id Invoice ID
937 * @apiSuccess {string} status "void"
938 * @apiSuccess {string} void_reason Reason provided or NULL
939 * @apiSuccess {string} voided_at Timestamp when voided
940 * @apiSuccess {number} voided_by User ID who performed void
941 * @apiSuccess {Object[]} items Array of invoice line items
942 * @apiError {number} 400 Cannot void a fully paid invoice
943 * @apiError {number} 404 Invoice not found (or not in user's tenant)
944 * @apiError {number} 500 Failed to void invoice
945 * @apiExample {curl} Example Request:
947 * -H "Authorization: Bearer eyJhbGc..." \
948 * -H "Content-Type: application/json" \
949 * -d '{"reason": "Customer dispute - incorrect pricing"}' \
950 * "https://api.everydaytech.au/invoices/123/void"
951 * @apiExample {json} Success Response:
960 * "payment_status": "pending",
961 * "void_reason": "Customer dispute - incorrect pricing",
962 * "voided_at": "2024-03-11T15:45:00.000Z",
969 * "unit_price": 5.99,
974 * @apiExample {json} Error Response (Paid Invoice):
975 * HTTP/1.1 400 Bad Request
977 * "error": "Cannot void a fully paid invoice"
980 * @see {@link module:routes/invoices.deleteInvoice} for deleting draft/void invoices
981 * @see {@link module:routes/invoices.updateInvoice} for updating invoices
983router.post('/:id/void', async (req, res) => {
984 const id = req.params.id;
985 const reason = (req.body && req.body.reason ? String(req.body.reason) : '').slice(0, 1000);
986 const client = await pool.connect();
988 await client.query('BEGIN');
990 // Ensure audit columns exist (Postgres supports ADD COLUMN IF NOT EXISTS)
991 await client.query("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS void_reason TEXT");
992 await client.query("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMP");
993 await client.query("ALTER TABLE invoices ADD COLUMN IF NOT EXISTS voided_by INTEGER");
995 // Enforce tenant scope when loading invoice (via customers to support legacy NULL tenant_id)
996 const { clause, params } = getTenantFilter(req, 'c');
1000 LEFT JOIN customers c ON i.customer_id = c.customer_id
1001 WHERE i.invoice_id = $1 ${clause ? 'AND ' + clause : ''}`;
1002 const invRes = await client.query(invSql, [id, ...params]);
1003 if (invRes.rows.length === 0) {
1004 await client.query('ROLLBACK');
1005 return res.status(404).json({ error: 'Invoice not found' });
1007 const invoice = invRes.rows[0];
1009 if (invoice.status === 'void') {
1010 // Idempotent: already void, just return
1011 const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
1012 invoice.items = itemsRes.rows;
1013 await client.query('COMMIT');
1014 return res.json(invoice);
1017 if (invoice.payment_status === 'paid') {
1018 await client.query('ROLLBACK');
1019 return res.status(400).json({ error: 'Cannot void a fully paid invoice' });
1022 // Restock items for stock-managed products
1023 const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
1024 for (const it of itemsRes.rows) {
1025 if (!it.product_id) continue;
1026 const pRes = await client.query('SELECT product_id, is_stock FROM products WHERE product_id = $1', [it.product_id]);
1027 if (pRes.rows.length && pRes.rows[0].is_stock) {
1028 await client.query('UPDATE products SET stock_quantity = stock_quantity + $1 WHERE product_id = $2', [Number(it.quantity || 0), it.product_id]);
1032 // Mark invoice as void with reason and audit
1033 const upd = await client.query(
1035 SET status = 'void', void_reason = $2, voided_at = CURRENT_TIMESTAMP, voided_by = $3, updated_at = CURRENT_TIMESTAMP
1036 WHERE invoice_id = $1 RETURNING *`,
1037 [id, reason || null, req.user?.user_id || null]
1039 const updated = upd.rows[0];
1040 updated.items = itemsRes.rows;
1042 await client.query('COMMIT');
1045 await client.query('ROLLBACK');
1046 console.error('Error voiding invoice:', err);
1047 res.status(500).json({ error: 'Failed to void invoice', details: err.message });
1054 * @api {delete} /invoices/:id Delete Invoice
1055 * @apiName DeleteInvoice
1056 * @apiGroup Invoices
1057 * @apiDescription Permanently deletes an invoice and all associated line items. Admin only.
1058 * Only draft or void invoices can be deleted. Paid invoices cannot be deleted (prevents
1059 * accounting manipulation). Restocks all line items before deletion.
1061 * **Deletion Rules:**
1062 * - Status must be 'draft' or 'void'
1063 * - Payment status cannot be 'paid'
1064 * - Admin role required (requireAdmin middleware)
1065 * - Invoice must belong to user's tenant
1067 * **Deletion Process:**
1068 * 1. Validate invoice exists and belongs to tenant
1069 * 2. Check payment_status != 'paid'
1070 * 3. Check status in ['draft', 'void']
1071 * 4. Restock all stock-managed products
1072 * 5. Delete all invoice_items records
1073 * 6. Delete invoice record
1074 * 7. Return success confirmation
1076 * **Stock Restoration:**
1077 * For each line item with product_id where product.is_stock=true:
1078 * - Stock quantity increased by item quantity
1079 * - Ensures no inventory loss from deleted invoices
1082 * - Admin only (requireAdmin middleware)
1083 * - Tenant isolation enforced
1084 * - Transaction-based for data integrity
1085 * - Cannot delete paid invoices
1088 * - Remove duplicate draft invoices
1089 * - Clean up test invoices
1090 * - Remove voided invoices after accounting period
1091 * @apiHeader {string} Authorization Bearer JWT token (admin role required)
1092 * @apiParam {number} id Invoice ID (in URL path)
1093 * @apiSuccess {boolean} success Always true when successful
1094 * @apiError {number} 400 Cannot delete a paid invoice
1095 * @apiError {number} 400 Only 'draft' or 'void' invoices can be deleted
1096 * @apiError {number} 404 Invoice not found (or not in user's tenant)
1097 * @apiError {number} 500 Failed to delete invoice
1098 * @apiExample {curl} Example Request:
1100 * -H "Authorization: Bearer eyJhbGc..." \
1101 * "https://api.everydaytech.au/invoices/123"
1102 * @apiExample {json} Success Response:
1107 * @apiExample {json} Error Response (Paid Invoice):
1108 * HTTP/1.1 400 Bad Request
1110 * "error": "Cannot delete a paid invoice"
1112 * @apiExample {json} Error Response (Wrong Status):
1113 * HTTP/1.1 400 Bad Request
1115 * "error": "Only 'draft' or 'void' invoices can be deleted"
1118 * @see {@link module:routes/invoices.voidInvoice} for voiding invoices
1119 * @see {@link module:routes/invoices.createInvoice} for creating invoices
1121router.delete('/:id', requireAdmin, async (req, res) => {
1122 const id = req.params.id;
1123 const client = await pool.connect();
1125 await client.query('BEGIN');
1127 // Enforce tenant scope when loading invoice
1128 const { clause, params } = getTenantFilter(req, 'c');
1132 LEFT JOIN customers c ON i.customer_id = c.customer_id
1133 WHERE i.invoice_id = $1 ${clause ? 'AND ' + clause : ''}`;
1134 const invRes = await client.query(invSql, [id, ...params]);
1135 if (invRes.rows.length === 0) {
1136 await client.query('ROLLBACK');
1137 return res.status(404).json({ error: 'Invoice not found' });
1139 const invoice = invRes.rows[0];
1141 if (invoice.payment_status === 'paid') {
1142 await client.query('ROLLBACK');
1143 return res.status(400).json({ error: 'Cannot delete a paid invoice' });
1146 if (!['draft', 'void'].includes(invoice.status)) {
1147 await client.query('ROLLBACK');
1148 return res.status(400).json({ error: "Only 'draft' or 'void' invoices can be deleted" });
1151 // Restock items before delete (in case status was 'draft')
1152 const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
1153 for (const it of itemsRes.rows) {
1154 if (!it.product_id) continue;
1155 const pRes = await client.query('SELECT product_id, is_stock FROM products WHERE product_id = $1', [it.product_id]);
1156 if (pRes.rows.length && pRes.rows[0].is_stock) {
1157 await client.query('UPDATE products SET stock_quantity = stock_quantity + $1 WHERE product_id = $2', [Number(it.quantity || 0), it.product_id]);
1161 await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
1162 await client.query('DELETE FROM invoices WHERE invoice_id = $1', [id]);
1164 await client.query('COMMIT');
1165 res.json({ success: true });
1167 await client.query('ROLLBACK');
1168 console.error('Error deleting invoice:', err);
1169 res.status(500).json({ error: 'Failed to delete invoice', details: err.message });
1175module.exports = router;