refactoring 1. step

This commit is contained in:
2026-03-02 10:09:24 -06:00
parent 198126c13e
commit 7226883a2e
18 changed files with 2915 additions and 2784 deletions

205
src/services/pdf-service.js Normal file
View File

@@ -0,0 +1,205 @@
/**
* PDF Generation Service
* Handles HTML to PDF conversion using Puppeteer
*/
const path = require('path');
const fs = require('fs').promises;
const { formatMoney, formatDate } = require('../utils/helpers');
// Initialize browser - will be set from main app
let browserInstance = null;
function setBrowser(browser) {
browserInstance = browser;
}
async function getBrowser() {
return browserInstance;
}
/**
* Generate PDF from HTML template
*/
async function generatePdfFromHtml(html, options = {}) {
const {
format = 'Letter',
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
printBackground = true
} = options;
const browser = await getBrowser();
if (!browser) {
throw new Error('Browser not initialized');
}
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
const pdf = await page.pdf({
format,
printBackground,
margin
});
await page.close();
return pdf;
}
/**
* Get company logo as base64 HTML
*/
async function getLogoHtml() {
let logoHTML = '';
try {
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
const logoData = await fs.readFile(logoPath);
const logoBase64 = logoData.toString('base64');
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
} catch (err) {
// No logo found
}
return logoHTML;
}
/**
* Render invoice items to HTML table rows
*/
function renderInvoiceItems(items, invoice = null) {
let itemsHTML = items.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>`;
}).join('');
// Add subtotal
const subtotal = invoice ? invoice.subtotal : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal:</td>
<td class="total-amount">$${formatMoney(subtotal)}</td>
</tr>`;
// Add tax if not exempt
if (invoice && !invoice.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
<td class="total-amount">$${formatMoney(invoice.tax_amount)}</td>
</tr>`;
}
// Add total
const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0;
const total = invoice ? parseFloat(invoice.total) : 0;
const balanceDue = total - amountPaid;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL:</td>
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>`;
// Add downpayment/balance if partial
if (amountPaid > 0) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="color: #059669;">Downpayment:</td>
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
</tr>
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
</tr>`;
}
// Thank you message
itemsHTML += `
<tr class="footer-row">
<td colspan="4" class="thank-you">Thank you for your business!</td>
</tr>`;
return itemsHTML;
}
/**
* Render quote items to HTML table rows
*/
function renderQuoteItems(items, quote = null) {
let itemsHTML = items.map(item => {
let rateFormatted = item.rate;
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
}
return `
<tr>
<td class="qty">${item.quantity}</td>
<td class="description">${item.description}</td>
<td class="rate">${rateFormatted}</td>
<td class="amount">${item.amount}</td>
</tr>`;
}).join('');
// Add subtotal
const subtotal = quote ? quote.subtotal : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Subtotal</td>
<td class="total-amount">$${formatMoney(subtotal)}</td>
</tr>`;
// Add tax if not exempt
if (quote && !quote.tax_exempt) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%)</td>
<td class="total-amount">$${formatMoney(quote.tax_amount)}</td>
</tr>`;
}
// Add total
const total = quote ? quote.total : 0;
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL</td>
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>
<tr class="footer-row">
<td colspan="4" class="thank-you">This quote is valid for 14 days. We appreciate your business </td>
</tr>`;
return itemsHTML;
}
/**
* Format address lines for template
*/
function formatAddressLines(line1, line2, line3, line4, customerName) {
const addressLines = [];
if (line1 && line1.trim().toLowerCase() !== (customerName || '').trim().toLowerCase()) {
addressLines.push(line1);
}
if (line2) addressLines.push(line2);
if (line3) addressLines.push(line3);
if (line4) addressLines.push(line4);
return addressLines.join('<br>');
}
module.exports = {
setBrowser,
getBrowser,
generatePdfFromHtml,
getLogoHtml,
renderInvoiceItems,
renderQuoteItems,
formatAddressLines
};

