3 * @brief Service Contract Management API Routes
4 * @description Comprehensive contract and service agreement management for MSP/PSA operations.
5 * Manages recurring service contracts with billing automation, line items, SLA tracking, and
6 * customer service level agreements. Supports multiple billing intervals, auto-invoicing, and
7 * resource limits (devices, contacts, products).
10 * - Contract lifecycle management (active, inactive, deleted status)
11 * - Flexible billing intervals (monthly, yearly, custom dates)
12 * - Auto-invoice generation with configurable schedules
13 * - Line items with recurring/one-time charges
14 * - Resource limits (max devices, contacts, products)
15 * - SLA hours configuration per contract
16 * - Labor rate and currency settings
17 * - Multi-tenant isolation
18 * - Schema-aware dynamic column handling (backwards compatible)
20 * **Billing Features:**
21 * - Automatic invoice generation via background worker
22 * - Next billing date calculation
23 * - Custom billing day support
24 * - Custom billing date arrays for irregular schedules
25 * - Manual invoice generation endpoint
27 * **Contract Line Items:**
28 * - Product-linked or custom line items
29 * - Quantity and unit price tracking
30 * - Recurring vs one-time charges
31 * - Sort order for display
32 * - Graceful handling if table doesn't exist (migration safe)
34 * **Database Schema:**
35 * - contracts: Main contract records with billing config
36 * - contract_line_items: Individual charges and products
37 * - Foreign keys: customers, tenants
38 * @module routes/contracts
40 * @requires services/db
41 * @requires middleware/auth
42 * @requires middleware/adminOnly
43 * @requires middleware/tenant
44 * @requires contractBillingWorker
45 * @author RMM-PSA Platform
49const express = require('express');
50const router = express.Router();
51const pool = require('../services/db');
52const authenticateToken = require('../middleware/auth');
53const requireAdmin = require('../middleware/adminOnly');
54const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
56// Apply authentication and tenant context to all routes
57router.use(authenticateToken, setTenantContext);
59// Utility: list existing columns for a table in current schema
61 * Get table columns from database schema
62 * @param {object} executor - Database connection pool or client
63 * @param {string} tableName - Name of the table to check
64 * @returns {Promise<Set<string>>} Set of column names
66async function getTableColumns(executor = pool, tableName) {
67 const res = await executor.query(
68 `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
71 return new Set(res.rows.map(r => r.column_name));
74// Utility: check if contract_line_items table exists
76 * Check if contract_line_items table exists in schema
77 * @param {object} executor - Database connection pool or client
78 * @returns {Promise<boolean>} True if table exists
80async function contractLineItemsTableExists(executor = pool) {
82 const res = await executor.query(
83 `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'contract_line_items')`
85 return res.rows[0]?.exists || false;
94 * @api {get} /contracts List all contracts
95 * @apiName ListContracts
97 * @apiDescription Retrieves paginated list of service contracts with comprehensive filtering.
98 * Returns contracts with customer names, tenant names, and supports search, status filtering,
99 * billing interval filtering, date ranges, and customer-specific filtering. Multi-tenant isolated.
101 * **Query Parameters:**
102 * - page (default: 1): Page number
103 * - limit (default: 10): Results per page
104 * - search: Search in title, description, or customer name
105 * - status: Filter by status (active, inactive, deleted, or all)
106 * - billing_interval: Filter by billing type (monthly, yearly, custom, or all)
107 * - customer_id: Filter by specific customer
108 * - date_from: Filter contracts starting after this date
109 * - date_to: Filter contracts starting before this date
111 * **Billing Intervals:**
112 * - monthly: Billed every month on specified billing_day
113 * - yearly: Billed annually on anniversary of start_date
114 * - custom: Billed on dates specified in custom_billing_dates array
115 * @apiHeader {string} Authorization Bearer JWT token with tenant context
116 * @apiParam {number} [page=1] Page number
117 * @apiParam {number} [limit=10] Results per page
118 * @apiParam {string} [search] Search query
119 * @apiParam {string} [status] Status filter
120 * @apiParam {string} [billing_interval] Billing interval filter
121 * @apiParam {number} [customer_id] Customer ID filter
122 * @apiParam {string} [date_from] Start date (ISO 8601)
123 * @apiParam {string} [date_to] End date (ISO 8601)
124 * @apiSuccess {Array} contracts Array of contract objects with customer/tenant names
125 * @apiSuccess {number} total Total count of matching contracts
126 * @apiExample {curl} List Active Monthly Contracts:
127 * curl -H "Authorization: Bearer eyJhbGc..." \\
128 * "https://api.everydaytech.au/contracts?status=active&billing_interval=monthly"
129 * @apiExample {json} Success Response:
135 * "title": "Managed Services Agreement",
137 * "customer_name": "Acme Corp",
138 * "status": "active",
139 * "billing_interval": "monthly",
140 * "start_date": "2025-01-01",
141 * "next_billing_date": "2026-03-01",
142 * "auto_invoice_enabled": true,
144 * "labor_rate": 150.00,
151 * @see {@link module:routes/contracts.getContract} for single contract details
153router.get('/', async (req, res) => {
154 const page = parseInt(req.query.page, 10) || 1;
155 const limit = parseInt(req.query.limit, 10) || 10;
156 const offset = (page - 1) * limit;
157 const search = req.query.search;
158 const status = req.query.status;
159 const billingInterval = req.query.billing_interval;
160 const dateFrom = req.query.date_from;
161 const dateTo = req.query.date_to;
162 const customerId = req.query.customer_id;
164const ContractsModel = require('../models/Contracts');
165// ...existing code...
168 * @api {get} /contracts/tenant/:tenantId Get contract for tenant
169 * @apiName GetContractForTenant
170 * @apiGroup Contracts
171 * @apiDescription Retrieves the primary service contract associated with a specific tenant.
172 * Used for tenant-level billing configuration and service agreement lookup. Returns 404
173 * if no contract found for the tenant.
176 * Primarily used for looking up the main service agreement for a sub-tenant in an
177 * MSP hierarchy. Each tenant typically has one primary contract defining their
178 * service level and billing terms.
179 * @apiHeader {string} Authorization Bearer JWT token
180 * @apiParam {number} tenantId Tenant ID (URL parameter)
181 * @apiSuccess {object} contract Contract object for the tenant
182 * @apiError {number} 404 Contract not found for tenant
183 * @apiError {number} 500 Server error
184 * @apiExample {curl} Example Request:
185 * curl -H "Authorization: Bearer eyJhbGc..." \
186 * "https://api.everydaytech.au/contracts/tenant/5"
188 * @see {@link module:models/Contracts.getContractForTenant} for model implementation
190router.get('/tenant/:tenantId', authenticateToken, async (req, res) => {
192 const contract = await ContractsModel.getContractForTenant(req.params.tenantId);
194 return res.status(404).json({ error: 'Contract not found for tenant' });
198 console.error('Error fetching contract for tenant:', err);
199 res.status(500).json({ error: 'Server error', details: err.message });
204 * @api {get} /contracts/_schema Get contracts schema info
205 * @apiName GetContractsSchema
206 * @apiGroup Contracts
207 * @apiDescription Debug endpoint that exposes current database schema information for the
208 * contracts and contract_line_items tables. Returns list of columns and table existence
209 * status. Used for development, debugging, and migration verification.
211 * **Development Only:**
212 * This is a helper endpoint for developers to verify schema state and column existence.
213 * Should be restricted in production environments.
214 * @apiSuccess {Array} contracts_columns List of column names in contracts table
215 * @apiSuccess {boolean} has_contract_line_items Whether contract_line_items table exists
216 * @apiError {number} 500 Schema query failed
217 * @apiExample {curl} Example Request:
218 * curl "https://api.everydaytech.au/contracts/_schema"
219 * @apiExample {json} Success Response:
222 * "contracts_columns": [
223 * "contract_id", "customer_id", "title", "status",
224 * "billing_interval", "auto_invoice_enabled", "sla_hours"
226 * "has_contract_line_items": true
230router.get('/_schema', async (req, res) => {
232 const cols = await getTableColumns(pool, 'contracts');
233 const hasLineItems = await contractLineItemsTableExists(pool);
234 res.json({ contracts_columns: Array.from(cols), has_contract_line_items: hasLineItems });
236 res.status(500).json({ error: 'schema query failed', details: e.message });
242 // Debug: log request context
243 console.log(`[API][contracts] GET /contracts | Tenant:`, req.tenant, '| Query:', req.query);
246 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'c');
248 SELECT c.*, cust.name as customer_name, t.name as tenant_name
250 LEFT JOIN customers cust ON c.customer_id = cust.customer_id
251 LEFT JOIN tenants t ON c.tenant_id = t.tenant_id
253 let countQuery = 'SELECT COUNT(*) FROM contracts c LEFT JOIN customers cust ON c.customer_id = cust.customer_id';
255 const params = [...tenantParams];
256 let paramIndex = nextParamIndex;
259 if (tenantClause) where.push(tenantClause);
263 params.push(`%${search}%`);
264 where.push(`(c.title ILIKE $${paramIndex} OR cust.name ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
270 params.push(customerId);
271 where.push(`c.customer_id = $${paramIndex}`);
276 if (status && status !== 'all') {
278 where.push(`c.status = $${params.length}`);
281 // Billing interval filter
282 if (billingInterval && billingInterval !== 'all') {
283 params.push(billingInterval);
284 where.push(`c.billing_interval = $${params.length}`);
287 // Date range filter (start_date)
289 params.push(dateFrom);
290 where.push(`c.start_date >= $${params.length}`);
294 where.push(`c.start_date <= $${params.length}`);
298 const clause = ' WHERE ' + where.join(' AND ');
300 countQuery += clause;
303 params.push(limit, offset);
304 query += ` ORDER BY c.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`;
306 // Debug: log final query and params
307 console.log(`[API][contracts] Executing query:`, query, '| Params:', params);
308 console.log(`[API][contracts] Executing countQuery:`, countQuery, '| Params:', params.slice(0, params.length - 2));
310 const [totalRes, result] = await Promise.all([
311 pool.query(countQuery, params.slice(0, params.length - 2)),
312 pool.query(query, params)
315 res.json({ contracts: result.rows, total: parseInt(totalRes.rows[0].count, 10) });
317 console.error('[API][contracts] Error fetching contracts:', err, '| Tenant:', req.tenant, '| Query:', req.query);
318 res.status(500).json({ error: 'Server error', details: err.message });
323 * @api {get} /contracts/:id Get contract details
324 * @apiName GetContract
325 * @apiGroup Contracts
326 * @apiDescription Retrieves complete contract information including all line items.
327 * Returns contract details with customer name, tenant name, and all associated
328 * line items ordered by sort_order. Gracefully handles missing line items table.
331 * - Full contract details (billing config, dates, limits)
332 * - Customer name and tenant name (via LEFT JOIN)
333 * - All line items with product links, pricing, quantities
334 * - SLA hours (defaults to 24 if null)
337 * Each line item includes:
338 * - product_id: Linked product (if applicable)
339 * - description: Line item description
340 * - quantity: Number of units
341 * - unit_price: Price per unit
342 * - recurring: Whether charge repeats each billing cycle
343 * - sort_order: Display order
344 * @apiHeader {string} Authorization Bearer JWT token with tenant context
345 * @apiParam {number} id Contract ID (URL parameter)
346 * @apiSuccess {object} contract Complete contract object
347 * @apiSuccess {Array} contract.line_items Array of line item objects
348 * @apiError {number} 404 Contract not found or access denied
349 * @apiError {number} 500 Server error
350 * @apiExample {curl} Example Request:
351 * curl -H "Authorization: Bearer eyJhbGc..." \
352 * "https://api.everydaytech.au/contracts/5"
353 * @apiExample {json} Success Response:
357 * "title": "Managed Services",
359 * "customer_name": "Acme Corp",
360 * "status": "active",
361 * "billing_interval": "monthly",
363 * "auto_invoice_enabled": true,
364 * "next_billing_date": "2026-04-01",
366 * "labor_rate": 150.00,
370 * "line_item_id": 10,
372 * "description": "Managed Desktop Support",
374 * "unit_price": 50.00,
381 * @see {@link module:routes/contracts.updateContract} for updating contracts
383router.get('/:id', authenticateToken, async (req, res) => {
384 const id = req.params.id;
386 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
388 SELECT c.*, cust.name as customer_name, t.name as tenant_name
390 LEFT JOIN customers cust ON c.customer_id = cust.customer_id
391 LEFT JOIN tenants t ON c.tenant_id = t.tenant_id
392 WHERE c.contract_id = $1
397 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
398 query += ` AND ${adjustedClause}`;
399 params.push(...tenantParams);
402 const result = await pool.query(query, params);
404 if (result.rows.length === 0) {
405 return res.status(404).json({ error: 'Contract not found' });
408 const contract = result.rows[0];
410 // Fetch line items - handle gracefully if table doesn't exist
412 const lineItemsRes = await pool.query(
413 'SELECT * FROM contract_line_items WHERE contract_id = $1 ORDER BY sort_order, line_item_id',
416 contract.line_items = lineItemsRes.rows;
417 } catch (lineItemErr) {
418 console.warn('contract_line_items table not found, skipping line items');
419 contract.line_items = [];
424 console.error('Error fetching contract:', err);
425 res.status(500).json({ error: 'Server error', details: err.message });
430 * @api {post} /contracts Create new contract
431 * @apiName CreateContract
432 * @apiGroup Contracts
433 * @apiDescription Creates a new service contract with optional line items. Automatically
434 * calculates next billing date based on billing interval, sets tenant context, and
435 * performs transaction-safe creation with line items. Uses schema-aware column insertion
436 * for backwards compatibility with different database versions.
438 * **Billing Configuration:**
439 * - **Monthly:** Bills on specified billing_day each month (or last day if > days in month)
440 * - **Yearly:** Bills on anniversary of start_date each year
441 * - **Custom:** Bills on dates specified in custom_billing_dates JSON array
444 * When auto_invoice_enabled=true, system automatically generates invoices on
445 * next_billing_date via background worker (contractBillingWorker).
448 * Array of charges to include in invoices:
449 * - product_id: Optional link to products table
450 * - description: Line item label
451 * - quantity: Number of units
452 * - unit_price: Price per unit
453 * - recurring: true for every invoice, false for one-time
454 * - sort_order: Display order
456 * **Transaction Safety:**
457 * Contract and all line items created in single database transaction.
458 * Rolls back entirely if any part fails.
459 * @apiHeader {string} Authorization Bearer JWT token with tenant context
460 * @apiHeader {string} Content-Type application/json
461 * @apiParam {number} customer_id Customer ID (required)
462 * @apiParam {string} title Contract title (required)
463 * @apiParam {string} start_date Start date ISO 8601 (required)
464 * @apiParam {string} [description] Contract description
465 * @apiParam {string} [status=active] Contract status (active, inactive, deleted)
466 * @apiParam {string} [end_date] Contract end date (null for ongoing)
467 * @apiParam {string} [billing_interval=monthly] Billing frequency (monthly, yearly, custom)
468 * @apiParam {number} [billing_day] Day of month for monthly billing (1-31)
469 * @apiParam {string} [custom_billing_dates] JSON array of custom billing dates
470 * @apiParam {boolean} [auto_invoice_enabled=false] Enable automatic invoicing
471 * @apiParam {number} [max_devices=0] Maximum allowed devices (0=unlimited)
472 * @apiParam {number} [max_contacts=0] Maximum contacts allowed
473 * @apiParam {number} [max_products=0] Maximum products allowed
474 * @apiParam {number} [labor_rate=0] Hourly labor rate
475 * @apiParam {string} [currency=AUD] Currency code
476 * @apiParam {string} [notes] Internal notes
477 * @apiParam {Number} [sla_hours=24] SLA response time in hours
478 * @apiParam {Array} [line_items=[]] Array of line item objects
480 * @apiSuccess {Object} contract Complete newly created contract with line items
481 * @apiSuccess {Number} contract.contract_id Auto-generated contract ID
483 * @apiError {Number} 400 Missing required fields
484 * @apiError {Number} 403 No tenant context
485 * @apiError {Number} 500 Failed to create contract
487 * @apiExample {curl} Example Request:
489 * -H "Authorization: Bearer eyJhbGc..." \
490 * -H "Content-Type: application/json" \
493 * "title": "Managed Services Agreement",
494 * "description": "Full IT support and monitoring",
495 * "start_date": "2026-03-01",
496 * "billing_interval": "monthly",
498 * "auto_invoice_enabled": true,
500 * "labor_rate": 150.00,
505 * "description": "Per-device monitoring",
507 * "unit_price": 50.00,
513 * "https://api.everydaytech.au/contracts"
515 * @apiExample {json} Success Response:
516 * HTTP/1.1 201 Created
521 * "title": "Managed Services Agreement",
522 * "status": "active",
523 * "next_billing_date": "2026-04-01",
524 * "line_items": [{ ... }],
525 * "created_at": "2026-03-11T10:00:00Z"
530 * @see {@link module:routes/contracts.updateContract} for updating contracts
531 * @see {@link module:workers/contractBillingWorker} for auto-invoice generation
533router.post('/', authenticateToken, async (req, res) => {
541 billing_interval = 'monthly',
543 custom_billing_dates,
544 auto_invoice_enabled = false,
555 if (!customer_id || !title || !start_date) {
556 return res.status(400).json({ error: 'Customer, title, and start date are required' });
559 if (!req.tenant?.id) {
560 console.error('POST /contracts - No tenant context found');
561 return res.status(403).json({ error: 'No tenant context' });
564 const client = await pool.connect();
566 await client.query('BEGIN');
568 // Calculate next billing date
569 let nextBillingDate = null;
570 if (auto_invoice_enabled) {
571 if (billing_interval === 'monthly') {
572 nextBillingDate = start_date;
573 } else if (billing_interval === 'yearly') {
574 nextBillingDate = start_date;
575 } else if (billing_interval === 'custom' && custom_billing_dates) {
576 const dates = JSON.parse(custom_billing_dates);
577 if (dates && dates.length > 0) {
578 nextBillingDate = dates[0];
583 // Insert contract using only columns that exist in current schema
584 const cols = await getTableColumns(client, 'contracts');
591 end_date: end_date || null,
593 // legacy column alias
594 invoice_interval: billing_interval,
596 custom_billing_dates,
597 auto_invoice_enabled,
598 // legacy column alias
599 auto_invoice: auto_invoice_enabled,
600 next_billing_date: nextBillingDate,
608 tenant_id: req.tenant.id
610 const insertCols = Object.keys(valueMap).filter(k => cols.has(k));
611 if (!insertCols.includes('customer_id') || !insertCols.includes('title') || !insertCols.includes('start_date') || !insertCols.includes('tenant_id')) {
612 throw new Error('Contracts table schema missing required columns (customer_id, title, start_date, tenant_id)');
614 const paramsArr = insertCols.map(k => valueMap[k]);
615 const placeholders = insertCols.map((_, i) => `$${i + 1}`);
616 const insertSql = `INSERT INTO contracts (${insertCols.join(', ')}) VALUES (${placeholders.join(', ')}) RETURNING *`;
617 const contractRes = await client.query(insertSql, paramsArr);
618 const contract = contractRes.rows[0];
619 if (contract && contract.sla_hours == null) contract.sla_hours = 24;
621 // Insert line items (if table exists)
622 const hasLineItems = await contractLineItemsTableExists(client);
623 if (hasLineItems && Array.isArray(line_items) && line_items.length) {
624 for (const item of line_items) {
626 `INSERT INTO contract_line_items (
627 contract_id, product_id, description, quantity, unit_price, recurring, sort_order
628 ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
630 contract.contract_id,
631 item.product_id || null,
635 item.recurring !== false,
640 } else if (!hasLineItems) {
641 console.warn('contract_line_items table not found, skipping insert of line items');
644 await client.query('COMMIT');
646 // Fetch the complete contract with line items (if table exists)
648 if (await contractLineItemsTableExists()) {
649 const lineItemsRes = await pool.query(
650 'SELECT * FROM contract_line_items WHERE contract_id = $1 ORDER BY sort_order',
651 [contract.contract_id]
653 contract.line_items = lineItemsRes.rows;
655 console.warn('contract_line_items table not found, skipping fetch of line items');
656 contract.line_items = [];
659 console.warn('contract_line_items table not found, skipping fetch of line items');
660 contract.line_items = [];
663 res.status(201).json({ contract });
665 await client.query('ROLLBACK');
666 console.error('Error creating contract:', err);
667 res.status(500).json({ error: 'Failed to create contract', details: err.message });
674 * @api {put} /contracts/:id Update contract
675 * @apiName UpdateContract
676 * @apiGroup Contracts
677 * @apiDescription Updates an existing contract and replaces all line items. Recalculates
678 * next billing date based on updated billing configuration. Uses transaction-safe update
679 * with automatic rollback on failure. Schema-aware for backwards compatibility.
681 * **Update Behavior:**
682 * - All contract fields can be updated
683 * - Existing line items are DELETED and replaced with provided line_items array
684 * - Next billing date recalculated if auto_invoice_enabled or billing config changes
685 * - Updated timestamp set automatically
687 * **Next Billing Date Logic:**
688 * - Uses last_billing_date if available, otherwise falls back to start_date
689 * - Monthly: Advances by 1 month, respects billing_day
690 * - Yearly: Advances by 1 year
691 * - Custom: Uses next future date from custom_billing_dates array
693 * **Transaction Safety:**
694 * Contract update and line item replacement in single transaction. Rolls back if any step fails.
695 * Uses SAVEPOINT for fallback to dynamic column detection if fixed-column update fails.
696 * @apiHeader {string} Authorization Bearer JWT token with tenant context
697 * @apiHeader {string} Content-Type application/json
698 * @apiParam {number} id Contract ID (URL parameter)
699 * @apiParam {number} customer_id Updated customer ID
700 * @apiParam {string} title Updated title
701 * @apiParam {string} description Updated description
702 * @apiParam {string} status Updated status
703 * @apiParam {string} start_date Updated start date
704 * @apiParam {string} end_date Updated end date
705 * @apiParam {string} billing_interval Updated billing interval
706 * @apiParam {number} billing_day Updated billing day
707 * @apiParam {string} custom_billing_dates Updated custom dates JSON
708 * @apiParam {boolean} auto_invoice_enabled Updated auto-invoice flag
709 * @apiParam {number} max_devices Updated device limit
710 * @apiParam {number} max_contacts Updated contact limit
711 * @apiParam {number} max_products Updated product limit
712 * @apiParam {number} labor_rate Updated hourly rate
713 * @apiParam {string} currency Updated currency code
714 * @apiParam {String} notes Updated notes
715 * @apiParam {Number} sla_hours Updated SLA hours
716 * @apiParam {Array} [line_items=[]] New line items (replaces all existing)
718 * @apiSuccess {Object} contract Updated contract object with new line items
720 * @apiError {Number} 404 Contract not found or access denied
721 * @apiError {Number} 500 Failed to update contract
723 * @apiExample {curl} Example Request:
725 * -H "Authorization: Bearer eyJhbGc..." \
726 * -H "Content-Type: application/json" \
729 * "title": "Updated Managed Services",
730 * "status": "active",
731 * "start_date": "2026-03-01",
732 * "billing_interval": "monthly",
734 * "auto_invoice_enabled": true,
738 * "description": "Premium support",
740 * "unit_price": 75.00,
746 * "https://api.everydaytech.au/contracts/5"
748 * @apiExample {json} Success Response:
753 * "title": "Updated Managed Services",
754 * "next_billing_date": "2026-04-15",
755 * "line_items": [{ ... }],
756 * "updated_at": "2026-03-11T10:30:00Z"
761 * @see {@link module:routes/contracts.getContract} for retrieving current state
763router.put('/:id', authenticateToken, async (req, res) => {
764 const id = req.params.id;
774 custom_billing_dates,
775 auto_invoice_enabled,
786 const client = await pool.connect();
788 await client.query('BEGIN');
790 // Calculate next billing date if needed (robust against missing last_billing_date column)
791 let nextBillingDate = null;
792 if (auto_invoice_enabled) {
793 let baseDate = start_date;
795 const cols = await getTableColumns(client, 'contracts');
796 if (cols.has('last_billing_date')) {
797 const lastBilling = await client.query(
798 'SELECT last_billing_date FROM contracts WHERE contract_id = $1',
801 if (lastBilling.rows[0]?.last_billing_date) {
802 baseDate = lastBilling.rows[0].last_billing_date;
806 // ignore, fallback to start_date
809 if (billing_interval === 'monthly') {
810 const date = new Date(baseDate);
811 date.setMonth(date.getMonth() + 1);
813 date.setDate(Math.min(billing_day, new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()));
815 nextBillingDate = date.toISOString().split('T')[0];
816 } else if (billing_interval === 'yearly') {
817 const date = new Date(baseDate);
818 date.setFullYear(date.getFullYear() + 1);
819 nextBillingDate = date.toISOString().split('T')[0];
820 } else if (billing_interval === 'custom' && custom_billing_dates) {
821 const dates = JSON.parse(custom_billing_dates);
822 const today = new Date().toISOString().split('T')[0];
823 const futureDates = dates.filter(d => d > today).sort();
824 if (futureDates.length > 0) {
825 nextBillingDate = futureDates[0];
832 await client.query('SAVEPOINT sp_contract_update');
834 // Get tenant filter for security
835 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
837 // Build WHERE clause with tenant filtering
838 let whereClause = 'contract_id=$19';
839 const updateParams = [
840 customer_id, title, description, status, start_date, end_date,
841 billing_interval, billing_day, custom_billing_dates, auto_invoice_enabled,
842 nextBillingDate, max_devices, max_contacts, max_products,
843 labor_rate, currency, notes, sla_hours, id
847 updateParams.push(...tenantParams);
848 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => {
849 return `$${19 + parseInt(num)}`;
851 whereClause += ` AND ${adjustedClause}`;
854 // Debug logging for contract update
855 console.log(`[Contracts] Updating contract ${id}:`, {
856 requestTenantId: req.tenant?.id,
857 isMsp: req.tenant?.isMsp,
858 statusChange: status,
861 hasCustomerId: !!customer_id,
862 paramCount: updateParams.length
865 console.log('[Contracts] Update SQL:', `UPDATE contracts c SET customer_id=$1, title=$2, description=$3, status=$4, start_date=$5, end_date=$6, billing_interval=$7, billing_day=$8, custom_billing_dates=$9, auto_invoice_enabled=$10, next_billing_date=$11, max_devices=$12, max_contacts=$13, max_products=$14, labor_rate=$15, currency=$16, notes=$17, sla_hours=$18, updated_at=CURRENT_TIMESTAMP WHERE ${whereClause} RETURNING *`);
866 console.log('[Contracts] Update params:', updateParams);
868 contractRes = await client.query(
869 `UPDATE contracts c SET
870 customer_id=$1, title=$2, description=$3, status=$4, start_date=$5, end_date=$6,
871 billing_interval=$7, billing_day=$8, custom_billing_dates=$9, auto_invoice_enabled=$10,
872 next_billing_date=$11, max_devices=$12, max_contacts=$13, max_products=$14,
873 labor_rate=$15, currency=$16, notes=$17, sla_hours=$18, updated_at=CURRENT_TIMESTAMP
874 WHERE ${whereClause} RETURNING *`,
878 // Fallback: dynamically update only existing columns in contracts table
879 await client.query('ROLLBACK TO SAVEPOINT sp_contract_update');
880 const cols = await getTableColumns(client, 'contracts');
889 // legacy column alias
890 invoice_interval: billing_interval,
892 custom_billing_dates,
893 auto_invoice_enabled,
894 // legacy column alias
895 auto_invoice: auto_invoice_enabled,
896 next_billing_date: nextBillingDate,
905 const setCols = Object.keys(valueMap).filter(k => cols.has(k));
906 if (setCols.length === 0) {
907 throw new Error('No updatable columns exist in contracts table');
910 // Build dynamic SET clause
911 const setParts = setCols.map((k, i) => `${k}=$${i + 1}`);
912 const updateParams = setCols.map(k => valueMap[k]);
914 // WHERE with tenant filter
915 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
916 let whereClause = `contract_id=$${setCols.length + 1}`;
917 updateParams.push(id);
919 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => `$${setCols.length + 1 + parseInt(num)}`);
920 whereClause += ` AND ${adjustedClause}`;
921 updateParams.push(...tenantParams);
924 const sql = `UPDATE contracts c SET ${setParts.join(', ')}, updated_at=CURRENT_TIMESTAMP WHERE ${whereClause} RETURNING *`;
925 contractRes = await client.query(sql, updateParams);
928 if (contractRes.rows.length === 0) {
929 // Contract not found - could be wrong tenant or doesn't exist
930 await client.query('ROLLBACK');
932 // Debug: check if contract exists at all
933 const existsCheck = await client.query(
934 'SELECT contract_id, tenant_id, status FROM contracts WHERE contract_id = $1',
938 if (existsCheck.rows.length === 0) {
939 console.error(`[Contracts] Contract ${id} does not exist in database`);
940 return res.status(404).json({
941 error: 'Contract not found',
942 details: 'No contract with this ID exists'
946 // Contract exists but tenant filter excluded it
947 const actualTenant = existsCheck.rows[0].tenant_id;
948 const requestTenant = req.tenant?.id;
949 console.error(`[Contracts] Contract ${id} exists but tenant mismatch:`, {
950 contractTenantId: actualTenant,
951 requestTenantId: requestTenant,
952 isMsp: req.tenant?.isMsp,
953 contractStatus: existsCheck.rows[0].status
956 return res.status(404).json({
957 error: 'Contract not found',
958 details: 'Contract not accessible by your tenant'
962 const contract = contractRes.rows[0];
964 // Delete existing line items and insert new ones (if table exists)
965 const hasLineItems = await contractLineItemsTableExists(client);
967 await client.query('DELETE FROM contract_line_items WHERE contract_id = $1', [id]);
968 for (const item of line_items) {
970 `INSERT INTO contract_line_items (
971 contract_id, product_id, description, quantity, unit_price, recurring, sort_order
972 ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
975 item.product_id || null,
979 item.recurring !== false,
985 console.warn('contract_line_items table not found, skipping update of line items');
988 await client.query('COMMIT');
990 // Fetch updated line items (if table exists)
992 if (await contractLineItemsTableExists()) {
993 const lineItemsRes = await pool.query(
994 'SELECT * FROM contract_line_items WHERE contract_id = $1 ORDER BY sort_order',
997 contract.line_items = lineItemsRes.rows;
999 console.warn('contract_line_items table not found, skipping fetch of line items');
1000 contract.line_items = [];
1003 console.warn('contract_line_items table not found, skipping fetch of line items');
1004 contract.line_items = [];
1006 if (contract && contract.sla_hours == null) contract.sla_hours = 24;
1008 res.json({ contract });
1010 await client.query('ROLLBACK');
1011 console.error('[Contracts] Error updating contract:', err);
1012 console.error('[Contracts] Error stack:', err.stack);
1013 console.error('[Contracts] Contract ID:', id);
1014 console.error('[Contracts] Request body:', JSON.stringify(req.body, null, 2));
1015 res.status(500).json({ error: 'Failed to update contract', details: err.message, errorCode: err.code });
1022 * @api {delete} /contracts/:id Delete contract
1023 * @apiName DeleteContract
1024 * @apiGroup Contracts
1025 * @apiDescription Permanently deletes a contract. Tenant isolation enforced - users can only
1026 * delete contracts belonging to their tenant. Associated line items are cascade deleted
1027 * by database foreign key constraints.
1029 * **Deletion vs Status:**
1030 * This endpoint performs HARD delete (removes from database). For soft delete that
1031 * preserves history, use the admin-only DELETE endpoint that sets status='deleted'.
1034 * This action cannot be undone. Consider setting status='inactive' or 'deleted' via
1035 * the update endpoint instead to preserve historical data.
1036 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1037 * @apiParam {number} id Contract ID to delete (URL parameter)
1038 * @apiSuccess {string} message Success confirmation message
1039 * @apiError {number} 404 Contract not found or access denied
1040 * @apiError {number} 500 Delete failed
1041 * @apiExample {curl} Example Request:
1043 * -H "Authorization: Bearer eyJhbGc..." \
1044 * "https://api.everydaytech.au/contracts/999"
1045 * @apiExample {json} Success Response:
1048 * "message": "Contract deleted successfully"
1051 * @see {@link module:routes/contracts.updateContract} for soft delete via status field
1053router.delete('/:id', authenticateToken, async (req, res) => {
1054 const id = req.params.id;
1056 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
1057 let query = 'DELETE FROM contracts c WHERE c.contract_id = $1';
1058 const params = [id];
1061 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
1062 query += ` AND ${adjustedClause}`;
1063 params.push(...tenantParams);
1066 query += ' RETURNING *';
1068 const result = await pool.query(query, params);
1069 if (result.rows.length === 0) {
1070 return res.status(404).json({ error: 'Contract not found' });
1072 res.json({ message: 'Contract deleted successfully' });
1074 console.error('Error deleting contract:', err);
1075 res.status(500).json({ error: 'Delete failed', details: err.message });
1080 * @api {post} /contracts/:id/generate-invoice Generate invoice manually
1081 * @apiName GenerateContractInvoice
1082 * @apiGroup Contracts
1083 * @apiDescription Manually triggers invoice generation for a contract. Adds job to billing
1084 * queue and waits for completion (30 second timeout). Generates invoice with all recurring
1085 * line items, updates next_billing_date, and returns invoice details.
1088 * - Manual billing outside regular schedule
1089 * - One-time additional invoice for contract
1090 * - Testing invoice generation
1091 * - Handling missed automatic billing
1093 * **Invoice Generation:**
1094 * - Creates invoice with current date
1095 * - Includes all recurring line items from contract
1096 * - Calculates total from line item quantities and prices
1097 * - Updates contract.next_billing_date based on billing_interval
1098 * - Links invoice to customer and contract
1100 * **Queue Processing:**
1101 * Job added to contractBillingWorker queue with priority=1 (high priority).
1102 * Endpoint waits up to 30 seconds for job completion before returning.
1103 * @apiHeader {string} Authorization Bearer JWT token
1104 * @apiParam {number} id Contract ID (URL parameter)
1105 * @apiSuccess {boolean} success Always true when successful
1106 * @apiSuccess {string} message Success confirmation
1107 * @apiSuccess {number} invoiceId Generated invoice ID
1108 * @apiSuccess {string} invoiceNumber Invoice number (e.g., "INV-2026-001")
1109 * @apiSuccess {number} amount Total invoice amount
1110 * @apiSuccess {string} nextBillingDate Updated next billing date for contract
1111 * @apiError {boolean} success false when generation fails
1112 * @apiError {string} message Failure message
1113 * @apiError {string} reason Specific reason for failure
1114 * @apiError {number} 500 Server error or queue failure
1115 * @apiExample {curl} Example Request:
1117 * -H "Authorization: Bearer eyJhbGc..." \
1118 * "https://api.everydaytech.au/contracts/5/generate-invoice"
1119 * @apiExample {json} Success Response:
1123 * "message": "Invoice generated successfully",
1125 * "invoiceNumber": "INV-2026-052",
1126 * "amount": 1250.00,
1127 * "nextBillingDate": "2026-04-01"
1129 * @apiExample {json} Error Response (Not Due):
1130 * HTTP/1.1 400 Bad Request
1133 * "message": "Failed to generate invoice",
1134 * "reason": "Invoice not due yet. Next billing date is 2026-04-15."
1137 * @see {@link module:workers/contractBillingWorker} for queue implementation
1138 * @see {@link module:routes/invoices} for invoice management
1140router.post('/:id/generate-invoice', async (req, res) => {
1142 const { id } = req.params;
1144 // Import billing queue
1145 const { billingQueue } = require('../contractBillingWorker');
1148 const job = await billingQueue.add(
1150 { contractId: parseInt(id) },
1154 // Wait for job to complete (with timeout)
1155 const result = await job.waitUntilFinished(
1156 billingQueue.events,
1157 30000 // 30 second timeout
1160 if (result.success) {
1163 message: 'Invoice generated successfully',
1164 invoiceId: result.invoiceId,
1165 invoiceNumber: result.invoiceNumber,
1166 amount: result.amount,
1167 nextBillingDate: result.nextBillingDate
1170 res.status(400).json({
1172 message: 'Failed to generate invoice',
1173 reason: result.reason
1177 console.error('Error generating invoice:', err);
1178 res.status(500).json({ error: 'Failed to generate invoice', details: err.message });
1183 * @api {delete} /contracts/:id Soft-delete contract (Admin)
1184 * @apiName SoftDeleteContract
1185 * @apiGroup Contracts
1186 * @apiDescription Soft-deletes a contract by setting status to 'deleted'. Admin-only operation.
1187 * Preserves all contract data and history while marking it as deleted. Contract remains in
1188 * database for reporting and audit purposes but is hidden from active lists when filtering
1189 * by status != 'deleted'.
1192 * Requires admin role via requireAdmin middleware. Regular users should use the other
1193 * DELETE endpoint for hard delete (if permitted by tenant access controls).
1195 * **Soft Delete Benefits:**
1196 * - Preserves historical billing data
1197 * - Maintains referential integrity with invoices
1198 * - Allows audit trail and reporting
1199 * - Can be "undeleted" by setting status back to 'active'
1201 * **Recommended Approach:**
1202 * Soft delete is preferred over hard delete for contracts with billing history or
1203 * generated invoices. Use this endpoint instead of the hard delete for production data.
1204 * @apiHeader {string} Authorization Bearer JWT token with admin permissions
1205 * @apiParam {number} id Contract ID to soft-delete (URL parameter)
1206 * @apiSuccess {string} message Success confirmation message
1207 * @apiError {number} 403 Forbidden - Admin role required
1208 * @apiError {number} 404 Contract not found or access denied
1209 * @apiError {number} 500 Failed to delete contract
1210 * @apiExample {curl} Example Request:
1212 * -H "Authorization: Bearer eyJhbGc..." \\
1213 * "https://api.everydaytech.au/contracts/5"
1214 * @apiExample {json} Success Response:
1217 * "message": "Contract deleted successfully"
1219 * @apiExample {json} Error Response (Not Admin):
1220 * HTTP/1.1 403 Forbidden
1222 * "error": "Admin access required"
1225 * @see {@link module:routes/contracts.updateContract} for reactivating by setting status='active'
1227router.delete('/:id', requireAdmin, async (req, res) => {
1228 const { id } = req.params;
1231 // Check if contract exists and belongs to tenant
1232 const checkResult = await pool.query(
1233 'SELECT contract_id FROM contracts WHERE contract_id = $1 AND tenant_id = $2',
1237 if (checkResult.rows.length === 0) {
1238 return res.status(404).json({ error: 'Contract not found or access denied' });
1241 // Soft delete: set status to 'deleted'
1243 `UPDATE contracts SET status = 'deleted', updated_at = NOW() WHERE contract_id = $1`,
1247 res.json({ message: 'Contract deleted successfully' });
1249 console.error('Error deleting contract:', err);
1250 res.status(500).json({ error: 'Failed to delete contract', details: err.message });
1254module.exports = router;