refactoring 1. step

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

View File

@ -8,3 +8,14 @@ DB_NAME=quotes_db
# Server Configuration
PORT=3000
NODE_ENV=production
# QBO API Credentials
QBO_CLIENT_ID=client_id
QBO_CLIENT_SECRET=client_secret
QBO_ENVIRONMENT=production
QBO_REDIRECT_URI=https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl
# QBO Tokens (aus dem Playground)
QBO_ACCESS_TOKEN=access_token
QBO_REFRESH_TOKEN=refresh_token
QBO_REALM_ID=realm_id

View File

@ -25,7 +25,9 @@ RUN npm install --omit=dev
# Copy application files
COPY server.js ./
COPY qbo_helper.js ./
COPY src ./src
COPY public ./public
COPY templates ./templates
# Create uploads directory
RUN mkdir -p public/uploads && \
@ -38,5 +40,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/customers', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Start server
CMD ["node", "server.js"]
# Start server (using modular entry point)
CMD ["node", "src/index.js"]

View File

@ -2,10 +2,10 @@
"name": "quote-invoice-system",
"version": "2.0.0",
"description": "Quote & Invoice Management System for Bay Area Affiliates",
"main": "server.js",
"main": "src/index.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"csv-parser": "^3.2.0",

117
public/js/utils/api.js Normal file
View File

