This commit is contained in:
Andreas Knuth 2026-02-19 14:45:19 -06:00
parent 48fa86916b
commit b24a360fba
5 changed files with 438 additions and 20 deletions

195
import_qbo_payment.js Normal file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env node
// import_qbo_payment.js — Importiert ein spezifisches QBO Payment in die lokale DB
//
// Verwendung:
// node import_qbo_payment.js <QBO_PAYMENT_ID>
//
// Beispiel:
// node import_qbo_payment.js 20616
//
// Was passiert:
// 1. Payment aus QBO laden (inkl. LinkedTxn → verknüpfte Invoices)
// 2. Lokale Invoices anhand qbo_id finden
// 3. Payment in lokale payments-Tabelle schreiben
// 4. Invoices als bezahlt markieren (paid_date)
require('dotenv').config();
const { Pool } = require('pg');
const { makeQboApiCall, getOAuthClient } = require('./qbo_helper');
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,
});
async function importPayment(qboPaymentId) {
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';
console.log(`\n🔍 Lade QBO Payment ${qboPaymentId}...`);
// 1. Payment aus QBO lesen
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment/${qboPaymentId}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const payment = data.Payment;
if (!payment) {
console.error('❌ Payment nicht gefunden in QBO.');
process.exit(1);
}
console.log(`✅ Payment gefunden:`);
console.log(` Datum: ${payment.TxnDate}`);
console.log(` Betrag: $${payment.TotalAmt}`);
console.log(` Referenz: ${payment.PaymentRefNum || '(keine)'}`);
console.log(` Kunde: ${payment.CustomerRef?.name || payment.CustomerRef?.value}`);
// 2. Verknüpfte Invoices aus dem Payment extrahieren
const linkedInvoices = [];
if (payment.Line) {
for (const line of payment.Line) {
if (line.LinkedTxn) {
for (const txn of line.LinkedTxn) {
if (txn.TxnType === 'Invoice') {
linkedInvoices.push({
qbo_invoice_id: txn.TxnId,
amount: line.Amount
});
}
}
}
}
}
console.log(` Verknüpfte Invoices: ${linkedInvoices.length}`);
linkedInvoices.forEach(li => {
console.log(` - QBO Invoice ID: ${li.qbo_invoice_id}, Amount: $${li.amount}`);
});
// 3. Lokale Invoices finden
const dbClient = await pool.connect();
try {
await dbClient.query('BEGIN');
// Kunden-ID lokal finden
const customerResult = await dbClient.query(
'SELECT id FROM customers WHERE qbo_id = $1',
[payment.CustomerRef?.value]
);
const customerId = customerResult.rows[0]?.id || null;
// PaymentMethod-Name aus QBO holen (optional)
let paymentMethodName = 'Unknown';
if (payment.PaymentMethodRef?.value) {
try {
const pmRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/paymentmethod/${payment.PaymentMethodRef.value}`,
method: 'GET'
});
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
paymentMethodName = pmData.PaymentMethod?.Name || 'Unknown';
} catch (e) {
console.log(' ⚠️ PaymentMethod konnte nicht geladen werden.');
}
}
// DepositTo-Account-Name aus QBO (optional)
let depositToName = '';
if (payment.DepositToAccountRef?.value) {
try {
const accRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/account/${payment.DepositToAccountRef.value}`,
method: 'GET'
});
const accData = accRes.getJson ? accRes.getJson() : accRes.json;
depositToName = accData.Account?.Name || '';
} catch (e) {
console.log(' ⚠️ Account konnte nicht geladen werden.');
}
}
// Prüfen ob Payment schon importiert
const existing = await dbClient.query(
'SELECT id FROM payments WHERE qbo_payment_id = $1',
[String(qboPaymentId)]
);
if (existing.rows.length > 0) {
console.log(`\n⚠️ Payment ${qboPaymentId} wurde bereits importiert (lokale ID: ${existing.rows[0].id}).`);
console.log(' Übersprungen.');
await dbClient.query('ROLLBACK');
return;
}
// Payment lokal anlegen
const paymentResult = 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 *`,
[payment.TxnDate, payment.PaymentRefNum || null, paymentMethodName, depositToName, payment.TotalAmt, customerId, String(qboPaymentId)]
);
const localPaymentId = paymentResult.rows[0].id;
console.log(`\n💾 Lokales Payment erstellt: ID ${localPaymentId}`);
// Verknüpfte Invoices lokal finden und markieren
let matchedCount = 0;
for (const li of linkedInvoices) {
const invResult = await dbClient.query(
'SELECT id, invoice_number FROM invoices WHERE qbo_id = $1',
[li.qbo_invoice_id]
);
if (invResult.rows.length > 0) {
const localInv = invResult.rows[0];
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[localPaymentId, localInv.id, li.amount]
);
await dbClient.query(
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND paid_date IS NULL',
[payment.TxnDate, localInv.id]
);
console.log(` ✅ Invoice #${localInv.invoice_number || localInv.id} (QBO: ${li.qbo_invoice_id}) → bezahlt`);
matchedCount++;
} else {
console.log(` ⚠️ QBO Invoice ID ${li.qbo_invoice_id} nicht in lokaler DB gefunden.`);
}
}
await dbClient.query('COMMIT');
console.log(`\n✅ Import abgeschlossen: ${matchedCount}/${linkedInvoices.length} Invoices verknüpft.`);
console.log(` Payment: Lokal ID ${localPaymentId}, QBO ID ${qboPaymentId}`);
console.log(` Methode: ${paymentMethodName}, Konto: ${depositToName}`);
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('❌ Fehler:', error);
} finally {
dbClient.release();
await pool.end();
}
}
// --- Main ---
const qboPaymentId = process.argv[2];
if (!qboPaymentId) {
console.log('Verwendung: node import_qbo_payment.js <QBO_PAYMENT_ID>');
console.log('Beispiel: node import_qbo_payment.js 20616');
process.exit(1);
}
importPayment(qboPaymentId).catch(err => {
console.error('Fatal:', err);
process.exit(1);
});

