From 7226883a2e793bd081fe0d05891ea4b099bab091 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 2 Mar 2026 10:09:24 -0600 Subject: [PATCH] refactoring 1. step --- .env.example | 11 + Dockerfile | 6 +- package.json | 6 +- public/js/utils/api.js | 117 ++ server.js | 2779 --------------------------------- src/config/database.js | 11 + src/config/qbo.js | 20 + src/index.js | 91 ++ src/routes/customers.js | 271 ++++ src/routes/invoices.js | 807 ++++++++++ src/routes/payments.js | 29 + src/routes/qbo.js | 601 +++++++ src/routes/quotes.js | 370 +++++ src/routes/settings.js | 71 + src/services/pdf-service.js | 205 +++ src/services/qbo-service.js | 229 +++ src/utils/helpers.js | 38 + src/utils/numberGenerators.js | 37 + 18 files changed, 2915 insertions(+), 2784 deletions(-) create mode 100644 public/js/utils/api.js delete mode 100644 server.js create mode 100644 src/config/database.js create mode 100644 src/config/qbo.js create mode 100644 src/index.js create mode 100644 src/routes/customers.js create mode 100644 src/routes/invoices.js create mode 100644 src/routes/payments.js create mode 100644 src/routes/qbo.js create mode 100644 src/routes/quotes.js create mode 100644 src/routes/settings.js create mode 100644 src/services/pdf-service.js create mode 100644 src/services/qbo-service.js create mode 100644 src/utils/helpers.js create mode 100644 src/utils/numberGenerators.js diff --git a/.env.example b/.env.example index d8b6cc1..5e89a25 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index db11c20..93c7d41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/package.json b/package.json index 81f7394..d682c37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/js/utils/api.js b/public/js/utils/api.js new file mode 100644 index 0000000..d974557 --- /dev/null +++ b/public/js/utils/api.js @@ -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; diff --git a/server.js b/server.js deleted file mode 100644 index da54f24..0000000 --- a/server.js +++ /dev/null @@ -1,2779 +0,0 @@ -const express = require('express'); -const { Pool } = require('pg'); -const path = require('path'); -const puppeteer = require('puppeteer'); -const fs = require('fs').promises; -const multer = require('multer'); -const OAuthClient = require('intuit-oauth'); -const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require('./qbo_helper'); - -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'); - - // Restart browser if it crashes - browser.on('disconnected', () => { - console.log('[BROWSER] Browser disconnected, restarting...'); - browser = null; - initBrowser(); - }); - } - return browser; -} - -// Database connection -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, -}); - -// Middleware -app.use(express.json()); -app.use(express.static('public')); - -// 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')); - } - } -}); - -// Helper functions -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}`; -} - -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); -} - -// ===================================================== -// INVOICE CREATE + UPDATE — Auto QBO Export/Sync -// ERSETZE POST /api/invoices und PUT /api/invoices/:id -// ===================================================== - -const QBO_LABOR_ID = '5'; -const QBO_PARTS_ID = '9'; - - -// --- Helper: QBO Invoice Export (create) --- -async function exportInvoiceToQbo(invoiceId, client) { - 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 oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // Nächste DocNumber ermitteln (aus lokaler DB) - 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 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 bei 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 }; -} - - -// --- Helper: QBO Invoice Update (sync) --- -async function syncInvoiceToQbo(invoiceId, client) { - 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 oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // Aktuellen SyncToken holen - 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 => { - // Robust parsing: handle both string ("$1,250.00") and numeric types - 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; // Always compute amount = rate * qty for QBO - const itemRefId = item.qbo_item_id || QBO_PARTS_ID; - const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts"; - - console.log(` 📋 Item: qty=${qty}, rate=${rate}, amount=${amount}, ref=${itemRefId}`); - - 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; - - // Prüfe auf Fault - 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 }; -} - -function formatMoney(val) { - return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} -// ===================================================== -// POST /api/invoices — Create + Auto QBO Export -// ===================================================== -app.post('/api/invoices', 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'); - - // invoice_number kann leer sein — wird von QBO vergeben - // Falls angegeben, validieren - if (invoice_number && !/^\d+$/.test(invoice_number)) { - await client.query('ROLLBACK'); - return res.status(400).json({ error: 'Invoice number must be numeric.' }); - } - - // Temporäre Nummer falls leer - 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 (falls Kunde in QBO) - let qboResult = null; - try { - qboResult = await exportInvoiceToQbo(invoiceId, client); - 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); - // Nicht abbrechen — lokal wurde gespeichert - } - - 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 /api/invoices/:id — Update + Auto QBO Sync -// ===================================================== -app.put('/api/invoices/: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'); - - // Invoice-Nummer validieren (falls angegeben) - 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 lokal — invoice_number nur ändern wenn angegeben - 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] - ); - } - - // Items neu schreiben - 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 { - // Prüfe ob Invoice schon in QBO - 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, client); - } else { - qboResult = await exportInvoiceToQbo(id, client); - } - - 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(); - } -}); - - -// Logo endpoints -app.get('/api/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' }); - } -}); - -app.post('/api/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' }); - } -}); - -// Customer endpoints -app.get('/api/customers', 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 /api/customers -app.post('/api/customers', 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 /api/customers/:id -app.put('/api/customers/: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 = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // SyncToken holen - 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]; - } - } - - // Adresse - 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' }); - } -}); - -app.delete('/api/customers/:id', async (req, res) => { - const { id } = req.params; - - try { - // Kunde laden - 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]; - - // In QBO deaktivieren falls vorhanden - if (customer.qbo_id) { - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // SyncToken holen - 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); - // Trotzdem lokal löschen - } - } - - // Lokal löschen - 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' }); - } -}); - -// Quote endpoints -app.get('/api/quotes', 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' }); - } -}); - -app.get('/api/quotes/:id', async (req, res) => { - const { id } = req.params; - try { - // KORRIGIERT: c.line1...c.line4 statt c.street - 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' }); - } -}); - -app.post('/api/quotes', 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(); - } -}); - -app.put('/api/quotes/: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(); - } -}); - -app.delete('/api/quotes/: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(); - } -}); - -// Invoice endpoints -app.get('/api/invoices', 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' }); - } -}); - -// IMPORTANT: This must come BEFORE /api/invoices/:id to avoid route collision -app.get('/api/invoices/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' }); - } -}); - -app.get('/api/invoices/: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' }); - } -}); - - -app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => { - const { id } = req.params; - - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // KORRIGIERT: c.line1...c.line4 statt c.street - 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(); - } -}); - - - -app.delete('/api/invoices/:id', async (req, res) => { - const { id } = req.params; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Invoice laden um qbo_id zu prüfen - 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]; - - // In QBO löschen falls vorhanden - if (invoice.qbo_id) { - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // Aktuellen SyncToken aus QBO holen (sicherste Methode) - 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) { - // QBO Invoice "voiden" (nicht löschen — QBO empfiehlt Void statt Delete) - // Void setzt Balance auf 0 und markiert als nichtig - 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) { - // QBO-Fehler loggen aber lokales Löschen trotzdem durchführen - console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message); - // Nicht abbrechen — lokal trotzdem löschen - } - } - - // Lokal löschen (payment_invoices hat ON DELETE CASCADE) - 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(); - } -}); - -app.patch('/api/invoices/:id/email-status', async (req, res) => { - const { id } = req.params; - const { status } = req.body; // 'sent' or 'open' - - 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]; - - // QBO updaten falls vorhanden - if (invoice.qbo_id) { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // SyncToken holen - 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}`); - } - } - - // Lokal updaten - 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 }); - } -}); - -// PDF Generation code continues below... - -// PDF Generation using templates and persistent browser -app.get('/api/quotes/:id/pdf', async (req, res) => { - const { id } = req.params; - - console.log(`[PDF] Starting quote PDF generation for ID: ${id}`); - - try { - // KORRIGIERT: Abfrage von line1-4 statt street/pobox - 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'); - - 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 = ``; - } catch (err) {} - - // Items HTML generieren - let itemsHTML = itemsResult.rows.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 ` - - ${item.quantity} - ${item.description} - ${rateFormatted} - ${item.amount} - `; - }).join(''); - - // Totals - itemsHTML += ` - - Subtotal - $${formatMoney(quote.subtotal)} - `; - if (!quote.tax_exempt) { - itemsHTML += ` - - Tax (${quote.tax_rate}%) - $${formatMoney(quote.tax_amount)} - `; - } - itemsHTML += ` - - TOTAL - $${formatMoney(quote.total)} - - - This quote is valid for 14 days. We appreciate your business - `; - - let tbdNote = quote.has_tbd ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; - - // --- ADRESS-LOGIK (NEU) --- - const addressLines = []; - // Wenn line1 existiert UND ungleich dem Namen ist, hinzufügen. Sonst überspringen (da Name eh drüber steht). - if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) { - addressLines.push(quote.line1); - } - if (quote.line2) addressLines.push(quote.line2); - if (quote.line3) addressLines.push(quote.line3); - if (quote.line4) addressLines.push(quote.line4); - - const streetBlock = addressLines.join('
'); - - // Ersetzen - html = html - .replace('{{LOGO_HTML}}', logoHTML) - .replace('{{CUSTOMER_NAME}}', quote.customer_name || '') - .replace('{{CUSTOMER_STREET}}', streetBlock) // Hier kommt der Block rein - .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 browserInstance = await initBrowser(); - const page = await browserInstance.newPage(); - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 }); - const pdf = await page.pdf({ - format: 'Letter', printBackground: true, - margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' } - }); - await page.close(); - - 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 }); - } -}); - -app.get('/api/invoices/:id/pdf', async (req, res) => { - const { id } = req.params; - console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`); - - try { - // KORRIGIERT: Abfrage von line1-4 - 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'); - - 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 = ``; - } catch (err) {} - - let itemsHTML = itemsResult.rows.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 ` - - ${item.quantity} - ${item.description} - ${rateFormatted} - ${item.amount} - `; - }).join(''); - - itemsHTML += ` - - Subtotal: - $${formatMoney(invoice.subtotal)} - `; - if (!invoice.tax_exempt) { - itemsHTML += ` - - Tax (${invoice.tax_rate}%): - $${formatMoney(invoice.tax_amount)} - `; - } - const amountPaid = parseFloat(invoice.amount_paid) || 0; - const balanceDue = parseFloat(invoice.total) - amountPaid; - - itemsHTML += ` - - TOTAL: - $${formatMoney(invoice.total)} - `; - - if (amountPaid > 0) { - itemsHTML += ` - - Downpayment: - -$${formatMoney(amountPaid)} - - - BALANCE DUE: - $${formatMoney(balanceDue)} - `; - } - - itemsHTML += ` - - Thank you for your business! - `; - - const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; - - // --- ADRESS-LOGIK (NEU) --- - const addressLines = []; - if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) { - addressLines.push(invoice.line1); - } - if (invoice.line2) addressLines.push(invoice.line2); - if (invoice.line3) addressLines.push(invoice.line3); - if (invoice.line4) addressLines.push(invoice.line4); - - const streetBlock = addressLines.join('
'); - - 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 browserInstance = await initBrowser(); - const page = await browserInstance.newPage(); - await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 }); - const pdf = await page.pdf({ - format: 'Letter', printBackground: true, - margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' } - }); - await page.close(); - - 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 }); - } -}); - - -// HTML Debug Endpoints -app.get('/api/quotes/:id/html', async (req, res) => { - const { id } = req.params; - - try { - // KORREKTUR: Line 1-4 abfragen - 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'); - - 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 = ``; - } catch (err) {} - - let itemsHTML = itemsResult.rows.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 ` - - ${item.quantity} - ${item.description} - ${rateFormatted} - ${item.amount} - `; - }).join(''); - - itemsHTML += ` - - Subtotal: - $${formatMoney(quote.subtotal)} - `; - if (!quote.tax_exempt) { - itemsHTML += ` - - Tax (${quote.tax_rate}%): - $${formatMoney(quote.tax_amount)} - `; - } - itemsHTML += ` - - This quote is valid for 14 days. We appreciate your business - TOTAL: - $${formatMoney(quote.total)} - - - Thank you for your business! - `; - - let tbdNote = quote.has_tbd ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; - - // --- ADRESS LOGIK --- - const addressLines = []; - if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) { - addressLines.push(quote.line1); - } - if (quote.line2) addressLines.push(quote.line2); - if (quote.line3) addressLines.push(quote.line3); - if (quote.line4) addressLines.push(quote.line4); - - const streetBlock = addressLines.join('
'); - - 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' }); - } -}); - -app.get('/api/invoices/:id/html', async (req, res) => { - const { id } = req.params; - - try { - // KORREKTUR: Line 1-4 abfragen - 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'); - - 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 = ``; - } catch (err) {} - - let itemsHTML = itemsResult.rows.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 ` - - ${item.quantity} - ${item.description} - ${rateFormatted} - ${item.amount} - `; - }).join(''); - - itemsHTML += ` - - Subtotal: - $${formatMoney(invoice.subtotal)} - `; - if (!invoice.tax_exempt) { - itemsHTML += ` - - Tax (${invoice.tax_rate}%): - $${formatMoney(invoice.tax_amount)} - `; - } - const amountPaid = parseFloat(invoice.amount_paid) || 0; - const balanceDue = parseFloat(invoice.total) - amountPaid; - - itemsHTML += ` - - TOTAL: - $${formatMoney(invoice.total)} - `; - - if (amountPaid > 0) { - itemsHTML += ` - - Downpayment: - -$${formatMoney(amountPaid)} - - - BALANCE DUE: - $${formatMoney(balanceDue)} - `; - } - - itemsHTML += ` - - Thank you for your business! - `; - - const authHTML = invoice.auth_code ? `

Authorization: ${invoice.auth_code}

` : ''; - - // --- ADRESS LOGIK --- - const addressLines = []; - if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) { - addressLines.push(invoice.line1); - } - if (invoice.line2) addressLines.push(invoice.line2); - if (invoice.line3) addressLines.push(invoice.line3); - if (invoice.line4) addressLines.push(invoice.line4); - - const streetBlock = addressLines.join('
'); - - 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' }); - } -}); - -// QBO Export Endpoint -app.post('/api/invoices/:id/export', async (req, res) => { - const { id } = req.params; - const client = await pool.connect(); - - // IDs für deine Items (Labor / Parts) - const QBO_LABOR_ID = '5'; - const QBO_PARTS_ID = '9'; - - try { - // 1. Lokale Rechnung laden - 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.` }); - } - - // 2. Items laden - const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]); - const items = itemsRes.rows; - - // 3. QBO Client vorbereiten - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - - // --- SCHRITT 3a: NÄCHSTE NUMMER ERMITTELN (lokal) --- - // Wir nehmen das Maximum aus qbo_doc_number UND invoice_number - 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(); - console.log(`✅ Nächste Nummer (aus lokaler DB): ${nextDocNumber}`); - - // 4. QBO JSON bauen - 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 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 || "" } - }; - - // 5. An QBO senden — mit Retry bei Duplicate - 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; - - // Prüfe auf Duplicate Error (code 6140) - 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; // Erfolg! - } 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}`); - - // 6. DB Update - 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(); - } -}); - -app.get('/api/qbo/overdue', async (req, res) => { - try { - // Datum vor 30 Tagen berechnen - 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}...`); - - // Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist - 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 = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // makeQboApiCall kümmert sich um den Refresh, falls nötig! - 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 }); - } -}); -// Schritt 1: User klickt "Authorize" → Redirect zu Intuit -app.get('/auth/qbo', (req, res) => { - const client = getOAuthClient(); - const authUri = client.authorizeUri({ - scope: [OAuthClient.scopes.Accounting], - state: 'intuit-qbo-auth' - }); - console.log('🔗 Redirecting to QBO Authorization:', authUri); - res.redirect(authUri); -}); - -// Schritt 2: Intuit redirected zurück mit Code → Token holen -app.get('/auth/qbo/callback', async (req, res) => { - const client = getOAuthClient(); - try { - const authResponse = await client.createToken(req.url); - console.log('✅ QBO Authorization erfolgreich!'); - saveTokens(); - - // Redirect zurück zur App (Settings Tab) - res.redirect('/#settings'); - } catch (e) { - console.error('❌ QBO Authorization fehlgeschlagen:', e); - res.status(500).send(` -

