/** * Invoice Module - CRUD Operations * Create, Read, Update, Delete operations for invoices */ import { query } from '@hykocx/zen/database'; import crypto from 'crypto'; import { getTodayUTC, subtractDays, formatDateForInput } from '../../shared/lib/dates.js'; /** * Generate invoice number * Format: CLIENTNUMBER + YEAR + NUMBER (e.g., 01202501) * @param {string} clientNumber - Client number (e.g., '01') * @returns {Promise} */ async function generateInvoiceNumber(clientNumber) { const currentYear = getTodayUTC().getFullYear().toString(); const prefix = `${clientNumber}${currentYear}`; // Get last invoice for this client and year const result = await query( `SELECT invoice_number FROM zen_invoices WHERE invoice_number LIKE $1 ORDER BY invoice_number DESC LIMIT 1`, [`${prefix}%`] ); let nextNumber = 1; if (result.rows.length > 0) { const lastInvoice = result.rows[0].invoice_number; const lastNumber = parseInt(lastInvoice.substring(prefix.length)); nextNumber = lastNumber + 1; } // Format: CLIENTNUMBER + YEAR + NUMBER (e.g., 01202501) return `${prefix}${nextNumber.toString().padStart(2, '0')}`; } /** * Generate unique token for invoice payment page * @returns {string} */ function generateInvoiceToken() { return crypto.randomBytes(32).toString('hex'); } /** * Calculate invoice totals * @param {Array} items - Invoice items * @returns {Object} Calculated totals */ function calculateInvoiceTotals(items) { const subtotal = items.reduce((sum, item) => { return sum + (item.quantity * item.unit_price); }, 0); const total = subtotal; return { subtotal: parseFloat(subtotal.toFixed(2)), total: parseFloat(total.toFixed(2)) }; } /** * Calculate issue date based on due date and first reminder days * @param {string} dueDate - Due date (YYYY-MM-DD) * @param {number} reminderDays - First reminder days * @returns {string} Calculated issue date (YYYY-MM-DD) */ function calculateIssueDate(dueDate, reminderDays) { const issueDate = subtractDays(dueDate, parseInt(reminderDays)); return formatDateForInput(issueDate); } /** * Create a new invoice * @param {Object} invoiceData - Invoice data * @returns {Promise} Created invoice with items */ export async function createInvoice(invoiceData) { const { client_id, due_date, items = [], notes = null, status = 'draft', first_reminder_days = 30, is_recurring = false, recurring_frequency = null, recurring_end_date = null, } = invoiceData; // Calculate issue_date automatically from due_date and first_reminder_days // Allow override if issue_date is explicitly provided (for backward compatibility) let issue_date = invoiceData.issue_date; if (!issue_date && due_date && first_reminder_days) { issue_date = calculateIssueDate(due_date, first_reminder_days); } // Validate required fields if (!client_id || !issue_date || !due_date || items.length === 0) { throw new Error('Client, due date, and at least one item are required'); } // Get client to generate invoice number const clientResult = await query( `SELECT client_number FROM zen_clients WHERE id = $1`, [client_id] ); if (clientResult.rows.length === 0) { throw new Error('Client not found'); } const clientNumber = clientResult.rows[0].client_number; // Generate invoice number and token const invoice_number = await generateInvoiceNumber(clientNumber); const token = generateInvoiceToken(); // Calculate totals (tax rate is always 0) const totals = calculateInvoiceTotals(items); // Check if Interac is enabled and create credentials if needed const interacEnabled = process.env.ZEN_MODULE_INVOICE_INTERAC === 'true'; if (interacEnabled) { try { await getOrCreateInteracCredentials(client_id); } catch (error) { console.warn('Failed to create Interac credentials:', error.message); // Don't fail invoice creation if Interac credentials fail } } // Start transaction await query('BEGIN'); try { // Create invoice (tax_rate and tax_amount are always 0) const invoiceResult = await query( `INSERT INTO zen_invoices ( invoice_number, token, client_id, issue_date, due_date, subtotal, tax_rate, tax_amount, total_amount, notes, status, first_reminder_days, is_recurring, recurring_frequency, recurring_end_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`, [ invoice_number, token, client_id, issue_date, due_date, totals.subtotal, 0, 0, totals.total, notes, status, first_reminder_days, is_recurring, recurring_frequency, recurring_end_date ] ); const invoice = invoiceResult.rows[0]; // Create invoice items const createdItems = []; for (let i = 0; i < items.length; i++) { const item = items[i]; const itemTotal = item.quantity * item.unit_price; const itemResult = await query( `INSERT INTO zen_invoice_items ( invoice_id, name, description, quantity, unit_price, total, sort_order ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ invoice.id, item.name, item.description || null, item.quantity, item.unit_price, itemTotal, i ] ); createdItems.push(itemResult.rows[0]); } await query('COMMIT'); return { ...invoice, items: createdItems }; } catch (error) { await query('ROLLBACK'); throw error; } } /** * Get invoice by ID * @param {number} id - Invoice ID * @returns {Promise} */ export async function getInvoiceById(id) { const invoiceResult = await query( `SELECT i.*, c.client_number, c.first_name, c.last_name, c.email as client_email, c.company_name, c.address, c.city, c.province, c.postal_code, c.country FROM zen_invoices i JOIN zen_clients c ON i.client_id = c.id WHERE i.id = $1`, [id] ); if (invoiceResult.rows.length === 0) { return null; } const invoice = invoiceResult.rows[0]; // Get invoice items const itemsResult = await query( `SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`, [id] ); // Get reminders const remindersResult = await query( `SELECT * FROM zen_invoice_reminders WHERE invoice_id = $1 ORDER BY sent_at DESC`, [id] ); return { ...invoice, items: itemsResult.rows, reminders: remindersResult.rows }; } /** * Get invoice by token * @param {string} token - Invoice token * @returns {Promise} */ export async function getInvoiceByToken(token) { const invoiceResult = await query( `SELECT i.*, c.client_number, c.first_name, c.last_name, c.email as client_email, c.company_name, c.address, c.city, c.province, c.postal_code, c.country FROM zen_invoices i JOIN zen_clients c ON i.client_id = c.id WHERE i.token = $1`, [token] ); if (invoiceResult.rows.length === 0) { return null; } const invoice = invoiceResult.rows[0]; // Get invoice items const itemsResult = await query( `SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`, [invoice.id] ); return { ...invoice, items: itemsResult.rows }; } /** * Get invoice by invoice number * @param {string} invoiceNumber - Invoice number * @returns {Promise} */ export async function getInvoiceByNumber(invoiceNumber) { const invoiceResult = await query( `SELECT i.*, c.client_number, c.first_name, c.last_name, c.email as client_email, c.company_name FROM zen_invoices i JOIN zen_clients c ON i.client_id = c.id WHERE i.invoice_number = $1`, [invoiceNumber] ); if (invoiceResult.rows.length === 0) { return null; } const invoice = invoiceResult.rows[0]; // Get invoice items const itemsResult = await query( `SELECT * FROM zen_invoice_items WHERE invoice_id = $1 ORDER BY sort_order`, [invoice.id] ); return { ...invoice, items: itemsResult.rows }; } const ALLOWED_INVOICE_SORT_COLUMNS = new Set([ 'created_at', 'updated_at', 'invoice_number', 'due_date', 'issue_date', 'total_amount', 'status', 'paid_amount' ]); const ALLOWED_SORT_ORDERS = new Set(['ASC', 'DESC']); const MAX_PAGE_LIMIT = 100; /** * Get all invoices with pagination and filters * @param {Object} options - Query options * @returns {Promise} Invoices and metadata */ export async function getInvoices(options = {}) { const { page = 1, limit = 20, search = '', status = null, statusNot = null, client_id = null, sortBy = 'created_at', sortOrder = 'DESC' } = options; // Whitelist sortBy and sortOrder to prevent SQL injection via column/direction interpolation const safeSortBy = ALLOWED_INVOICE_SORT_COLUMNS.has(sortBy) ? sortBy : 'created_at'; const safeSortOrder = ALLOWED_SORT_ORDERS.has(String(sortOrder).toUpperCase()) ? String(sortOrder).toUpperCase() : 'DESC'; // Cap pagination limit to prevent DoS via oversized result sets const safeLimit = Math.min(Math.max(1, parseInt(limit) || 20), MAX_PAGE_LIMIT); const offset = (page - 1) * safeLimit; // Build where conditions const conditions = []; const params = []; let paramIndex = 1; if (search) { conditions.push(`( i.invoice_number ILIKE $${paramIndex} OR c.first_name ILIKE $${paramIndex} OR c.last_name ILIKE $${paramIndex} OR c.company_name ILIKE $${paramIndex} )`); params.push(`%${search}%`); paramIndex++; } if (status) { conditions.push(`i.status = $${paramIndex}`); params.push(status); paramIndex++; } if (statusNot) { conditions.push(`i.status != $${paramIndex}`); params.push(statusNot); paramIndex++; } if (client_id) { conditions.push(`i.client_id = $${paramIndex}`); params.push(client_id); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Get total count const countResult = await query( `SELECT COUNT(*) FROM zen_invoices i JOIN zen_clients c ON i.client_id = c.id ${whereClause}`, params ); const total = parseInt(countResult.rows[0].count); // Get invoices const invoicesResult = await query( `SELECT i.*, c.client_number, c.first_name, c.last_name, c.email as client_email, c.company_name, (SELECT COUNT(*) FROM zen_invoice_items WHERE invoice_id = i.id) as items_count FROM zen_invoices i JOIN zen_clients c ON i.client_id = c.id ${whereClause} ORDER BY i.${safeSortBy} ${safeSortOrder} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, [...params, safeLimit, offset] ); return { invoices: invoicesResult.rows, pagination: { page, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) } }; } /** * Update invoice * @param {number} id - Invoice ID * @param {Object} updates - Fields to update * @returns {Promise} Updated invoice */ export async function updateInvoice(id, updates) { // Get current invoice to check status const currentInvoice = await getInvoiceById(id); if (!currentInvoice) { throw new Error('Invoice not found'); } const allowedFields = [ 'issue_date', 'due_date', 'notes', 'status', 'paid_at', 'first_reminder_days', 'is_recurring', 'recurring_frequency', 'recurring_end_date' ]; // Auto-calculate issue_date if due_date or first_reminder_days are being updated // but issue_date is not explicitly provided if ((updates.due_date || updates.first_reminder_days) && !updates.issue_date) { const dueDate = updates.due_date || currentInvoice.due_date.toISOString().split('T')[0]; const reminderDays = updates.first_reminder_days || currentInvoice.first_reminder_days; updates.issue_date = calculateIssueDate(dueDate, reminderDays); } const setFields = []; const values = []; let paramIndex = 1; for (const [key, value] of Object.entries(updates)) { if (allowedFields.includes(key)) { setFields.push(`${key} = $${paramIndex}`); values.push(value); paramIndex++; } } if (setFields.length === 0 && !updates.items) { throw new Error('No valid fields to update'); } // Allow updating items if invoice is not paid (not 'paid' or 'partial' status) const canEditItems = currentInvoice.status !== 'paid' && currentInvoice.status !== 'partial'; if (canEditItems && updates.items && Array.isArray(updates.items)) { // Validate items if (updates.items.length === 0) { throw new Error('Invoice must have at least one item'); } for (const item of updates.items) { if (!item.name || !item.quantity || !item.unit_price) { throw new Error('Each item must have a name, quantity, and unit_price'); } } // Calculate totals from items const totals = calculateInvoiceTotals(updates.items); // Add totals to update setFields.push(`subtotal = $${paramIndex}`); values.push(totals.subtotal); paramIndex++; setFields.push(`total_amount = $${paramIndex}`); values.push(totals.total); paramIndex++; // Delete existing items await query( `DELETE FROM zen_invoice_items WHERE invoice_id = $1`, [id] ); // Insert new items for (let i = 0; i < updates.items.length; i++) { const item = updates.items[i]; const itemTotal = parseFloat((item.quantity * item.unit_price).toFixed(2)); await query( `INSERT INTO zen_invoice_items (invoice_id, name, description, quantity, unit_price, total, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ id, item.name, item.description || null, item.quantity, item.unit_price, itemTotal, i ] ); } } // Update invoice fields if there are any if (setFields.length > 0) { // Add updated_at setFields.push(`updated_at = CURRENT_TIMESTAMP`); // Add ID parameter values.push(id); await query( `UPDATE zen_invoices SET ${setFields.join(', ')} WHERE id = $${paramIndex}`, values ); } // Return updated invoice with items return await getInvoiceById(id); } /** * Mark invoice as paid * @param {number} id - Invoice ID * @param {number} amount - Amount paid * @returns {Promise} Updated invoice */ export async function markInvoiceAsPaid(id, amount) { const invoice = await getInvoiceById(id); if (!invoice) { throw new Error('Invoice not found'); } const newPaidAmount = parseFloat(invoice.paid_amount) + parseFloat(amount); // Total amount owed includes interest const totalAmountOwed = parseFloat(invoice.total_amount) + parseFloat(invoice.interest_amount || 0); const isFullyPaid = newPaidAmount >= totalAmountOwed; const result = await query( `UPDATE zen_invoices SET paid_amount = $1, status = $2, paid_at = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 RETURNING *`, [ newPaidAmount, isFullyPaid ? 'paid' : 'partial', isFullyPaid ? getTodayUTC() : invoice.paid_at, id ] ); return result.rows[0]; } /** * Add reminder to invoice * @param {number} invoiceId - Invoice ID * @param {string} reminderType - Reminder type * @param {number} daysBefore - Days before due date * @returns {Promise} Created reminder */ export async function addInvoiceReminder(invoiceId, reminderType, daysBefore) { const result = await query( `INSERT INTO zen_invoice_reminders (invoice_id, reminder_type, days_before) VALUES ($1, $2, $3) RETURNING *`, [invoiceId, reminderType, daysBefore] ); return result.rows[0]; } /** * Delete invoice * @param {number} id - Invoice ID * @returns {Promise} Success status * @throws {Error} If invoice is paid or has payments */ export async function deleteInvoice(id) { // Check if invoice exists and get its status const invoiceResult = await query( `SELECT status, paid_amount FROM zen_invoices WHERE id = $1`, [id] ); if (invoiceResult.rows.length === 0) { throw new Error('Invoice not found'); } const invoice = invoiceResult.rows[0]; // Prevent deletion if invoice has been paid (fully or partially) if (invoice.status === 'paid' || invoice.status === 'partial' || parseFloat(invoice.paid_amount) > 0) { throw new Error('Cannot delete paid invoices. Paid invoices must be kept for accounting records.'); } const result = await query( `DELETE FROM zen_invoices WHERE id = $1`, [id] ); return result.rowCount > 0; } /** * Nature-related words for Interac security answers */ const NATURE_WORDS = [ 'forest', 'ocean', 'mountain', 'river', 'lake', 'tree', 'flower', 'leaf', 'stone', 'cloud', 'rain', 'snow', 'sun', 'moon', 'star', 'wind', 'earth', 'sky', 'valley', 'meadow' ]; /** * Generate random digits using a cryptographically secure source * @param {number} length - Number of digits to generate * @returns {string} */ function generateRandomDigits(length) { let result = ''; for (let i = 0; i < length; i++) { result += crypto.randomInt(0, 10); } return result; } /** * Generate Interac security question * Format: 4 random digits + 2-digit client number * @param {string} clientNumber - Client number (e.g., '01') * @returns {string} */ function generateInteracQuestion(clientNumber) { const randomDigits = generateRandomDigits(4); const paddedClientNumber = clientNumber.padStart(2, '0'); return randomDigits + paddedClientNumber; } /** * Generate Interac security answer * Format: nature word + 4 random digits * @returns {string} */ function generateInteracAnswer() { const randomWord = NATURE_WORDS[crypto.randomInt(0, NATURE_WORDS.length)]; const randomDigits = generateRandomDigits(4); return randomWord + randomDigits; } /** * Get Interac credentials by client ID * @param {number} clientId - Client ID * @returns {Promise} */ export async function getInteracCredentialsByClientId(clientId) { const result = await query( `SELECT * FROM zen_invoice_interac WHERE client_id = $1`, [clientId] ); return result.rows.length > 0 ? result.rows[0] : null; } /** * Create Interac credentials for a client * @param {number} clientId - Client ID * @returns {Promise} */ export async function createInteracCredentials(clientId) { // Get client to access client number const clientResult = await query( `SELECT client_number FROM zen_clients WHERE id = $1`, [clientId] ); if (clientResult.rows.length === 0) { throw new Error('Client not found'); } const clientNumber = clientResult.rows[0].client_number; // Generate question and answer const securityQuestion = generateInteracQuestion(clientNumber); const securityAnswer = generateInteracAnswer(); // Insert credentials const result = await query( `INSERT INTO zen_invoice_interac (client_id, security_question, security_answer) VALUES ($1, $2, $3) RETURNING *`, [clientId, securityQuestion, securityAnswer] ); return result.rows[0]; } /** * Get or create Interac credentials for a client * @param {number} clientId - Client ID * @returns {Promise} */ export async function getOrCreateInteracCredentials(clientId) { // Check if credentials already exist let credentials = await getInteracCredentialsByClientId(clientId); // If not, create them if (!credentials) { credentials = await createInteracCredentials(clientId); } return credentials; }