@ -0,0 +1,117 @@
/**
* API Utility
* Centralized API calls for the frontend
*/
const API = {
// Customer API
customers: {
getAll: () => fetch('/api/customers').then(r => r.json()),
get: (id) => fetch(`/api/customers/${id}`).then(r => r.json()),
create: (data) => fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
update: (id, data) => fetch(`/api/customers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
delete: (id) => fetch(`/api/customers/${id}`, { method: 'DELETE' }).then(r => r.json()),
exportToQbo: (id) => fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' }).then(r => r.json())
},
// Quote API
quotes: {
getAll: () => fetch('/api/quotes').then(r => r.json()),
get: (id) => fetch(`/api/quotes/${id}`).then(r => r.json()),
create: (data) => fetch('/api/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
update: (id, data) => fetch(`/api/quotes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
delete: (id) => fetch(`/api/quotes/${id}`, { method: 'DELETE' }).then(r => r.json()),
convertToInvoice: (id) => fetch(`/api/quotes/${id}/convert-to-invoice`, { method: 'POST' }).then(r => r.json()),
getPdf: (id) => window.open(`/api/quotes/${id}/pdf`, '_blank'),
getHtml: (id) => window.open(`/api/quotes/${id}/html`, '_blank')
},
// Invoice API
invoices: {
getAll: () => fetch('/api/invoices').then(r => r.json()),
get: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
getNextNumber: () => fetch('/api/invoices/next-number').then(r => r.json()),
create: (data) => fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
update: (id, data) => fetch(`/api/invoices/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json()),
delete: (id) => fetch(`/api/invoices/${id}`, { method: 'DELETE' }).then(r => r.json()),
exportToQbo: (id) => fetch(`/api/invoices/${id}/export`, { method: 'POST' }).then(r => r.json()),
updateQbo: (id) => fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' }).then(r => r.json()),
markPaid: (id, paidDate) => fetch(`/api/invoices/${id}/mark-paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid_date: paidDate })
}).then(r => r.json()),
markUnpaid: (id) => fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }).then(r => r.json()),
resetQbo: (id) => fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }).then(r => r.json()),
setEmailStatus: (id, status) => fetch(`/api/invoices/${id}/email-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
}).then(r => r.json()),
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
},
// Payment API
payments: {
getAll: () => fetch('/api/payments').then(r => r.json()),
record: (data) => fetch('/api/qbo/record-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(r => r.json())
},
// QBO API
qbo: {
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
getAccounts: () => fetch('/api/qbo/accounts').then(r => r.json()),
getPaymentMethods: () => fetch('/api/qbo/payment-methods').then(r => r.json()),
getLaborRate: () => fetch('/api/qbo/labor-rate').then(r => r.json()),
getLastSync: () => fetch('/api/qbo/last-sync').then(r => r.json()),
getOverdue: () => fetch('/api/qbo/overdue').then(r => r.json()),
importUnpaid: () => fetch('/api/qbo/import-unpaid', { method: 'POST' }).then(r => r.json()),
syncPayments: () => fetch('/api/qbo/sync-payments', { method: 'POST' }).then(r => r.json()),
auth: () => window.location.href = '/auth/qbo'
},
// Settings API
settings: {
getLogo: () => fetch('/api/logo-info').then(r => r.json()),
uploadLogo: (file) => {
const formData = new FormData();
formData.append('logo', file);
return fetch('/api/upload-logo', {
method: 'POST',
body: formData
}).then(r => r.json());
}
}
};
// Make globally available
window.API = API;

2779
server.js

File diff suppressed because it is too large Load Diff

11
src/config/database.js Normal file
View File

@ -0,0 +1,11 @@
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'quotes_db',
password: process.env.DB_PASSWORD || 'postgres',
port: process.env.DB_PORT || 5432,
});
module.exports = { pool };

20
src/config/qbo.js Normal file
View File

@ -0,0 +1,20 @@
const OAuthClient = require('intuit-oauth');
const { getOAuthClient: getClient, saveTokens, resetOAuthClient } = require('../../qbo_helper');
function getOAuthClient() {
return getClient();
}
function getQboBaseUrl() {
return process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
}
module.exports = {
OAuthClient,
getOAuthClient,
getQboBaseUrl,
saveTokens,
resetOAuthClient
};

91
src/index.js Normal file
View File

@ -0,0 +1,91 @@
/**
* Quote & Invoice System - Main Entry Point
* Modularized Backend
*/
const express = require('express');
const puppeteer = require('puppeteer');
// Import routes
const customerRoutes = require('./routes/customers');
const quoteRoutes = require('./routes/quotes');
const invoiceRoutes = require('./routes/invoices');
const paymentRoutes = require('./routes/payments');
const qboRoutes = require('./routes/qbo');
const settingsRoutes = require('./routes/settings');
// Import PDF service for browser initialization
const { setBrowser } = require('./services/pdf-service');
const app = express();
const PORT = process.env.PORT || 3000;
// Global browser instance
let browser = null;
// Initialize browser on startup
async function initBrowser() {
if (!browser) {
console.log('[BROWSER] Launching persistent browser...');
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--no-zygote',
'--single-process'
],
protocolTimeout: 180000,
timeout: 180000
});
console.log('[BROWSER] Browser launched and ready');
// Pass browser to PDF service
setBrowser(browser);
// Restart browser if it crashes
browser.on('disconnected', () => {
console.log('[BROWSER] Browser disconnected, restarting...');
browser = null;
initBrowser();
});
}
return browser;
}
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Mount routes
app.use('/api/customers', customerRoutes);
app.use('/api/quotes', quoteRoutes);
app.use('/api/invoices', invoiceRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/qbo', qboRoutes);
app.use('/api', settingsRoutes);
// Start server
async function startServer() {
await initBrowser();
app.listen(PORT, () => {
console.log(`Quote System running on port ${PORT}`);
});
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (browser) {
await browser.close();
}
const { pool } = require('./config/database');
await pool.end();
process.exit(0);
});
startServer();
module.exports = app;

271
src/routes/customers.js Normal file
View File

@ -0,0 +1,271 @@
/**
* Customer Routes
* Handles customer CRUD operations and QBO sync
*/
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET all customers
router.get('/', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM customers ORDER BY name');
res.json(result.rows);
} catch (error) {
console.error('Error fetching customers:', error);
res.status(500).json({ error: 'Error fetching customers' });
}
});
// POST create customer
router.post('/', async (req, res) => {
const {
name, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
const result = await pool.query(
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null, account_number || null,
email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, remarks || null]
);
res.json(result.rows[0]);
} catch (error) {
console.error('Error creating customer:', error);
res.status(500).json({ error: 'Error creating customer' });
}
});
// PUT update customer
router.put('/:id', async (req, res) => {
const { id } = req.params;
const {
name, contact, line1, line2, line3, line4, city, state, zip_code,
account_number, email, phone, phone2, taxable, remarks
} = req.body;
try {
const result = await pool.query(
`UPDATE customers
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
WHERE id = $16
RETURNING *`,
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
city || null, state || null, zip_code || null, account_number || null,
email || null, phone || null, phone2 || null,
taxable !== undefined ? taxable : true, remarks || null, id]
);
const customer = result.rows[0];
// QBO Update
if (customer.qbo_id) {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// Get SyncToken
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Customer?.SyncToken;
if (syncToken !== undefined) {
const updatePayload = {
Id: customer.qbo_id,
SyncToken: syncToken,
sparse: true,
DisplayName: name,
CompanyName: name,
PrimaryEmailAddr: email ? { Address: email } : undefined,
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
Taxable: taxable !== false,
Notes: remarks || undefined
};
// Contact → GivenName / FamilyName
if (contact) {
const parts = contact.trim().split(/\s+/);
if (parts.length >= 2) {
updatePayload.GivenName = parts[0];
updatePayload.FamilyName = parts.slice(1).join(' ');
} else {
updatePayload.GivenName = parts[0];
}
}
// Address
const addr = {};
if (line1) addr.Line1 = line1;
if (line2) addr.Line2 = line2;
if (line3) addr.Line3 = line3;
if (line4) addr.Line4 = line4;
if (city) addr.City = city;
if (state) addr.CountrySubDivisionCode = state;
if (zip_code) addr.PostalCode = zip_code;
if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr;
console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
console.log(`✅ QBO Customer ${customer.qbo_id} updated.`);
}
} catch (qboError) {
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
}
}
res.json(customer);
} catch (error) {
console.error('Error updating customer:', error);
res.status(500).json({ error: 'Error updating customer' });
}
});
// DELETE customer
router.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
// Load customer
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
if (custResult.rows.length === 0) {
return res.status(404).json({ error: 'Customer not found' });
}
const customer = custResult.rows[0];
// Deactivate in QBO if present
if (customer.qbo_id) {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// Get SyncToken
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Customer?.SyncToken;
if (syncToken !== undefined) {
console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: customer.qbo_id,
SyncToken: syncToken,
sparse: true,
Active: false
})
});
console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`);
}
} catch (qboError) {
console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message);
}
}
// Delete locally
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting customer:', error);
res.status(500).json({ error: 'Error deleting customer' });
}
});
// POST export customer to QBO
router.post('/:id/export-qbo', async (req, res) => {
const { id } = req.params;
try {
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
const customer = custResult.rows[0];
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const qboCustomer = {
DisplayName: customer.name,
CompanyName: customer.name,
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
Taxable: customer.taxable !== false,
Notes: customer.remarks || undefined
};
// Contact
if (customer.contact) {
const parts = customer.contact.trim().split(/\s+/);
if (parts.length >= 2) {
qboCustomer.GivenName = parts[0];
qboCustomer.FamilyName = parts.slice(1).join(' ');
} else {
qboCustomer.GivenName = parts[0];
}
}
// Address
const addr = {};
if (customer.line1) addr.Line1 = customer.line1;
if (customer.line2) addr.Line2 = customer.line2;
if (customer.line3) addr.Line3 = customer.line3;
if (customer.line4) addr.Line4 = customer.line4;
if (customer.city) addr.City = customer.city;
if (customer.state) addr.CountrySubDivisionCode = customer.state;
if (customer.zip_code) addr.PostalCode = customer.zip_code;
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/customer`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboCustomer)
});
const data = response.getJson ? response.getJson() : response.json;
const qboId = data.Customer?.Id;
if (!qboId) throw new Error('QBO returned no ID');
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
res.json({ success: true, qbo_id: qboId, name: customer.name });
} catch (error) {
console.error('QBO Customer Export Error:', error);
res.status(500).json({ error: 'Export failed: ' + error.message });
}
});
module.exports = router;

807
src/routes/invoices.js Normal file
View File

@ -0,0 +1,807 @@
/**
* Invoice Routes
* Handles invoice CRUD operations, QBO sync, and PDF generation
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const { pool } = require('../config/database');
const { getNextInvoiceNumber } = require('../utils/numberGenerators');
const { formatDate, formatMoney } = require('../utils/helpers');
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET all invoices
router.get('/', async (req, res) => {
try {
const result = await pool.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
ORDER BY i.created_at DESC
`);
const rows = result.rows.map(r => ({
...r,
amount_paid: parseFloat(r.amount_paid) || 0,
balance: (parseFloat(r.total) || 0) - (parseFloat(r.amount_paid) || 0)
}));
res.json(rows);
} catch (error) {
console.error('Error fetching invoices:', error);
res.status(500).json({ error: 'Error fetching invoices' });
}
});
// GET next invoice number
router.get('/next-number', async (req, res) => {
try {
const nextNumber = await getNextInvoiceNumber();
res.json({ next_number: nextNumber });
} catch (error) {
console.error('Error getting next invoice number:', error);
res.status(500).json({ error: 'Error getting next invoice number' });
}
});
// GET single invoice
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
res.json({ invoice, items: itemsResult.rows });
} catch (error) {
console.error('Error fetching invoice:', error);
res.status(500).json({ error: 'Error fetching invoice' });
}
});
// POST create invoice
router.post('/', async (req, res) => {
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Validate invoice_number if provided
if (invoice_number && !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invoice number must be numeric.' });
}
const tempNumber = invoice_number || `DRAFT-${Date.now()}`;
if (invoice_number) {
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1', [invoice_number]);
if (existing.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
}
}
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) subtotal += amount;
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
const invoiceResult = await client.query(
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`,
[tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id]
);
const invoiceId = invoiceResult.rows[0].id;
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
);
}
await client.query('COMMIT');
// Auto QBO Export
let qboResult = null;
try {
qboResult = await exportInvoiceToQbo(invoiceId, pool);
if (qboResult.skipped) {
console.log(` Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
}
} catch (qboErr) {
console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message);
}
res.json({
...invoiceResult.rows[0],
qbo_id: qboResult?.qbo_id || null,
qbo_doc_number: qboResult?.qbo_doc_number || null
});
} catch (error) {
await client.query('ROLLBACK');
console.error('Error creating invoice:', error);
res.status(500).json({ error: 'Error creating invoice' });
} finally {
client.release();
}
});
// PUT update invoice
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Validate invoice_number if provided
if (invoice_number && !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invoice number must be numeric.' });
}
if (invoice_number) {
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id]);
if (existing.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
}
}
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) subtotal += amount;
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// Update local
if (invoice_number) {
await client.query(
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, bill_to_name = $12, updated_at = CURRENT_TIMESTAMP
WHERE id = $13`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id]
);
} else {
await client.query(
`UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5,
tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, scheduled_send_date = $10, bill_to_name = $11, updated_at = CURRENT_TIMESTAMP
WHERE id = $12`,
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id]
);
}
// Delete and re-insert items
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
);
}
await client.query('COMMIT');
// Auto QBO: Export if not yet in QBO, Sync if already in QBO
let qboResult = null;
try {
const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
const hasQboId = !!checkRes.rows[0]?.qbo_id;
if (hasQboId) {
qboResult = await syncInvoiceToQbo(id, pool);
} else {
qboResult = await exportInvoiceToQbo(id, pool);
}
if (qboResult.skipped) {
console.log(` Invoice ${id}: ${qboResult.reason}`);
}
} catch (qboErr) {
console.error(`⚠️ Auto QBO failed for Invoice ${id}:`, qboErr.message);
}
res.json({ success: true, qbo_synced: !!qboResult?.success, qbo_id: qboResult?.qbo_id || null, qbo_doc_number: qboResult?.qbo_doc_number || null });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating invoice:', error);
res.status(500).json({ error: 'Error updating invoice' });
} finally {
client.release();
}
});
// DELETE invoice
router.delete('/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Load invoice to check qbo_id
const invResult = await client.query('SELECT qbo_id, qbo_sync_token, invoice_number FROM invoices WHERE id = $1', [id]);
if (invResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invResult.rows[0];
// Delete in QBO if present
if (invoice.qbo_id) {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Invoice?.SyncToken;
if (syncToken !== undefined) {
console.log(`🗑️ Voiding QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.invoice_number})...`);
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice?operation=void`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: invoice.qbo_id,
SyncToken: syncToken
})
});
console.log(`✅ QBO Invoice ${invoice.qbo_id} voided.`);
}
} catch (qboError) {
console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message);
}
}
// Delete locally
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
await client.query('DELETE FROM payment_invoices WHERE invoice_id = $1', [id]);
await client.query('DELETE FROM invoices WHERE id = $1', [id]);
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error deleting invoice:', error);
res.status(500).json({ error: 'Error deleting invoice' });
} finally {
client.release();
}
});
// PATCH invoice email status
router.patch('/:id/email-status', async (req, res) => {
const { id } = req.params;
const { status } = req.body;
if (!['sent', 'open'].includes(status)) {
return res.status(400).json({ error: 'Status must be "sent" or "open".' });
}
try {
const invResult = await pool.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
if (invResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
const invoice = invResult.rows[0];
// Update QBO if present
if (invoice.qbo_id) {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const qboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
const syncToken = qboData.Invoice?.SyncToken;
if (syncToken !== undefined) {
const emailStatus = status === 'sent' ? 'EmailSent' : 'NotSet';
await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Id: invoice.qbo_id,
SyncToken: syncToken,
sparse: true,
EmailStatus: emailStatus
})
});
console.log(`✅ QBO Invoice ${invoice.qbo_id} email status → ${emailStatus}`);
}
}
// Update local
await pool.query(
'UPDATE invoices SET email_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[status, id]
);
res.json({ success: true, status });
} catch (error) {
console.error('Error updating email status:', error);
res.status(500).json({ error: 'Failed to update status: ' + error.message });
}
});
// PATCH mark invoice as paid
router.patch('/:id/mark-paid', async (req, res) => {
const { id } = req.params;
const { paid_date } = req.body;
try {
const dateToUse = paid_date || new Date().toISOString().split('T')[0];
const result = await pool.query(
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
[dateToUse, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`);
res.json(result.rows[0]);
} catch (error) {
console.error('Error marking invoice as paid:', error);
res.status(500).json({ error: 'Error marking invoice as paid' });
}
});
// PATCH mark invoice as unpaid
router.patch('/:id/mark-unpaid', async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`);
res.json(result.rows[0]);
} catch (error) {
console.error('Error marking invoice as unpaid:', error);
res.status(500).json({ error: 'Error marking invoice as unpaid' });
}
});
// PATCH reset QBO link
router.patch('/:id/reset-qbo', async (req, res) => {
const { id } = req.params;
try {
const result = await pool.query(
`UPDATE invoices
SET qbo_id = NULL, qbo_sync_token = NULL, qbo_doc_number = NULL, invoice_number = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 RETURNING *`,
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
console.log(`🔄 Invoice ID ${id} QBO-Verknüpfung zurückgesetzt`);
res.json(result.rows[0]);
} catch (error) {
console.error('Error resetting QBO link:', error);
res.status(500).json({ error: 'Error resetting QBO link' });
}
});
// POST export to QBO
router.post('/:id/export', async (req, res) => {
const { id } = req.params;
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
`, [id]);
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
const invoice = invoiceRes.rows[0];
if (!invoice.customer_qbo_id) {
return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` });
}
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
const items = itemsRes.rows;
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
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();
const lineItems = items.map(item => {
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
const itemRefId = item.qbo_item_id || '9';
const itemRefName = itemRefId == '5' ? "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 qboInvoicePayload = {
"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": "EmailSent",
"BillEmail": { "Address": invoice.email || "" }
};
let qboInvoice = null;
const MAX_RETRIES = 5;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
console.log(`📤 Sende Rechnung an QBO (DocNumber: ${qboInvoicePayload.DocNumber})...`);
const createResponse = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboInvoicePayload)
});
const responseData = createResponse.getJson ? createResponse.getJson() : createResponse.json;
if (responseData.Fault?.Error?.[0]?.code === '6140') {
const oldNum = parseInt(qboInvoicePayload.DocNumber);
qboInvoicePayload.DocNumber = (oldNum + 1).toString();
console.log(`⚠️ DocNumber ${oldNum} existiert bereits. Versuche ${qboInvoicePayload.DocNumber}...`);
continue;
}
qboInvoice = responseData.Invoice || responseData;
if (qboInvoice.Id) {
break;
} else {
console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2));
throw new Error("QBO hat keine ID zurückgegeben: " +
(responseData.Fault?.Error?.[0]?.Message || JSON.stringify(responseData)));
}
}
if (!qboInvoice || !qboInvoice.Id) {
throw new Error(`Konnte nach ${MAX_RETRIES} Versuchen keine freie DocNumber finden.`);
}
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`);
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, id]
);
res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber });
} catch (error) {
console.error("QBO Export Error:", error);
let errorDetails = error.message;
if (error.response?.data?.Fault?.Error?.[0]) {
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
}
res.status(500).json({ error: "QBO Export failed: " + errorDetails });
} finally {
client.release();
}
});
// POST update in QBO
router.post('/:id/update-qbo', async (req, res) => {
const { id } = req.params;
const QBO_LABOR_ID = '5';
const QBO_PARTS_ID = '9';
const dbClient = await pool.connect();
try {
const invoiceRes = await dbClient.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
`, [id]);
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
const invoice = invoiceRes.rows[0];
if (!invoice.qbo_id) {
return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' });
}
if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') {
return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' });
}
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
const items = itemsRes.rows;
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`);
const currentQboRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
method: 'GET'
});
const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json;
const currentQboInvoice = currentQboData.Invoice;
if (!currentQboInvoice) {
return res.status(500).json({ error: 'Could not load current invoice from QBO.' });
}
const currentSyncToken = currentQboInvoice.SyncToken;
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
const lineItems = items.map(item => {
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
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(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`);
const updateResponse = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/invoice`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload)
});
const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json;
const updatedInvoice = updateData.Invoice || updateData;
if (!updatedInvoice.Id) {
console.error("QBO Update Response:", JSON.stringify(updateData, null, 2));
throw new Error("QBO did not return an updated invoice.");
}
console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`);
await dbClient.query(
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[updatedInvoice.SyncToken, id]
);
res.json({
success: true,
qbo_id: updatedInvoice.Id,
sync_token: updatedInvoice.SyncToken,
message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.`
});
} catch (error) {
console.error("QBO Update Error:", error);
let errorDetails = error.message;
if (error.response?.data?.Fault?.Error?.[0]) {
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
}
res.status(500).json({ error: "QBO Update failed: " + errorDetails });
} finally {
dbClient.release();
}
});
// GET invoice PDF
router.get('/:id/pdf', async (req, res) => {
const { id } = req.params;
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
try {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
const pdf = await generatePdfFromHtml(html);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"`
});
res.end(pdf, 'binary');
console.log('[INVOICE-PDF] Invoice PDF sent successfully');
} catch (error) {
console.error('[INVOICE-PDF] ERROR:', error);
res.status(500).json({ error: 'Error generating PDF', details: error.message });
}
});
// GET invoice HTML (debug)
router.get('/:id/html', async (req, res) => {
const { id } = req.params;
try {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
const itemsResult = await pool.query(
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
[id]
);
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error) {
console.error('[HTML] ERROR:', error);
res.status(500).json({ error: 'Error generating HTML' });
}
});
module.exports = router;