229
src/services/qbo-service.js Normal file
View File

@@ -0,0 +1,229 @@
/**
* QuickBooks Online Service
* Handles QBO API interactions
*/
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// QBO Item IDs
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
/**
* Get OAuth client and company ID
*/
function getClientInfo() {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
return { oauthClient, companyId, baseUrl };
}
/**
* Export invoice to QBO
*/
async function exportInvoiceToQbo(invoiceId, pool) {
const client = await pool.connect();
try {
const invoiceRes = await client.query(`
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [invoiceId]);
const invoice = invoiceRes.rows[0];
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
const items = itemsRes.rows;
const { companyId, baseUrl } = getClientInfo();
// Get next DocNumber
const maxNumResult = await client.query(`
SELECT GREATEST(
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
) as max_num
`);
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
// Build line items
const lineItems = items.map(item => {
const parseNum = (val) => {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
};
const rate = parseNum(item.rate);
const qty = parseNum(item.quantity) || 1;
const amount = rate * qty;
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
return {
"DetailType": "SalesItemLineDetail",
"Amount": amount,
"Description": item.description,
"SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate,
"Qty": qty
}
};
});
const qboPayload = {
"CustomerRef": { "value": invoice.customer_qbo_id },
"DocNumber": nextDocNumber,
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"Line": lineItems,
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
"EmailStatus": "NotSet",
"BillEmail": { "Address": invoice.email || "" }
};
// Retry on duplicate
let qboInvoice = null;
for (let attempt = 0; attempt < 5; attempt++) {
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboPayload)
});
const data = response.getJson ? response.getJson() : response.json;
if (data.Fault?.Error?.[0]?.code === '6140') {
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
continue;
}
if (data.Fault) {
const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault);
console.error(`❌ QBO Export Fault:`, errMsg);
throw new Error('QBO export failed: ' + errMsg);
}
qboInvoice = data.Invoice || data;
if (qboInvoice.Id) break;
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
}
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
await client.query(
'UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5',
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, invoiceId]
);
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
} finally {
client.release();
}
}
/**
* Sync invoice to QBO (update)
*/
async function syncInvoiceToQbo(invoiceId, pool) {
const client = await pool.connect();
try {
const invoiceRes = await client.query(`
SELECT i.*, c.qbo_id as customer_qbo_id
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [invoiceId]);
const invoice = invoiceRes.rows[0];
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
const { companyId, baseUrl } = getClientInfo();
// Get current sync token
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const currentSyncToken = qboData.Invoice?.SyncToken;
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
const lineItems = itemsRes.rows.map(item => {
const parseNum = (val) => {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
};
const rate = parseNum(item.rate);
const qty = parseNum(item.quantity) || 1;
const amount = rate * qty;
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
return {
"DetailType": "SalesItemLineDetail",
"Amount": amount,
"Description": item.description,
"SalesItemLineDetail": {
"ItemRef": { "value": itemRefId, "name": itemRefName },
"UnitPrice": rate,
"Qty": parseFloat(item.quantity) || 1
}
};
});
const updatePayload = {
"Id": invoice.qbo_id,
"SyncToken": currentSyncToken,
"sparse": true,
"Line": lineItems,
"CustomerRef": { "value": invoice.customer_qbo_id },
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
};
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
const updateRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
if (updateData.Fault) {
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
console.error(`❌ QBO Sync Fault:`, errMsg);
throw new Error('QBO sync failed: ' + errMsg);
}
const updated = updateData.Invoice || updateData;
if (!updated.Id) {
console.error(`❌ QBO unexpected response:`, JSON.stringify(updateData).substring(0, 500));
throw new Error('QBO update returned no ID');
}
await client.query(
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[updated.SyncToken, invoiceId]
);
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
return { success: true, sync_token: updated.SyncToken };
} finally {
client.release();
}
}
module.exports = {
QBO_LABOR_ID,
QBO_PARTS_ID,
getClientInfo,
exportInvoiceToQbo,
syncInvoiceToQbo
};