2083 lines
81 KiB
JavaScript
2083 lines
81 KiB
JavaScript
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);
|
|
}
|
|
|
|
// 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) => {
|
|
// line1 bis line4 statt street/pobox/suite
|
|
const {
|
|
name, line1, line2, line3, line4, city, state, zip_code,
|
|
account_number, email, phone, phone2, taxable
|
|
} = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`INSERT INTO customers
|
|
(name, line1, line2, line3, line4, city, state,
|
|
zip_code, account_number, email, phone, phone2, taxable)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
RETURNING *`,
|
|
[name, 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]
|
|
);
|
|
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, line1, line2, line3, line4, city, state, zip_code,
|
|
account_number, email, phone, phone2, taxable
|
|
} = req.body;
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`UPDATE customers
|
|
SET name = $1, line1 = $2, line2 = $3, line3 = $4, line4 = $5,
|
|
city = $6, state = $7, zip_code = $8, account_number = $9, email = $10,
|
|
phone = $11, phone2 = $12, taxable = $13, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $14
|
|
RETURNING *`,
|
|
[name, 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, id]
|
|
);
|
|
res.json(result.rows[0]);
|
|
} 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 {
|
|
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
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
ORDER BY i.created_at DESC
|
|
`);
|
|
res.json(result.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 {
|
|
// KORRIGIERT: c.line1, c.line2, c.line3, c.line4 statt c.street
|
|
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
|
|
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 itemsResult = await pool.query(
|
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
res.json({
|
|
invoice: invoiceResult.rows[0],
|
|
items: itemsResult.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching invoice:', error);
|
|
res.status(500).json({ error: 'Error fetching invoice' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/invoices', async (req, res) => {
|
|
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body;
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
|
|
// Wenn angegeben, muss sie numerisch sein und darf nicht existieren
|
|
if (invoice_number && invoice_number.trim() !== '') {
|
|
if (!/^\d+$/.test(invoice_number)) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
|
}
|
|
|
|
const existingInvoice = await client.query(
|
|
'SELECT id FROM invoices WHERE invoice_number = $1',
|
|
[invoice_number]
|
|
);
|
|
|
|
if (existingInvoice.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;
|
|
|
|
// invoice_number kann NULL sein
|
|
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
|
|
const sendDate = scheduled_send_date || null;
|
|
|
|
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, scheduled_send_date)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
|
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate]
|
|
);
|
|
|
|
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');
|
|
res.json(invoiceResult.rows[0]);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
console.error('Error creating invoice:', error);
|
|
res.status(500).json({ error: 'Error creating invoice' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
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.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 } = req.body;
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
|
|
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
|
|
|
|
if (invNum && !/^\d+$/.test(invNum)) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
|
}
|
|
|
|
if (invNum) {
|
|
const existingInvoice = await client.query(
|
|
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
|
|
[invNum, id]
|
|
);
|
|
if (existingInvoice.rows.length > 0) {
|
|
await client.query('ROLLBACK');
|
|
return res.status(400).json({ error: `Invoice number ${invNum} 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 sendDate = scheduled_send_date || null;
|
|
|
|
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, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $12`,
|
|
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id]
|
|
);
|
|
|
|
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');
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
console.error('Error updating invoice:', error);
|
|
res.status(500).json({ error: 'Error updating 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');
|
|
await client.query('DELETE FROM invoice_items 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();
|
|
}
|
|
});
|
|
|
|
// 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 = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
|
} 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 `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// Totals
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Subtotal:</td>
|
|
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
|
</tr>`;
|
|
if (!quote.tax_exempt) {
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%):</td>
|
|
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
|
</tr>`;
|
|
}
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">TOTAL:</td>
|
|
<td class="total-amount">$${parseFloat(quote.total).toFixed(2)}</td>
|
|
</tr>
|
|
<tr class="footer-row">
|
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
|
</tr>`;
|
|
|
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
|
|
|
// --- 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('<br>');
|
|
|
|
// Ersetzen
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock) // Hier kommt der Block rein
|
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
|
.replace('{{ITEMS}}', itemsHTML)
|
|
.replace('{{TBD_NOTE}}', tbdNote);
|
|
|
|
const browserInstance = await initBrowser();
|
|
const page = await browserInstance.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdf = await page.pdf({
|
|
format: 'Letter', printBackground: true,
|
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
|
});
|
|
await page.close();
|
|
|
|
res.set({
|
|
'Content-Type': 'application/pdf',
|
|
'Content-Length': pdf.length,
|
|
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
|
|
});
|
|
res.end(pdf, 'binary');
|
|
console.log('[PDF] Quote PDF sent successfully');
|
|
|
|
} catch (error) {
|
|
console.error('[PDF] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/invoices/:id/pdf', async (req, res) => {
|
|
const { id } = req.params;
|
|
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
|
|
|
|
try {
|
|
// KORRIGIERT: Abfrage von line1-4
|
|
const invoiceResult = await pool.query(`
|
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
WHERE i.id = $1
|
|
`, [id]);
|
|
|
|
if (invoiceResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Invoice not found' });
|
|
}
|
|
|
|
const invoice = invoiceResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
const logoData = await fs.readFile(logoPath);
|
|
const logoBase64 = logoData.toString('base64');
|
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
|
} catch (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Subtotal:</td>
|
|
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
|
</tr>`;
|
|
if (!invoice.tax_exempt) {
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
|
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
|
</tr>`;
|
|
}
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">TOTAL:</td>
|
|
<td class="total-amount">$${parseFloat(invoice.total).toFixed(2)}</td>
|
|
</tr>
|
|
<tr class="footer-row">
|
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
|
</tr>`;
|
|
|
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
|
|
|
// --- 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('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
|
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
|
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
|
.replace('{{TERMS}}', invoice.terms)
|
|
.replace('{{AUTHORIZATION}}', authHTML)
|
|
.replace('{{ITEMS}}', itemsHTML);
|
|
|
|
const browserInstance = await initBrowser();
|
|
const page = await browserInstance.newPage();
|
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
const pdf = await page.pdf({
|
|
format: 'Letter', printBackground: true,
|
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
|
});
|
|
await page.close();
|
|
|
|
res.set({
|
|
'Content-Type': 'application/pdf',
|
|
'Content-Length': pdf.length,
|
|
'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"`
|
|
});
|
|
res.end(pdf, 'binary');
|
|
console.log('[INVOICE-PDF] Invoice PDF sent successfully');
|
|
|
|
} catch (error) {
|
|
console.error('[INVOICE-PDF] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// HTML Debug Endpoints
|
|
app.get('/api/quotes/:id/html', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
// KORREKTUR: Line 1-4 abfragen
|
|
const quoteResult = await pool.query(`
|
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
|
FROM quotes q
|
|
LEFT JOIN customers c ON q.customer_id = c.id
|
|
WHERE q.id = $1
|
|
`, [id]);
|
|
|
|
if (quoteResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Quote not found' });
|
|
}
|
|
|
|
const quote = quoteResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
const logoData = await fs.readFile(logoPath);
|
|
const logoBase64 = logoData.toString('base64');
|
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
|
} catch (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Subtotal:</td>
|
|
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
|
</tr>`;
|
|
if (!quote.tax_exempt) {
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%):</td>
|
|
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
|
</tr>`;
|
|
}
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">TOTAL:</td>
|
|
<td class="total-amount">$${parseFloat(quote.total).toFixed(2)}</td>
|
|
</tr>
|
|
<tr class="footer-row">
|
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
|
</tr>`;
|
|
|
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
|
|
|
// --- 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('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
|
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
|
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
|
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
|
.replace('{{ITEMS}}', itemsHTML)
|
|
.replace('{{TBD_NOTE}}', tbdNote);
|
|
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
|
|
} catch (error) {
|
|
console.error('[HTML] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating HTML' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/invoices/:id/html', async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
// KORREKTUR: Line 1-4 abfragen
|
|
const invoiceResult = await pool.query(`
|
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
WHERE i.id = $1
|
|
`, [id]);
|
|
|
|
if (invoiceResult.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Invoice not found' });
|
|
}
|
|
|
|
const invoice = invoiceResult.rows[0];
|
|
const itemsResult = await pool.query(
|
|
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
|
[id]
|
|
);
|
|
|
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
|
let html = await fs.readFile(templatePath, 'utf-8');
|
|
|
|
let logoHTML = '';
|
|
try {
|
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
|
const logoData = await fs.readFile(logoPath);
|
|
const logoBase64 = logoData.toString('base64');
|
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
|
} catch (err) {}
|
|
|
|
let itemsHTML = itemsResult.rows.map(item => {
|
|
let rateFormatted = item.rate;
|
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
|
}
|
|
return `
|
|
<tr>
|
|
<td class="qty">${item.quantity}</td>
|
|
<td class="description">${item.description}</td>
|
|
<td class="rate">${rateFormatted}</td>
|
|
<td class="amount">${item.amount}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Subtotal:</td>
|
|
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
|
</tr>`;
|
|
if (!invoice.tax_exempt) {
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
|
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
|
</tr>`;
|
|
}
|
|
itemsHTML += `
|
|
<tr class="footer-row">
|
|
<td colspan="3" class="total-label">TOTAL:</td>
|
|
<td class="total-amount">$${parseFloat(invoice.total).toFixed(2)}</td>
|
|
</tr>
|
|
<tr class="footer-row">
|
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
|
</tr>`;
|
|
|
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
|
|
|
// --- 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('<br>');
|
|
|
|
html = html
|
|
.replace('{{LOGO_HTML}}', logoHTML)
|
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
|
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
|
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
|
.replace('{{TERMS}}', invoice.terms)
|
|
.replace('{{AUTHORIZATION}}', authHTML)
|
|
.replace('{{ITEMS}}', itemsHTML);
|
|
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
|
|
} catch (error) {
|
|
console.error('[HTML] ERROR:', error);
|
|
res.status(500).json({ error: 'Error generating HTML' });
|
|
}
|
|
});
|
|
|
|
// QBO Export Endpoint
|
|
app.post('/api/invoices/:id/export', async (req, res) => {
|
|
const { id } = req.params;
|
|
const client = await pool.connect();
|
|
|
|
// IDs für deine Items (Labor / Parts)
|
|
const QBO_LABOR_ID = '5';
|
|
const QBO_PARTS_ID = '9';
|
|
|
|
try {
|
|
// 1. Lokale Rechnung laden
|
|
const invoiceRes = await client.query(`
|
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
|
FROM invoices i
|
|
LEFT JOIN customers c ON i.customer_id = c.id
|
|
WHERE i.id = $1
|
|
`, [id]);
|
|
|
|
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
|
const invoice = invoiceRes.rows[0];
|
|
|
|
if (!invoice.customer_qbo_id) {
|
|
return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` });
|
|
}
|
|
|
|
// 2. Items laden
|
|
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
|
|
const items = itemsRes.rows;
|
|
|
|
// 3. QBO Client vorbereiten
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
|
? 'https://quickbooks.api.intuit.com'
|
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
|
|
|
|
|
// -------------------------------------------
|
|
// --- SCHRITT 3a: NÄCHSTE NUMMER ERMITTELN ---
|
|
console.log("🔍 Frage QBO nach der höchsten Rechnungsnummer...");
|
|
|
|
// Alle Rechnungen laden (nur DocNumber Feld), nach DocNumber absteigend
|
|
// QBO unterstützt leider kein MAX() oder CAST, daher holen wir die letzten 100
|
|
// und ermitteln die höchste rein numerische Nummer clientseitig.
|
|
const numQuery = "SELECT DocNumber FROM Invoice ORDERBY DocNumber DESC MAXRESULTS 100";
|
|
const lastNumResponse = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(numQuery)}`,
|
|
method: 'GET'
|
|
});
|
|
|
|
const lastNumData = lastNumResponse.getJson ? lastNumResponse.getJson() : lastNumResponse.json;
|
|
let nextDocNumber = null;
|
|
|
|
if (lastNumData.QueryResponse?.Invoice?.length > 0) {
|
|
// Nur rein numerische DocNumbers betrachten (keine "110444-A" etc.)
|
|
const numericDocs = lastNumData.QueryResponse.Invoice
|
|
.map(inv => inv.DocNumber)
|
|
.filter(dn => /^\d+$/.test(dn))
|
|
.map(dn => parseInt(dn, 10))
|
|
.sort((a, b) => b - a); // Absteigend
|
|
|
|
if (numericDocs.length > 0) {
|
|
const highest = numericDocs[0];
|
|
nextDocNumber = (highest + 1).toString();
|
|
console.log(`✅ Höchste numerische Nummer: ${highest}. Neue Nummer: ${nextDocNumber}`);
|
|
}
|
|
}
|
|
|
|
// Fallback
|
|
if (!nextDocNumber) {
|
|
console.log("⚠️ Konnte keine Nummer aus QBO ermitteln. Verwende lokale Nummer.");
|
|
nextDocNumber = invoice.invoice_number;
|
|
}
|
|
|
|
// 4. QBO JSON bauen
|
|
const lineItems = items.map(item => {
|
|
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
|
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
|
|
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
|
|
|
return {
|
|
"DetailType": "SalesItemLineDetail",
|
|
"Amount": amount,
|
|
"Description": item.description,
|
|
"SalesItemLineDetail": {
|
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
|
"UnitPrice": rate,
|
|
"Qty": parseFloat(item.quantity) || 1
|
|
}
|
|
};
|
|
});
|
|
|
|
const qboInvoicePayload = {
|
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
|
|
|
// HIER SETZEN WIR DIE ERMITTELTE NUMMER EIN
|
|
"DocNumber": nextDocNumber,
|
|
|
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
|
"Line": lineItems,
|
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
|
|
|
|
// Status auf "Verschickt" setzen
|
|
"EmailStatus": "EmailSent",
|
|
"BillEmail": { "Address": invoice.email || "" }
|
|
};
|
|
|
|
console.log(`📤 Sende Rechnung an QBO (DocNumber: ${nextDocNumber})...`);
|
|
|
|
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;
|
|
// Check auf Unterobjekt "Invoice"
|
|
const qboInvoice = responseData.Invoice || responseData;
|
|
|
|
console.log("🔍 FULL QBO RESPONSE (ID):", qboInvoice.Id);
|
|
|
|
if (!qboInvoice.Id) {
|
|
console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2));
|
|
throw new Error("QBO hat keine ID zurückgegeben.");
|
|
}
|
|
|
|
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`);
|
|
|
|
// 6. DB Update: Wir speichern AUCH die QBO-Nummer, damit wir wissen, wie sie drüben heißt
|
|
await client.query(
|
|
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $3 WHERE id = $4`,
|
|
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, id]
|
|
);
|
|
|
|
// Wir geben die neue Nummer zurück, damit das Frontend Bescheid weiß
|
|
res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber });
|
|
|
|
} catch (error) {
|
|
console.error("QBO Export Error:", error);
|
|
let errorDetails = error.message;
|
|
if (error.response?.data?.Fault?.Error?.[0]) {
|
|
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
|
}
|
|
res.status(500).json({ error: "QBO Export failed: " + errorDetails });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.get('/api/qbo/overdue', async (req, res) => {
|
|
try {
|
|
// Datum vor 30 Tagen berechnen
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - 30);
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
|
|
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
|
|
|
// Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist
|
|
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
|
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
|
? 'https://quickbooks.api.intuit.com'
|
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
|
|
|
// makeQboApiCall kümmert sich um den Refresh, falls nötig!
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
method: 'GET'
|
|
});
|
|
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
const invoices = data.QueryResponse?.Invoice || [];
|
|
|
|
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
|
res.json(invoices);
|
|
|
|
} catch (error) {
|
|
console.error("QBO Report Error:", error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
// Schritt 1: User klickt "Authorize" → Redirect zu Intuit
|
|
app.get('/auth/qbo', (req, res) => {
|
|
const client = getOAuthClient();
|
|
const authUri = client.authorizeUri({
|
|
scope: [OAuthClient.scopes.Accounting],
|
|
state: 'intuit-qbo-auth'
|
|
});
|
|
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
|
res.redirect(authUri);
|
|
});
|
|
|
|
// Schritt 2: Intuit redirected zurück mit Code → Token holen
|
|
app.get('/auth/qbo/callback', async (req, res) => {
|
|
const client = getOAuthClient();
|
|
try {
|
|
const authResponse = await client.createToken(req.url);
|
|
console.log('✅ QBO Authorization erfolgreich!');
|
|
saveTokens();
|
|
|
|
// Redirect zurück zur App (Settings Tab)
|
|
res.redirect('/#settings');
|
|
} catch (e) {
|
|
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
|
res.status(500).send(`
|
|
<h2>QBO Authorization Failed</h2>
|
|
<p>${e.message || e}</p>
|
|
<a href="/">Zurück zur App</a>
|
|
`);
|
|
}
|
|
});
|
|
|
|
// 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: `Kunde "${customer.name}" ist bereits in QBO (ID: ${customer.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';
|
|
|
|
// QBO Customer Objekt
|
|
const qboCustomer = {
|
|
DisplayName: customer.name,
|
|
CompanyName: customer.name,
|
|
BillAddr: {},
|
|
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
|
|
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
|
|
// Taxable setzt man über TaxExemptionReasonId oder SalesTermRef
|
|
Taxable: customer.taxable !== false
|
|
};
|
|
|
|
// Adresse aufbauen
|
|
const addr = qboCustomer.BillAddr;
|
|
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;
|
|
|
|
// Kein leeres BillAddr senden
|
|
if (Object.keys(addr).length === 0) delete qboCustomer.BillAddr;
|
|
|
|
console.log(`📤 Exportiere Kunde "${customer.name}" nach QBO...`);
|
|
|
|
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;
|
|
|
|
if (data.Customer) {
|
|
const qboId = data.Customer.Id;
|
|
|
|
// qbo_id lokal speichern
|
|
await pool.query(
|
|
'UPDATE customers SET qbo_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
[qboId, id]
|
|
);
|
|
|
|
console.log(`✅ Kunde "${customer.name}" in QBO erstellt: ID ${qboId}`);
|
|
res.json({ success: true, qbo_id: qboId, name: customer.name });
|
|
} else {
|
|
console.error('❌ QBO Customer Fehler:', JSON.stringify(data));
|
|
|
|
// Spezieller Fehler: Name existiert schon in QBO
|
|
const errMsg = data.Fault?.Error?.[0]?.Message || JSON.stringify(data);
|
|
const errDetail = data.Fault?.Error?.[0]?.Detail || '';
|
|
|
|
res.status(500).json({ error: `QBO Fehler: ${errMsg}. ${errDetail}` });
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ Customer Export Error:', error);
|
|
res.status(500).json({ error: 'Export fehlgeschlagen: ' + 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 });
|
|
}
|
|
});
|
|
|
|
|
|
// 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);
|
|
}); |