29
src/routes/payments.js Normal file
View File

@ -0,0 +1,29 @@
/**
* Payment Routes
* Handles payment recording and listing
*/
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
// GET all payments
router.get('/', async (req, res) => {
try {
const result = await pool.query(`
SELECT p.*, c.name as customer_name,
COALESCE(json_agg(json_build_object(
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
FROM payments p
LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
LEFT JOIN invoices i ON i.id = pi.invoice_id
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
`);
res.json(result.rows);
} catch (error) {
res.status(500).json({ error: 'Error fetching payments' });
}
});
module.exports = router;

601
src/routes/qbo.js Normal file
View File

@ -0,0 +1,601 @@
/**
* QBO Routes
* Handles QBO OAuth, sync, and data operations
*/
const express = require('express');
const router = express.Router();
const { pool } = require('../config/database');
const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo');
const { makeQboApiCall } = require('../../qbo_helper');
// GET QBO status
router.get('/status', (req, res) => {
try {
const client = getOAuthClient();
const token = client.getToken();
const hasToken = !!(token && token.refresh_token);
res.json({
connected: hasToken,
realmId: token?.realmId || null
});
} catch (e) {
res.json({ connected: false });
}
});
// GET auth URL - redirects to Intuit
router.get('/auth', (req, res) => {
const client = getOAuthClient();
const authUri = client.authorizeUri({
scope: [require('../config/qbo').OAuthClient.scopes.Accounting],
state: 'intuit-qbo-auth'
});
console.log('🔗 Redirecting to QBO Authorization:', authUri);
res.redirect(authUri);
});
// OAuth callback
router.get('/auth/callback', async (req, res) => {
const client = getOAuthClient();
try {
const authResponse = await client.createToken(req.url);
console.log('✅ QBO Authorization erfolgreich!');
saveTokens();
res.redirect('/#settings');
} catch (e) {
console.error('❌ QBO Authorization fehlgeschlagen:', e);
res.status(500).send(`
<h2>QBO Authorization Failed</h2>
<p>${e.message || e}</p>
<a href="/">Zurück zur App</a>
`);
}
});
// GET bank accounts from QBO
router.get('/accounts', async (req, res) => {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET payment methods from QBO
router.get('/payment-methods', async (req, res) => {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET labor rate from QBO
router.get('/labor-rate', async (req, res) => {
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/item/5`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const rate = data.Item?.UnitPrice || null;
console.log(`💰 QBO Labor Rate: $${rate}`);
res.json({ rate });
} catch (error) {
console.error('Error fetching labor rate:', error);
res.json({ rate: null });
}
});
// GET last sync timestamp
router.get('/last-sync', async (req, res) => {
try {
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
res.json({ last_sync: result.rows[0]?.value || null });
} catch (error) {
res.json({ last_sync: null });
}
});
// GET overdue invoices from QBO
router.get('/overdue', async (req, res) => {
try {
const date = new Date();
date.setDate(date.getDate() - 30);
const dateStr = date.toISOString().split('T')[0];
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const invoices = data.QueryResponse?.Invoice || [];
console.log(`${invoices.length} überfällige Rechnungen gefunden.`);
res.json(invoices);
} catch (error) {
console.error("QBO Report Error:", error);
res.status(500).json({ error: error.message });
}
});
// POST import unpaid invoices from QBO
router.post('/import-unpaid', async (req, res) => {
const dbClient = await pool.connect();
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
console.log('📥 QBO Import: Lade unbezahlte Rechnungen...');
const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000";
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const qboInvoices = data.QueryResponse?.Invoice || [];
console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`);
if (qboInvoices.length === 0) {
return res.json({
success: true,
imported: 0,
skipped: 0,
skippedNoCustomer: 0,
message: 'Keine unbezahlten Rechnungen in QBO gefunden.'
});
}
// Load local customers
const customersResult = await dbClient.query(
'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL'
);
const customerMap = new Map();
customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c));
// Get already imported QBO invoices
const existingResult = await dbClient.query(
'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL'
);
const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id));
let imported = 0;
let skipped = 0;
let skippedNoCustomer = 0;
const skippedCustomerNames = [];
await dbClient.query('BEGIN');
for (const qboInv of qboInvoices) {
const qboId = String(qboInv.Id);
if (existingQboIds.has(qboId)) {
skipped++;
continue;
}
const customerQboId = String(qboInv.CustomerRef?.value || '');
const localCustomer = customerMap.get(customerQboId);
if (!localCustomer) {
skippedNoCustomer++;
const custName = qboInv.CustomerRef?.name || 'Unbekannt';
if (!skippedCustomerNames.includes(custName)) {
skippedCustomerNames.push(custName);
}
continue;
}
const docNumber = qboInv.DocNumber || '';
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
const syncToken = qboInv.SyncToken || '';
let terms = 'Net 30';
if (qboInv.SalesTermRef?.name) {
terms = qboInv.SalesTermRef.name;
}
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
const taxExempt = taxAmount === 0;
const total = parseFloat(qboInv.TotalAmt) || 0;
const subtotal = total - taxAmount;
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
const authCode = qboInv.CustomerMemo?.value || '';
const invoiceResult = await dbClient.query(
`INSERT INTO invoices
(invoice_number, customer_id, invoice_date, terms, auth_code,
tax_exempt, tax_rate, subtotal, tax_amount, total,
qbo_id, qbo_sync_token, qbo_doc_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`,
[docNumber, localCustomer.id, txnDate, terms, authCode,
taxExempt, taxRate, subtotal, taxAmount, total,
qboId, syncToken, docNumber]
);
const localInvoiceId = invoiceResult.rows[0].id;
const lines = qboInv.Line || [];
let itemOrder = 0;
for (const line of lines) {
if (line.DetailType !== 'SalesItemLineDetail') continue;
const detail = line.SalesItemLineDetail || {};
const qty = String(detail.Qty || 1);
const rate = String(detail.UnitPrice || 0);
const amount = String(line.Amount || 0);
const description = line.Description || '';
const itemRefValue = detail.ItemRef?.value || '9';
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
let qboItemId = '9';
if (itemRefValue === '5' || itemRefName.includes('labor')) {
qboItemId = '5';
}
await dbClient.query(
`INSERT INTO invoice_items
(invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId]
);
itemOrder++;
}
imported++;
console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`);
}
await dbClient.query('COMMIT');
const message = [
`${imported} Rechnungen importiert.`,
skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '',
skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : ''
].filter(Boolean).join(' ');
console.log(`📥 QBO Import abgeschlossen: ${message}`);
res.json({
success: true,
imported,
skipped,
skippedNoCustomer,
skippedCustomerNames,
message
});
} catch (error) {
await dbClient.query('ROLLBACK');
console.error('❌ QBO Import Error:', error);
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
} finally {
dbClient.release();
}
});
// POST record payment in QBO
router.post('/record-payment', async (req, res) => {
const {
invoice_payments,
payment_date,
reference_number,
payment_method_id,
payment_method_name,
deposit_to_account_id,
deposit_to_account_name
} = req.body;
if (!invoice_payments || invoice_payments.length === 0) {
return res.status(400).json({ error: 'No invoices selected.' });
}
const dbClient = await pool.connect();
try {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const ids = invoice_payments.map(ip => ip.invoice_id);
const result = await dbClient.query(
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.id = ANY($1)`, [ids]
);
const invoicesData = result.rows;
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
if (notInQbo.length > 0) {
return res.status(400).json({
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
});
}
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
if (custIds.length > 1) {
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
}
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
const qboPayment = {
CustomerRef: { value: custIds[0] },
TotalAmt: totalAmt,
TxnDate: payment_date,
PaymentRefNum: reference_number || '',
PaymentMethodRef: { value: payment_method_id },
DepositToAccountRef: { value: deposit_to_account_id },
Line: invoicesData.map(inv => ({
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
}))
};
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(qboPayment)
});
const data = response.getJson ? response.getJson() : response.json;
if (!data.Payment) {
return res.status(500).json({
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
});
}
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
await dbClient.query('BEGIN');
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[payment_date, reference_number || null, payment_method_name || 'Check',
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
);
const localPaymentId = payResult.rows[0].id;
for (const ip of invoice_payments) {
const payAmt = parseFloat(ip.amount);
const inv = invoicesData.find(i => i.id === ip.invoice_id);
const invTotal = inv ? parseFloat(inv.total) : 0;
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[localPaymentId, ip.invoice_id, payAmt]
);
if (payAmt >= invTotal) {
await dbClient.query(
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[payment_date, ip.invoice_id]
);
}
}
await dbClient.query('COMMIT');
res.json({
success: true,
payment_id: localPaymentId,
qbo_payment_id: qboPaymentId,
total: totalAmt,
invoices_paid: invoice_payments.length,
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Payment Error:', error);
res.status(500).json({ error: 'Payment failed: ' + error.message });
} finally {
dbClient.release();
}
});
// POST sync payments from QBO
router.post('/sync-payments', async (req, res) => {
const dbClient = await pool.connect();
try {
const openResult = await dbClient.query(`
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
FROM invoices i
WHERE i.qbo_id IS NOT NULL
`);
const openInvoices = openResult.rows;
if (openInvoices.length === 0) {
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
return res.json({ synced: 0, message: 'All invoices up to date.' });
}
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
const batchSize = 50;
const qboInvoices = new Map();
for (let i = 0; i < openInvoices.length; i += batchSize) {
const batch = openInvoices.slice(i, i + batchSize);
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const invoices = data.QueryResponse?.Invoice || [];
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
}
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
let updated = 0;
let newPayments = 0;
await dbClient.query('BEGIN');
for (const localInv of openInvoices) {
const qboInv = qboInvoices.get(localInv.qbo_id);
if (!qboInv) continue;
const qboBalance = parseFloat(qboInv.Balance) || 0;
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
const localPaid = parseFloat(localInv.local_paid) || 0;
if (qboBalance === 0 && qboTotal > 0) {
const UNDEPOSITED_FUNDS_ID = '221';
let status = 'Paid';
if (qboInv.LinkedTxn) {
for (const txn of qboInv.LinkedTxn) {
if (txn.TxnType === 'Payment') {
try {
const pmRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
method: 'GET'
});
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
const payment = pmData.Payment;
if (payment && payment.DepositToAccountRef &&
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
status = 'Deposited';
}
} catch (e) { /* ignore */ }
}
}
}
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
if (needsUpdate) {
await dbClient.query(
`UPDATE invoices SET
paid_date = COALESCE(paid_date, CURRENT_DATE),
payment_status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[status, localInv.id]
);
updated++;
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
}
const diff = qboTotal - localPaid;
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[payResult.rows[0].id, localInv.id, diff]
);
newPayments++;
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
}
} else if (qboBalance > 0 && qboBalance < qboTotal) {
const qboPaid = qboTotal - qboBalance;
const diff = qboPaid - localPaid;
const needsUpdate = localInv.payment_status !== 'Partial';
if (needsUpdate) {
await dbClient.query(
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['Partial', localInv.id]
);
updated++;
}
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[payResult.rows[0].id, localInv.id, diff]
);
newPayments++;
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
}
}
}
await dbClient.query(`
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
ON CONFLICT (key) DO UPDATE SET value = $1
`, [new Date().toISOString()]);
await dbClient.query('COMMIT');
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
res.json({
synced: updated,
new_payments: newPayments,
total_checked: openInvoices.length,
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Sync Error:', error);
res.status(500).json({ error: 'Sync failed: ' + error.message });
} finally {
dbClient.release();
}
});
module.exports = router;