QBO Authorization Failed

-

${e.message || e}

- Zurück zur App - `); - } -}); - -// Status-Check Endpoint (für die UI) -app.get('/api/qbo/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 }); - } -}); - -app.post('/api/qbo/import-unpaid', async (req, res) => { - const dbClient = await pool.connect(); - - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // 1. Alle unbezahlten Rechnungen aus QBO holen - // Balance > '0' = noch nicht vollständig bezahlt - // MAXRESULTS 1000 = sicherheitshalber hoch setzen - 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.' - }); - } - - // 2. Lokale Kunden laden (die mit QBO verknüpft sind) - 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)); - - // 3. Bereits importierte QBO-Rechnungen ermitteln (nach qbo_id) - 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)); - - // 4. Import durchführen - 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); - - // Bereits importiert? → Überspringen - if (existingQboIds.has(qboId)) { - skipped++; - continue; - } - - // Kunde lokal vorhanden? - 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; - } - - // Werte aus QBO-Rechnung extrahieren - const docNumber = qboInv.DocNumber || ''; - const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0]; - const syncToken = qboInv.SyncToken || ''; - - // Terms aus QBO mappen (SalesTermRef) - let terms = 'Net 30'; - if (qboInv.SalesTermRef?.name) { - terms = qboInv.SalesTermRef.name; - } - - // Tax: Prüfen ob TaxLine vorhanden - const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0; - const taxExempt = taxAmount === 0; - - // Subtotal berechnen (Total - Tax) - const total = parseFloat(qboInv.TotalAmt) || 0; - const subtotal = total - taxAmount; - const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25; - - // Memo als auth_code (falls vorhanden) - const authCode = qboInv.CustomerMemo?.value || ''; - - // Rechnung einfügen - 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; - - // Line Items importieren - const lines = qboInv.Line || []; - let itemOrder = 0; - - for (const line of lines) { - // Nur SalesItemLineDetail-Zeilen (keine SubTotalLine etc.) - 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 || ''; - - // Item-Typ ermitteln (Labor=5, Parts=9) - const itemRefValue = detail.ItemRef?.value || '9'; - const itemRefName = (detail.ItemRef?.name || '').toLowerCase(); - let qboItemId = '9'; // Default: Parts - 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(); - } -}); - -// Mark invoice as paid -app.patch('/api/invoices/:id/mark-paid', async (req, res) => { - const { id } = req.params; - const { paid_date } = req.body; // Optional: explizites Datum, sonst heute - - 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' }); - } -}); - -// Mark invoice as unpaid -app.patch('/api/invoices/: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' }); - } -}); - -app.patch('/api/invoices/: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' }); - } -}); - -// ===================================================== -// QBO PAYMENT ENDPOINTS v3 — In server.js einfügen -// - Invoice payments (multi, partial, overpay) -// - Downpayment (separate endpoint, called from customer view) -// - Customer credit query -// ===================================================== - - -// --- Bank-Konten aus QBO --- -app.get('/api/qbo/accounts', async (req, res) => { - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - 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 }); - } -}); - - -// --- Payment Methods aus QBO --- -app.get('/api/qbo/payment-methods', async (req, res) => { - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - 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 }); - } -}); - - -// --- Record Payment (against invoices) --- -app.post('/api/qbo/record-payment', async (req, res) => { - const { - invoice_payments, // [{ invoice_id, amount }] - 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 = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - 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(); - } -}); - -// ===================================================== -// QBO INVOICE UPDATE — Sync local changes to QBO -// ===================================================== -// Aktualisiert eine bereits exportierte Invoice in QBO. -// Benötigt qbo_id + qbo_sync_token (Optimistic Locking). -// Sendet alle Items neu (QBO ersetzt die Line-Items komplett). - -app.post('/api/invoices/: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 { - // 1. Lokale Rechnung + Items laden - 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; - - // 2. QBO vorbereiten - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen - 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}`); - - // 4. Line Items bauen - 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 - } - }; - }); - - // 5. QBO Update Payload — sparse update - // Id + SyncToken sind Pflicht. Alles was mitgesendet wird, wird aktualisiert. - 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}`); - - // 6. Neuen SyncToken lokal speichern - 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(); - } -}); - -// --- List local payments --- -app.get('/api/payments', 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' }); - } -}); - -// ===================================================== -// Neue Server Endpoints — In server.js einfügen -// 1. Customer QBO Export -// 2. Labor Rate aus QBO -// ===================================================== - - -// --- 1. Kunde nach QBO exportieren --- -app.post('/api/customers/: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 = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - 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 }); - } -}); - -// --- 2. Labor Rate aus QBO laden --- -// Lädt den UnitPrice des "Labor" Items (ID 5) aus QBO -app.get('/api/qbo/labor-rate', async (req, res) => { - try { - const oauthClient = getOAuthClient(); - const companyId = oauthClient.getToken().realmId; - const baseUrl = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // Item ID 5 = Labor - 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); - // Nicht kritisch — Fallback auf Frontend-Default - res.json({ rate: null }); - } -}); - -// --- 3. Sync Payments from QBO --- -// Prüft alle offenen lokalen Invoices gegen QBO. -// Aktualisiert paid_date und payment_status (Paid/Deposited). -app.post('/api/qbo/sync-payments', async (req, res) => { - const dbClient = await pool.connect(); - try { - // Alle lokalen Invoices die in QBO sind und noch aktualisiert werden könnten - // Auch bereits bezahlte prüfen um payment_status zu korrigieren (Paid↔Deposited) - 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 = process.env.QBO_ENVIRONMENT === 'production' - ? 'https://quickbooks.api.intuit.com' - : 'https://sandbox-quickbooks.api.intuit.com'; - - // QBO Invoices in Batches laden (max 50 IDs pro Query) - 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; - - // Prüfe ob in QBO bezahlt/teilweise bezahlt - if (qboBalance === 0 && qboTotal > 0) { - // Voll bezahlt in QBO - 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 */ } - } - } - } - - // Update wenn sich etwas geändert hat - 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}`); - } - - // Fehlenden Payment-Eintrag NUR erstellen wenn Differenz > $0.01 - 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) { - // Teilweise bezahlt in QBO - 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++; - } - - // Payment nur erstellen wenn echte Differenz > $0.01 - 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)})`); - } - } - } - - // Last sync timestamp speichern - 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(); - } -}); - - -// --- 4. Last sync timestamp --- -app.get('/api/qbo/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 }); - } -}); - - - -// Start server and browser -async function startServer() { - await initBrowser(); - - app.listen(PORT, () => { - console.log(`Quote System running on port ${PORT}`); - }); -} - -startServer(); - -// Graceful shutdown -process.on('SIGTERM', async () => { - if (browser) { - await browser.close(); - } - await pool.end(); - process.exit(0); -}); \ No newline at end of file diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..42cc9b6 --- /dev/null +++ b/src/config/database.js @@ -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 }; diff --git a/src/config/qbo.js b/src/config/qbo.js new file mode 100644 index 0000000..90a38d3 --- /dev/null +++ b/src/config/qbo.js @@ -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 +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..af08537 --- /dev/null +++ b/src/index.js @@ -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; diff --git a/src/routes/customers.js b/src/routes/customers.js new file mode 100644 index 0000000..655f544 --- /dev/null +++ b/src/routes/customers.js @@ -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; diff --git a/src/routes/invoices.js b/src/routes/invoices.js new file mode 100644 index 0000000..91d83d8 --- /dev/null +++ b/src/routes/invoices.js @@ -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 ? `