View File

@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => {
//loadInvoices(); //loadInvoices();
setDefaultDate(); setDefaultDate();
checkCurrentLogo(); checkCurrentLogo();
loadLaborRate();
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) *** // *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
const savedTab = localStorage.getItem('activeTab') || 'quotes'; const savedTab = localStorage.getItem('activeTab') || 'quotes';
showTab(savedTab); showTab(savedTab);
@ -219,30 +219,28 @@ async function loadCustomers() {
function renderCustomers() { function renderCustomers() {
const tbody = document.getElementById('customers-list'); const tbody = document.getElementById('customers-list');
tbody.innerHTML = customers.map(customer => { tbody.innerHTML = customers.map(customer => {
// Logik: Line 1-4 zusammenbauen
// filter(Boolean) entfernt null/undefined/leere Strings
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean); const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
// City, State, Zip anhängen
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' '); const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
// Alles zusammenfügen
let fullAddress = lines.join(', '); let fullAddress = lines.join(', ');
if (cityStateZip) { if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
} // QBO Status
const qboStatus = customer.qbo_id
? `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="QBO ID: ${customer.qbo_id}">QBO ✓</span>`
: `<button onclick="exportCustomerToQbo(${customer.id})" class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 hover:bg-orange-200 cursor-pointer" title="Kunde nach QBO exportieren">QBO Export</button>`;
return ` return `
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${customer.name} ${qboStatus}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td> <td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button> <button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button> <button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
</td> </td>
</tr> </tr>`;
`;
}).join(''); }).join('');
} }
@ -351,7 +349,34 @@ async function deleteCustomer(id) {
alert('Error deleting customer'); alert('Error deleting customer');
} }
} }
async function exportCustomerToQbo(customerId) {
const customer = customers.find(c => c.id === customerId);
if (!customer) return;
if (!confirm(`Kunde "${customer.name}" nach QuickBooks Online exportieren?`)) return;
showSpinner('Exportiere Kunde nach QBO...');
try {
const response = await fetch(`/api/customers/${customerId}/export-qbo`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
alert(`✅ Kunde "${result.name}" erfolgreich in QBO erstellt (ID: ${result.qbo_id}).`);
// Kunden-Liste neu laden
const custResponse = await fetch('/api/customers');
customers = await custResponse.json();
renderCustomers();
} else {
alert(`❌ Fehler: ${result.error}`);
}
} catch (error) {
console.error('Error exporting customer:', error);
alert('Netzwerkfehler beim Export.');
} finally {
hideSpinner();
}
}
// Quote Management // Quote Management
async function loadQuotes() { async function loadQuotes() {
try { try {
@ -916,7 +941,7 @@ function addInvoiceItem(item = null) {
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label> <label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateInvoiceItemPreview(this.closest('[id^=invoice-item-]'), ${itemId})"> <select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="handleTypeChange(this, ${itemId})">
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option> <option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option> <option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
</select> </select>
@ -999,7 +1024,27 @@ function updateInvoiceItemPreview(itemDiv, itemId) {
descPreview.textContent = preview || 'New item'; descPreview.textContent = preview || 'New item';
} }
} }
function handleTypeChange(selectEl, itemId) {
const itemDiv = selectEl.closest(`[id^=invoice-item-]`);
// Wenn Labor gewählt und Rate leer → Labor Rate eintragen
if (selectEl.value === '5' && qboLaborRate) {
const rateInput = itemDiv.querySelector('[data-field="rate"]');
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
rateInput.value = qboLaborRate;
// Amount neu berechnen
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
const amountInput = itemDiv.querySelector('[data-field="amount"]');
if (qtyInput.value) {
const qty = parseFloat(qtyInput.value) || 0;
amountInput.value = (qty * qboLaborRate).toFixed(2);
}
}
}
updateInvoiceItemPreview(itemDiv, itemId);
updateInvoiceTotals();
}
function removeInvoiceItem(itemId) { function removeInvoiceItem(itemId) {
document.getElementById(`invoice-item-${itemId}`).remove(); document.getElementById(`invoice-item-${itemId}`).remove();
updateInvoiceTotals(); updateInvoiceTotals();
@ -1261,4 +1306,54 @@ async function importFromQBO() {
btn.disabled = false; btn.disabled = false;
} }
} }
// =====================================================
// 3. Labor Rate laden und in addInvoiceItem verwenden
// NEUE globale Variable + Lade-Funktion
// =====================================================
let qboLaborRate = null; // Wird beim Start geladen
async function loadLaborRate() {
try {
const response = await fetch('/api/qbo/labor-rate');
const data = await response.json();
if (data.rate) {
qboLaborRate = data.rate;
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
}
} catch (e) {
console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.');
}
}
// =====================================================
// 5. Spinner Funktionen — NEUE Funktionen hinzufügen
// Wird bei QBO-Operationen angezeigt
// =====================================================
function showSpinner(message = 'Bitte warten...') {
let overlay = document.getElementById('qbo-spinner');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'qbo-spinner';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
document.body.appendChild(overlay);
}
overlay.innerHTML = `
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
</div>`;
overlay.style.display = 'flex';
}
function hideSpinner() {
const overlay = document.getElementById('qbo-spinner');
if (overlay) overlay.style.display = 'none';
}
window.openInvoiceModal = openInvoiceModal; window.openInvoiceModal = openInvoiceModal;