370
src/routes/quotes.js Normal file
View File

@ -0,0 +1,370 @@
/**
* Quote Routes
* Handles quote CRUD operations and PDF generation
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const { pool } = require('../config/database');
const { getNextQuoteNumber } = require('../utils/numberGenerators');
const { formatDate, formatMoney } = require('../utils/helpers');
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service');
// GET all quotes
router.get('/', async (req, res) => {
try {
const result = await pool.query(`
SELECT q.*, c.name as customer_name
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
ORDER BY q.created_at DESC
`);
res.json(result.rows);
} catch (error) {
console.error('Error fetching quotes:', error);
res.status(500).json({ error: 'Error fetching quotes' });
}
});
// GET single quote
router.get('/:id', async (req, res) => {
const { id } = req.params;
try {
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
res.json({
quote: quoteResult.rows[0],
items: itemsResult.rows
});
} catch (error) {
console.error('Error fetching quote:', error);
res.status(500).json({ error: 'Error fetching quote' });
}
});
// POST create quote
router.post('/', async (req, res) => {
const { customer_id, quote_date, tax_exempt, items } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
const quote_number = await getNextQuoteNumber();
let subtotal = 0;
let has_tbd = false;
for (const item of items) {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
has_tbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
const quoteResult = await client.query(
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
);
const quoteId = quoteResult.rows[0].id;
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
);
}
await client.query('COMMIT');
res.json(quoteResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
console.error('Error creating quote:', error);
res.status(500).json({ error: 'Error creating quote' });
} finally {
client.release();
}
});
// PUT update quote
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { customer_id, quote_date, tax_exempt, items } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
let subtotal = 0;
let has_tbd = false;
for (const item of items) {
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
has_tbd = true;
} else {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
subtotal += amount;
}
}
}
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
await client.query(
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
WHERE id = $9`,
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
);
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
for (let i = 0; i < items.length; i++) {
await client.query(
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
);
}
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error updating quote:', error);
res.status(500).json({ error: 'Error updating quote' });
} finally {
client.release();
}
});
// DELETE quote
router.delete('/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
console.error('Error deleting quote:', error);
res.status(500).json({ error: 'Error deleting quote' });
} finally {
client.release();
}
});
// GET quote PDF
router.get('/:id/pdf', async (req, res) => {
const { id } = req.params;
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
try {
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const quote = quoteResult.rows[0];
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', quote.city || '')
.replace('{{CUSTOMER_STATE}}', quote.state || '')
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
.replace('{{ITEMS}}', itemsHTML)
.replace('{{TBD_NOTE}}', tbdNote);
const pdf = await generatePdfFromHtml(html);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdf.length,
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
});
res.end(pdf, 'binary');
console.log('[PDF] Quote PDF sent successfully');
} catch (error) {
console.error('[PDF] ERROR:', error);
res.status(500).json({ error: 'Error generating PDF', details: error.message });
}
});
// GET quote HTML (debug)
router.get('/:id/html', async (req, res) => {
const { id } = req.params;
try {
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
return res.status(404).json({ error: 'Quote not found' });
}
const quote = quoteResult.rows[0];
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
let html = await fs.readFile(templatePath, 'utf-8');
const logoHTML = await getLogoHtml();
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
html = html
.replace('{{LOGO_HTML}}', logoHTML)
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
.replace('{{CUSTOMER_STREET}}', streetBlock)
.replace('{{CUSTOMER_CITY}}', quote.city || '')
.replace('{{CUSTOMER_STATE}}', quote.state || '')
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
.replace('{{ITEMS}}', itemsHTML)
.replace('{{TBD_NOTE}}', tbdNote);
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error) {
console.error('[HTML] ERROR:', error);
res.status(500).json({ error: 'Error generating HTML' });
}
});
// POST convert quote to invoice
router.post('/:id/convert-to-invoice', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
try {
await client.query('BEGIN');
const quoteResult = await pool.query(`
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
FROM quotes q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = $1
`, [id]);
if (quoteResult.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Quote not found' });
}
const quote = quoteResult.rows[0];
const itemsResult = await pool.query(
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
[id]
);
const hasTBD = itemsResult.rows.some(item =>
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
);
if (hasTBD) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
}
const invoice_number = null;
const invoiceDate = new Date().toISOString().split('T')[0];
const invoiceResult = await client.query(
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[invoice_number, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
);
const invoiceId = invoiceResult.rows[0].id;
for (let i = 0; i < itemsResult.rows.length; i++) {
const item = itemsResult.rows[i];
await client.query(
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
);
}
await client.query('COMMIT');
res.json(invoiceResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
console.error('Error converting quote to invoice:', error);
res.status(500).json({ error: 'Error converting quote to invoice' });
} finally {
client.release();
}
});
module.exports = router;

71
src/routes/settings.js Normal file
View File

@ -0,0 +1,71 @@
/**
* Settings Routes
* Handles logo upload and settings
*/
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
// Configure multer for logo upload
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads');
try {
await fs.mkdir(uploadDir, { recursive: true });
} catch (err) {
console.error('Error creating upload directory:', err);
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
cb(null, 'company-logo.png');
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'));
}
}
});
// GET logo info
router.get('/logo-info', async (req, res) => {
try {
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
try {
await fs.access(logoPath);
res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' });
} catch {
res.json({ hasLogo: false });
}
} catch (error) {
console.error('Error checking logo:', error);
res.status(500).json({ error: 'Error checking logo' });
}
});
// POST upload logo
router.post('/upload-logo', upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.json({
message: 'Logo uploaded successfully',
path: '/uploads/company-logo.png'
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Error uploading logo' });
}
});
module.exports = router;

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
};

