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 `
-
* 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('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('* 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('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('${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(` +${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 = `