721 lines
20 KiB
JavaScript
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;
|
|
} |