EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
contracts.js
Go to the documentation of this file.
1/**
2 * @file contracts.js
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).
8 *
9 * **Core Features:**
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)
19 *
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
26 *
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)
33 *
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
39 * @requires express
40 * @requires services/db
41 * @requires middleware/auth
42 * @requires middleware/adminOnly
43 * @requires middleware/tenant
44 * @requires contractBillingWorker
45 * @author RMM-PSA Platform
46 * @date 2026-02-10
47 * @since 2.0.0
48 */
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');
55
56// Apply authentication and tenant context to all routes
57router.use(authenticateToken, setTenantContext);
58
59// Utility: list existing columns for a table in current schema
60/**
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
65 */
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`,
69 [tableName]
70 );
71 return new Set(res.rows.map(r => r.column_name));
72}
73
74// Utility: check if contract_line_items table exists
75/**
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
79 */
80async function contractLineItemsTableExists(executor = pool) {
81 try {
82 const res = await executor.query(
83 `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'contract_line_items')`
84 );
85 return res.rows[0]?.exists || false;
86 } catch (_) {
87 return false;
88 }
89}
90
91
92
93/**
94 * @api {get} /contracts List all contracts
95 * @apiName ListContracts
96 * @apiGroup Contracts
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.
100 *
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
110 *
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:
130 * HTTP/1.1 200 OK
131 * {
132 * "contracts": [
133 * {
134 * "contract_id": 5,
135 * "title": "Managed Services Agreement",
136 * "customer_id": 42,
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,
143 * "sla_hours": 24,
144 * "labor_rate": 150.00,
145 * "currency": "AUD"
146 * }
147 * ],
148 * "total": 23
149 * }
150 * @since 2.0.0
151 * @see {@link module:routes/contracts.getContract} for single contract details
152 */
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;
163
164const ContractsModel = require('../models/Contracts');
165// ...existing code...
166
167/**
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.
174 *
175 * **Use Case:**
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"
187 * @since 2.0.0
188 * @see {@link module:models/Contracts.getContractForTenant} for model implementation
189 */
190router.get('/tenant/:tenantId', authenticateToken, async (req, res) => {
191 try {
192 const contract = await ContractsModel.getContractForTenant(req.params.tenantId);
193 if (!contract) {
194 return res.status(404).json({ error: 'Contract not found for tenant' });
195 }
196 res.json(contract);
197 } catch (err) {
198 console.error('Error fetching contract for tenant:', err);
199 res.status(500).json({ error: 'Server error', details: err.message });
200 }
201});
202
203/**
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.
210 *
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:
220 * HTTP/1.1 200 OK
221 * {
222 * "contracts_columns": [
223 * "contract_id", "customer_id", "title", "status",
224 * "billing_interval", "auto_invoice_enabled", "sla_hours"
225 * ],
226 * "has_contract_line_items": true
227 * }
228 * @since 2.0.0
229 */
230router.get('/_schema', async (req, res) => {
231 try {
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 });
235 } catch (e) {
236 res.status(500).json({ error: 'schema query failed', details: e.message });
237 }
238});
239
240
241
242 // Debug: log request context
243 console.log(`[API][contracts] GET /contracts | Tenant:`, req.tenant, '| Query:', req.query);
244
245 try {
246 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 'c');
247 let query = `
248 SELECT c.*, cust.name as customer_name, t.name as tenant_name
249 FROM contracts c
250 LEFT JOIN customers cust ON c.customer_id = cust.customer_id
251 LEFT JOIN tenants t ON c.tenant_id = t.tenant_id
252 `;
253 let countQuery = 'SELECT COUNT(*) FROM contracts c LEFT JOIN customers cust ON c.customer_id = cust.customer_id';
254 const where = [];
255 const params = [...tenantParams];
256 let paramIndex = nextParamIndex;
257
258 // Tenant filter
259 if (tenantClause) where.push(tenantClause);
260
261 // Search
262 if (search) {
263 params.push(`%${search}%`);
264 where.push(`(c.title ILIKE $${paramIndex} OR cust.name ILIKE $${paramIndex} OR c.description ILIKE $${paramIndex})`);
265 paramIndex++;
266 }
267
268 // Customer filter
269 if (customerId) {
270 params.push(customerId);
271 where.push(`c.customer_id = $${paramIndex}`);
272 paramIndex++;
273 }
274
275 // Status filter
276 if (status && status !== 'all') {
277 params.push(status);
278 where.push(`c.status = $${params.length}`);
279 }
280
281 // Billing interval filter
282 if (billingInterval && billingInterval !== 'all') {
283 params.push(billingInterval);
284 where.push(`c.billing_interval = $${params.length}`);
285 }
286
287 // Date range filter (start_date)
288 if (dateFrom) {
289 params.push(dateFrom);
290 where.push(`c.start_date >= $${params.length}`);
291 }
292 if (dateTo) {
293 params.push(dateTo);
294 where.push(`c.start_date <= $${params.length}`);
295 }
296
297 if (where.length) {
298 const clause = ' WHERE ' + where.join(' AND ');
299 query += clause;
300 countQuery += clause;
301 }
302
303 params.push(limit, offset);
304 query += ` ORDER BY c.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`;
305
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));
309
310 const [totalRes, result] = await Promise.all([
311 pool.query(countQuery, params.slice(0, params.length - 2)),
312 pool.query(query, params)
313 ]);
314
315 res.json({ contracts: result.rows, total: parseInt(totalRes.rows[0].count, 10) });
316 } catch (err) {
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 });
319 }
320});
321
322/**
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.
329 *
330 * **Included Data:**
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)
335 *
336 * **Line Items:**
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:
354 * HTTP/1.1 200 OK
355 * {
356 * "contract_id": 5,
357 * "title": "Managed Services",
358 * "customer_id": 42,
359 * "customer_name": "Acme Corp",
360 * "status": "active",
361 * "billing_interval": "monthly",
362 * "billing_day": 1,
363 * "auto_invoice_enabled": true,
364 * "next_billing_date": "2026-04-01",
365 * "sla_hours": 24,
366 * "labor_rate": 150.00,
367 * "currency": "AUD",
368 * "line_items": [
369 * {
370 * "line_item_id": 10,
371 * "product_id": 5,
372 * "description": "Managed Desktop Support",
373 * "quantity": 25,
374 * "unit_price": 50.00,
375 * "recurring": true,
376 * "sort_order": 1
377 * }
378 * ]
379 * }
380 * @since 2.0.0
381 * @see {@link module:routes/contracts.updateContract} for updating contracts
382 */
383router.get('/:id', authenticateToken, async (req, res) => {
384 const id = req.params.id;
385 try {
386 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
387 let query = `
388 SELECT c.*, cust.name as customer_name, t.name as tenant_name
389 FROM contracts c
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
393 `;
394 const params = [id];
395
396 if (tenantClause) {
397 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
398 query += ` AND ${adjustedClause}`;
399 params.push(...tenantParams);
400 }
401
402 const result = await pool.query(query, params);
403
404 if (result.rows.length === 0) {
405 return res.status(404).json({ error: 'Contract not found' });
406 }
407
408 const contract = result.rows[0];
409
410 // Fetch line items - handle gracefully if table doesn't exist
411 try {
412 const lineItemsRes = await pool.query(
413 'SELECT * FROM contract_line_items WHERE contract_id = $1 ORDER BY sort_order, line_item_id',
414 [id]
415 );
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 = [];
420 }
421
422 res.json(contract);
423 } catch (err) {
424 console.error('Error fetching contract:', err);
425 res.status(500).json({ error: 'Server error', details: err.message });
426 }
427});
428
429/**
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.
437 *
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
442 *
443 * **Auto-Invoice:**
444 * When auto_invoice_enabled=true, system automatically generates invoices on
445 * next_billing_date via background worker (contractBillingWorker).
446 *
447 * **Line Items:**
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
455 *
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
479 *
480 * @apiSuccess {Object} contract Complete newly created contract with line items
481 * @apiSuccess {Number} contract.contract_id Auto-generated contract ID
482 *
483 * @apiError {Number} 400 Missing required fields
484 * @apiError {Number} 403 No tenant context
485 * @apiError {Number} 500 Failed to create contract
486 *
487 * @apiExample {curl} Example Request:
488 * curl -X POST \
489 * -H "Authorization: Bearer eyJhbGc..." \
490 * -H "Content-Type: application/json" \
491 * -d '{
492 * "customer_id": 42,
493 * "title": "Managed Services Agreement",
494 * "description": "Full IT support and monitoring",
495 * "start_date": "2026-03-01",
496 * "billing_interval": "monthly",
497 * "billing_day": 1,
498 * "auto_invoice_enabled": true,
499 * "max_devices": 50,
500 * "labor_rate": 150.00,
501 * "sla_hours": 4,
502 * "line_items": [
503 * {
504 * "product_id": 5,
505 * "description": "Per-device monitoring",
506 * "quantity": 25,
507 * "unit_price": 50.00,
508 * "recurring": true,
509 * "sort_order": 1
510 * }
511 * ]
512 * }' \
513 * "https://api.everydaytech.au/contracts"
514 *
515 * @apiExample {json} Success Response:
516 * HTTP/1.1 201 Created
517 * {
518 * "contract": {
519 * "contract_id": 5,
520 * "customer_id": 42,
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"
526 * }
527 * }
528 *
529 * @since 2.0.0
530 * @see {@link module:routes/contracts.updateContract} for updating contracts
531 * @see {@link module:workers/contractBillingWorker} for auto-invoice generation
532 */
533router.post('/', authenticateToken, async (req, res) => {
534 const {
535 customer_id,
536 title,
537 description,
538 status = 'active',
539 start_date,
540 end_date,
541 billing_interval = 'monthly',
542 billing_day,
543 custom_billing_dates,
544 auto_invoice_enabled = false,
545 max_devices = 0,
546 max_contacts = 0,
547 max_products = 0,
548 labor_rate = 0,
549 currency = 'AUD',
550 notes,
551 sla_hours = 24,
552 line_items = []
553 } = req.body;
554
555 if (!customer_id || !title || !start_date) {
556 return res.status(400).json({ error: 'Customer, title, and start date are required' });
557 }
558
559 if (!req.tenant?.id) {
560 console.error('POST /contracts - No tenant context found');
561 return res.status(403).json({ error: 'No tenant context' });
562 }
563
564 const client = await pool.connect();
565 try {
566 await client.query('BEGIN');
567
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];
579 }
580 }
581 }
582
583 // Insert contract using only columns that exist in current schema
584 const cols = await getTableColumns(client, 'contracts');
585 const valueMap = {
586 customer_id,
587 title,
588 description,
589 status,
590 start_date,
591 end_date: end_date || null,
592 billing_interval,
593 // legacy column alias
594 invoice_interval: billing_interval,
595 billing_day,
596 custom_billing_dates,
597 auto_invoice_enabled,
598 // legacy column alias
599 auto_invoice: auto_invoice_enabled,
600 next_billing_date: nextBillingDate,
601 max_devices,
602 max_contacts,
603 max_products,
604 labor_rate,
605 currency,
606 notes,
607 sla_hours,
608 tenant_id: req.tenant.id
609 };
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)');
613 }
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;
620
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) {
625 await client.query(
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)`,
629 [
630 contract.contract_id,
631 item.product_id || null,
632 item.description,
633 item.quantity,
634 item.unit_price,
635 item.recurring !== false,
636 item.sort_order || 0
637 ]
638 );
639 }
640 } else if (!hasLineItems) {
641 console.warn('contract_line_items table not found, skipping insert of line items');
642 }
643
644 await client.query('COMMIT');
645
646 // Fetch the complete contract with line items (if table exists)
647 try {
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]
652 );
653 contract.line_items = lineItemsRes.rows;
654 } else {
655 console.warn('contract_line_items table not found, skipping fetch of line items');
656 contract.line_items = [];
657 }
658 } catch (e) {
659 console.warn('contract_line_items table not found, skipping fetch of line items');
660 contract.line_items = [];
661 }
662
663 res.status(201).json({ contract });
664 } catch (err) {
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 });
668 } finally {
669 client.release();
670 }
671});
672
673/**
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.
680 *
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
686 *
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
692 *
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)
717 *
718 * @apiSuccess {Object} contract Updated contract object with new line items
719 *
720 * @apiError {Number} 404 Contract not found or access denied
721 * @apiError {Number} 500 Failed to update contract
722 *
723 * @apiExample {curl} Example Request:
724 * curl -X PUT \
725 * -H "Authorization: Bearer eyJhbGc..." \
726 * -H "Content-Type: application/json" \
727 * -d '{
728 * "customer_id": 42,
729 * "title": "Updated Managed Services",
730 * "status": "active",
731 * "start_date": "2026-03-01",
732 * "billing_interval": "monthly",
733 * "billing_day": 15,
734 * "auto_invoice_enabled": true,
735 * "sla_hours": 2,
736 * "line_items": [
737 * {
738 * "description": "Premium support",
739 * "quantity": 30,
740 * "unit_price": 75.00,
741 * "recurring": true,
742 * "sort_order": 1
743 * }
744 * ]
745 * }' \
746 * "https://api.everydaytech.au/contracts/5"
747 *
748 * @apiExample {json} Success Response:
749 * HTTP/1.1 200 OK
750 * {
751 * "contract": {
752 * "contract_id": 5,
753 * "title": "Updated Managed Services",
754 * "next_billing_date": "2026-04-15",
755 * "line_items": [{ ... }],
756 * "updated_at": "2026-03-11T10:30:00Z"
757 * }
758 * }
759 *
760 * @since 2.0.0
761 * @see {@link module:routes/contracts.getContract} for retrieving current state
762 */
763router.put('/:id', authenticateToken, async (req, res) => {
764 const id = req.params.id;
765 const {
766 customer_id,
767 title,
768 description,
769 status,
770 start_date,
771 end_date,
772 billing_interval,
773 billing_day,
774 custom_billing_dates,
775 auto_invoice_enabled,
776 max_devices,
777 max_contacts,
778 max_products,
779 labor_rate,
780 currency,
781 notes,
782 sla_hours,
783 line_items = []
784 } = req.body;
785
786 const client = await pool.connect();
787 try {
788 await client.query('BEGIN');
789
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;
794 try {
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',
799 [id]
800 );
801 if (lastBilling.rows[0]?.last_billing_date) {
802 baseDate = lastBilling.rows[0].last_billing_date;
803 }
804 }
805 } catch (_) {
806 // ignore, fallback to start_date
807 }
808
809 if (billing_interval === 'monthly') {
810 const date = new Date(baseDate);
811 date.setMonth(date.getMonth() + 1);
812 if (billing_day) {
813 date.setDate(Math.min(billing_day, new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()));
814 }
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];
826 }
827 }
828 }
829
830 // Update contract
831 let contractRes;
832 await client.query('SAVEPOINT sp_contract_update');
833 try {
834 // Get tenant filter for security
835 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 'c');
836
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
844 ];
845
846 if (tenantClause) {
847 updateParams.push(...tenantParams);
848 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => {
849 return `$${19 + parseInt(num)}`;
850 });
851 whereClause += ` AND ${adjustedClause}`;
852 }
853
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,
859 tenantClause,
860 whereClause,
861 hasCustomerId: !!customer_id,
862 paramCount: updateParams.length
863 });
864
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);
867
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 *`,
875 updateParams
876 );
877 } catch (e) {
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');
881 const valueMap = {
882 customer_id,
883 title,
884 description,
885 status,
886 start_date,
887 end_date,
888 billing_interval,
889 // legacy column alias
890 invoice_interval: billing_interval,
891 billing_day,
892 custom_billing_dates,
893 auto_invoice_enabled,
894 // legacy column alias
895 auto_invoice: auto_invoice_enabled,
896 next_billing_date: nextBillingDate,
897 max_devices,
898 max_contacts,
899 max_products,
900 labor_rate,
901 currency,
902 notes,
903 sla_hours
904 };
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');
908 }
909
910 // Build dynamic SET clause
911 const setParts = setCols.map((k, i) => `${k}=$${i + 1}`);
912 const updateParams = setCols.map(k => valueMap[k]);
913
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);
918 if (tenantClause) {
919 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (match, num) => `$${setCols.length + 1 + parseInt(num)}`);
920 whereClause += ` AND ${adjustedClause}`;
921 updateParams.push(...tenantParams);
922 }
923
924 const sql = `UPDATE contracts c SET ${setParts.join(', ')}, updated_at=CURRENT_TIMESTAMP WHERE ${whereClause} RETURNING *`;
925 contractRes = await client.query(sql, updateParams);
926 }
927
928 if (contractRes.rows.length === 0) {
929 // Contract not found - could be wrong tenant or doesn't exist
930 await client.query('ROLLBACK');
931
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',
935 [id]
936 );
937
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'
943 });
944 }
945
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
954 });
955
956 return res.status(404).json({
957 error: 'Contract not found',
958 details: 'Contract not accessible by your tenant'
959 });
960 }
961
962 const contract = contractRes.rows[0];
963
964 // Delete existing line items and insert new ones (if table exists)
965 const hasLineItems = await contractLineItemsTableExists(client);
966 if (hasLineItems) {
967 await client.query('DELETE FROM contract_line_items WHERE contract_id = $1', [id]);
968 for (const item of line_items) {
969 await client.query(
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)`,
973 [
974 id,
975 item.product_id || null,
976 item.description,
977 item.quantity,
978 item.unit_price,
979 item.recurring !== false,
980 item.sort_order || 0
981 ]
982 );
983 }
984 } else {
985 console.warn('contract_line_items table not found, skipping update of line items');
986 }
987
988 await client.query('COMMIT');
989
990 // Fetch updated line items (if table exists)
991 try {
992 if (await contractLineItemsTableExists()) {
993 const lineItemsRes = await pool.query(
994 'SELECT * FROM contract_line_items WHERE contract_id = $1 ORDER BY sort_order',
995 [id]
996 );
997 contract.line_items = lineItemsRes.rows;
998 } else {
999 console.warn('contract_line_items table not found, skipping fetch of line items');
1000 contract.line_items = [];
1001 }
1002 } catch (e) {
1003 console.warn('contract_line_items table not found, skipping fetch of line items');
1004 contract.line_items = [];
1005 }
1006 if (contract && contract.sla_hours == null) contract.sla_hours = 24;
1007
1008 res.json({ contract });
1009 } catch (err) {
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 });
1016 } finally {
1017 client.release();
1018 }
1019});
1020
1021/**
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.
1028 *
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'.
1032 *
1033 * **Warning:**
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:
1042 * curl -X DELETE \
1043 * -H "Authorization: Bearer eyJhbGc..." \
1044 * "https://api.everydaytech.au/contracts/999"
1045 * @apiExample {json} Success Response:
1046 * HTTP/1.1 200 OK
1047 * {
1048 * "message": "Contract deleted successfully"
1049 * }
1050 * @since 2.0.0
1051 * @see {@link module:routes/contracts.updateContract} for soft delete via status field
1052 */
1053router.delete('/:id', authenticateToken, async (req, res) => {
1054 const id = req.params.id;
1055 try {
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];
1059
1060 if (tenantClause) {
1061 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
1062 query += ` AND ${adjustedClause}`;
1063 params.push(...tenantParams);
1064 }
1065
1066 query += ' RETURNING *';
1067
1068 const result = await pool.query(query, params);
1069 if (result.rows.length === 0) {
1070 return res.status(404).json({ error: 'Contract not found' });
1071 }
1072 res.json({ message: 'Contract deleted successfully' });
1073 } catch (err) {
1074 console.error('Error deleting contract:', err);
1075 res.status(500).json({ error: 'Delete failed', details: err.message });
1076 }
1077});
1078
1079/**
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.
1086 *
1087 * **Use Cases:**
1088 * - Manual billing outside regular schedule
1089 * - One-time additional invoice for contract
1090 * - Testing invoice generation
1091 * - Handling missed automatic billing
1092 *
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
1099 *
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:
1116 * curl -X POST \
1117 * -H "Authorization: Bearer eyJhbGc..." \
1118 * "https://api.everydaytech.au/contracts/5/generate-invoice"
1119 * @apiExample {json} Success Response:
1120 * HTTP/1.1 200 OK
1121 * {
1122 * "success": true,
1123 * "message": "Invoice generated successfully",
1124 * "invoiceId": 890,
1125 * "invoiceNumber": "INV-2026-052",
1126 * "amount": 1250.00,
1127 * "nextBillingDate": "2026-04-01"
1128 * }
1129 * @apiExample {json} Error Response (Not Due):
1130 * HTTP/1.1 400 Bad Request
1131 * {
1132 * "success": false,
1133 * "message": "Failed to generate invoice",
1134 * "reason": "Invoice not due yet. Next billing date is 2026-04-15."
1135 * }
1136 * @since 2.0.0
1137 * @see {@link module:workers/contractBillingWorker} for queue implementation
1138 * @see {@link module:routes/invoices} for invoice management
1139 */
1140router.post('/:id/generate-invoice', async (req, res) => {
1141 try {
1142 const { id } = req.params;
1143
1144 // Import billing queue
1145 const { billingQueue } = require('../contractBillingWorker');
1146
1147 // Add job to queue
1148 const job = await billingQueue.add(
1149 'generate-invoice',
1150 { contractId: parseInt(id) },
1151 { priority: 1 }
1152 );
1153
1154 // Wait for job to complete (with timeout)
1155 const result = await job.waitUntilFinished(
1156 billingQueue.events,
1157 30000 // 30 second timeout
1158 );
1159
1160 if (result.success) {
1161 res.json({
1162 success: true,
1163 message: 'Invoice generated successfully',
1164 invoiceId: result.invoiceId,
1165 invoiceNumber: result.invoiceNumber,
1166 amount: result.amount,
1167 nextBillingDate: result.nextBillingDate
1168 });
1169 } else {
1170 res.status(400).json({
1171 success: false,
1172 message: 'Failed to generate invoice',
1173 reason: result.reason
1174 });
1175 }
1176 } catch (err) {
1177 console.error('Error generating invoice:', err);
1178 res.status(500).json({ error: 'Failed to generate invoice', details: err.message });
1179 }
1180});
1181
1182/**
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'.
1190 *
1191 * **Admin Only:**
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).
1194 *
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'
1200 *
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:
1211 * curl -X DELETE \\
1212 * -H "Authorization: Bearer eyJhbGc..." \\
1213 * "https://api.everydaytech.au/contracts/5"
1214 * @apiExample {json} Success Response:
1215 * HTTP/1.1 200 OK
1216 * {
1217 * "message": "Contract deleted successfully"
1218 * }
1219 * @apiExample {json} Error Response (Not Admin):
1220 * HTTP/1.1 403 Forbidden
1221 * {
1222 * "error": "Admin access required"
1223 * }
1224 * @since 2.0.0
1225 * @see {@link module:routes/contracts.updateContract} for reactivating by setting status='active'
1226 */
1227router.delete('/:id', requireAdmin, async (req, res) => {
1228 const { id } = req.params;
1229
1230 try {
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',
1234 [id, req.tenant.id]
1235 );
1236
1237 if (checkResult.rows.length === 0) {
1238 return res.status(404).json({ error: 'Contract not found or access denied' });
1239 }
1240
1241 // Soft delete: set status to 'deleted'
1242 await pool.query(
1243 `UPDATE contracts SET status = 'deleted', updated_at = NOW() WHERE contract_id = $1`,
1244 [id]
1245 );
1246
1247 res.json({ message: 'Contract deleted successfully' });
1248 } catch (err) {
1249 console.error('Error deleting contract:', err);
1250 res.status(500).json({ error: 'Failed to delete contract', details: err.message });
1251 }
1252});
1253
1254module.exports = router;