38
src/utils/helpers.js Normal file
View File

@ -0,0 +1,38 @@
/**
* Utility helper functions for the Quote & Invoice System
*/
function formatDate(date) {
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const year = d.getFullYear();
return `${month}/${day}/${year}`;
}
function formatMoney(val) {
return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function parseNumericValue(val) {
if (val === null || val === undefined) return 0;
if (typeof val === 'number') return val;
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
}
function formatAddress(address) {
if (!address) return '';
const lines = [];
if (address.line1) lines.push(address.line1);
if (address.line2) lines.push(address.line2);
if (address.line3) lines.push(address.line3);
if (address.line4) lines.push(address.line4);
return lines.join('<br>');
}
module.exports = {
formatDate,
formatMoney,
parseNumericValue,
formatAddress
};

View File

@ -0,0 +1,37 @@
/**
* Number generation utilities for quotes and invoices
*/
const { pool } = require('../config/database');
async function getNextQuoteNumber() {
const year = new Date().getFullYear();
const result = await pool.query(
'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1',
[`${year}-%`]
);
if (result.rows.length === 0) {
return `${year}-001`;
}
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]);
const nextNumber = String(lastNumber + 1).padStart(3, '0');
return `${year}-${nextNumber}`;
}
async function getNextInvoiceNumber() {
const result = await pool.query(
'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\''
);
if (result.rows.length === 0 || result.rows[0].max_number === null) {
return '110508';
}
return String(parseInt(result.rows[0].max_number) + 1);
}
module.exports = {
getNextQuoteNumber,
getNextInvoiceNumber
};