Authorization: ${invoice.auth_code}

` : ''; + + 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 ? `

Authorization: ${invoice.auth_code}

` : ''; + + 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; diff --git a/src/routes/payments.js b/src/routes/payments.js new file mode 100644 index 0000000..c763f30 --- /dev/null +++ b/src/routes/payments.js @@ -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; diff --git a/src/routes/qbo.js b/src/routes/qbo.js new file mode 100644 index 0000000..808804a --- /dev/null +++ b/src/routes/qbo.js @@ -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(` +

QBO Authorization Failed

+

${e.message || e}

+ Zurück zur App + `); + } +}); + +// 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; diff --git a/src/routes/quotes.js b/src/routes/quotes.js new file mode 100644 index 0000000..ddf09b1 --- /dev/null +++ b/src/routes/quotes.js @@ -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 ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; + + 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 ? '

* Note: This quote contains items marked as "TBD". The final total may vary.

' : ''; + + 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; diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 0000000..1802598 --- /dev/null +++ b/src/routes/settings.js @@ -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; diff --git a/src/services/pdf-service.js b/src/services/pdf-service.js new file mode 100644 index 0000000..acbc258 --- /dev/null +++ b/src/services/pdf-service.js @@ -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 = ``; + } 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 ` + + ${item.quantity} + ${item.description} + ${rateFormatted} + ${item.amount} + `; + }).join(''); + + // Add subtotal + const subtotal = invoice ? invoice.subtotal : 0; + itemsHTML += ` + + Subtotal: + $${formatMoney(subtotal)} + `; + + // Add tax if not exempt + if (invoice && !invoice.tax_exempt) { + itemsHTML += ` + + Tax (${invoice.tax_rate}%): + $${formatMoney(invoice.tax_amount)} + `; + } + + // Add total + const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0; + const total = invoice ? parseFloat(invoice.total) : 0; + const balanceDue = total - amountPaid; + + itemsHTML += ` + + TOTAL: + $${formatMoney(total)} + `; + + // Add downpayment/balance if partial + if (amountPaid > 0) { + itemsHTML += ` + + Downpayment: + -$${formatMoney(amountPaid)} + + + BALANCE DUE: + $${formatMoney(balanceDue)} + `; + } + + // Thank you message + itemsHTML += ` + + Thank you for your business! + `; + + 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 ` + + ${item.quantity} + ${item.description} + ${rateFormatted} + ${item.amount} + `; + }).join(''); + + // Add subtotal + const subtotal = quote ? quote.subtotal : 0; + itemsHTML += ` + + Subtotal + $${formatMoney(subtotal)} + `; + + // Add tax if not exempt + if (quote && !quote.tax_exempt) { + itemsHTML += ` + + Tax (${quote.tax_rate}%) + $${formatMoney(quote.tax_amount)} + `; + } + + // Add total + const total = quote ? quote.total : 0; + itemsHTML += ` + + TOTAL + $${formatMoney(total)} + + + This quote is valid for 14 days. We appreciate your business + `; + + 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('
'); +} + +module.exports = { + setBrowser, + getBrowser, + generatePdfFromHtml, + getLogoHtml, + renderInvoiceItems, + renderQuoteItems, + formatAddressLines +}; diff --git a/src/services/qbo-service.js b/src/services/qbo-service.js new file mode 100644 index 0000000..1921a09 --- /dev/null +++ b/src/services/qbo-service.js @@ -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 +}; diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..09bb0ad --- /dev/null +++ b/src/utils/helpers.js @@ -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('
'); +} + +module.exports = { + formatDate, + formatMoney, + parseNumericValue, + formatAddress +}; diff --git a/src/utils/numberGenerators.js b/src/utils/numberGenerators.js new file mode 100644 index 0000000..25f33b8 --- /dev/null +++ b/src/utils/numberGenerators.js @@ -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 +};