View File

@ -167,9 +167,16 @@ function renderInvoiceRow(invoice) {
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) --- // --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`; const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
const qboBtn = hasQbo // QBO Button — nur aktiv wenn Kunde eine qbo_id hat
? `<span class="text-gray-400 text-xs cursor-pointer" title="In QBO (ID: ${invoice.qbo_id}) — Click to reset" onclick="window.invoiceView.resetQbo(${invoice.id})">✓ QBO</span>` const customerHasQbo = !!invoice.customer_qbo_id;
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`; let qboBtn;
if (hasQbo) {
qboBtn = `<span class="text-gray-400 text-xs cursor-pointer" title="In QBO (ID: ${invoice.qbo_id}) — Click to reset" onclick="window.invoiceView.resetQbo(${invoice.id})">✓ QBO</span>`;
} else if (!customerHasQbo) {
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Kunde muss erst nach QBO exportiert werden">QBO ⚠</span>`;
} else {
qboBtn = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
}
const pdfBtn = draft const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>` ? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF erst nach QBO Export">PDF</span>`
@ -332,12 +339,14 @@ export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank')
export async function exportToQBO(id) { export async function exportToQBO(id) {
if (!confirm('Rechnung an QuickBooks Online senden?')) return; if (!confirm('Rechnung an QuickBooks Online senden?')) return;
if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...');
try { try {
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' }); const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const d = await r.json(); const d = await r.json();
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); } if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
else alert(`${d.error}`); else alert(`${d.error}`);
} catch (e) { alert('Netzwerkfehler.'); } } catch (e) { alert('Netzwerkfehler.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
} }
export async function resetQbo(id) { export async function resetQbo(id) {

View File

@ -233,6 +233,7 @@ async function submitPayment() {
const submitBtn = document.getElementById('payment-submit-btn'); const submitBtn = document.getElementById('payment-submit-btn');
submitBtn.innerHTML = '⏳ Wird gesendet...'; submitBtn.innerHTML = '⏳ Wird gesendet...';
submitBtn.disabled = true; submitBtn.disabled = true;
if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...');
try { try {
const response = await fetch('/api/qbo/record-payment', { const response = await fetch('/api/qbo/record-payment', {
@ -264,6 +265,7 @@ async function submitPayment() {
} finally { } finally {
submitBtn.innerHTML = '💰 Record Payment in QBO'; submitBtn.innerHTML = '💰 Record Payment in QBO';
submitBtn.disabled = false; submitBtn.disabled = false;
if (typeof hideSpinner === 'function') hideSpinner();
} }
} }

119
server.js
View File

@ -403,7 +403,7 @@ app.delete('/api/quotes/:id', async (req, res) => {
app.get('/api/invoices', async (req, res) => { app.get('/api/invoices', async (req, res) => {
try { try {
const result = await pool.query(` const result = await pool.query(`
SELECT i.*, c.name as customer_name SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id
FROM invoices i FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN customers c ON i.customer_id = c.id
ORDER BY i.created_at DESC ORDER BY i.created_at DESC
@ -1831,8 +1831,125 @@ app.get('/api/payments', async (req, res) => {
} }
}); });
// =====================================================
// 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 // Start server and browser
async function startServer() { async function startServer() {