Files
core/src/modules/invoice/crud.js
T
2026-04-12 12:50:14 -04:00

721 lines
20 KiB
JavaScript

/**
* 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<string>}
*/
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<Object>} 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<Object|null>}
*/
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<Object|null>}
*/
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<Object|null>}
*/
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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<boolean>} 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<Object|null>}
*/
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<Object>}
*/
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<Object>}
*/
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;
}