update
This commit is contained in:
parent
48fa86916b
commit
b24a360fba
|
|
@ -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);
|
||||
});
|
||||
123
public/app.js
123
public/app.js
|
|
@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
//loadInvoices();
|
||||
setDefaultDate();
|
||||
checkCurrentLogo();
|
||||
|
||||
loadLaborRate();
|
||||
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
|
||||
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||
showTab(savedTab);
|
||||
|
|
@ -219,30 +219,28 @@ async function loadCustomers() {
|
|||
function renderCustomers() {
|
||||
const tbody = document.getElementById('customers-list');
|
||||
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);
|
||||
|
||||
// City, State, Zip anhängen
|
||||
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||
|
||||
// Alles zusammenfügen
|
||||
let fullAddress = lines.join(', ');
|
||||
if (cityStateZip) {
|
||||
fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||
}
|
||||
if (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 `
|
||||
<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 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">
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
|
@ -351,7 +349,34 @@ async function deleteCustomer(id) {
|
|||
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
|
||||
async function loadQuotes() {
|
||||
try {
|
||||
|
|
@ -916,7 +941,7 @@ function addInvoiceItem(item = null) {
|
|||
|
||||
<div class="col-span-2">
|
||||
<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="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||
</select>
|
||||
|
|
@ -999,7 +1024,27 @@ function updateInvoiceItemPreview(itemDiv, itemId) {
|
|||
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) {
|
||||
document.getElementById(`invoice-item-${itemId}`).remove();
|
||||
updateInvoiceTotals();
|
||||
|
|
@ -1261,4 +1306,54 @@ async function importFromQBO() {
|
|||
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;
|
||||
|
|
@ -167,9 +167,16 @@ function renderInvoiceRow(invoice) {
|
|||
// --- 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 qboBtn = hasQbo
|
||||
? `<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>`
|
||||
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
||||
// QBO Button — nur aktiv wenn Kunde eine qbo_id hat
|
||||
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||
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
|
||||
? `<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) {
|
||||
if (!confirm('Rechnung an QuickBooks Online senden?')) return;
|
||||
if (typeof showSpinner === 'function') showSpinner('Exportiere Rechnung nach QBO...');
|
||||
try {
|
||||
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
|
||||
else alert(`❌ ${d.error}`);
|
||||
} catch (e) { alert('Netzwerkfehler.'); }
|
||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||
}
|
||||
|
||||
export async function resetQbo(id) {
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ async function submitPayment() {
|
|||
const submitBtn = document.getElementById('payment-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Wird gesendet...';
|
||||
submitBtn.disabled = true;
|
||||
if (typeof showSpinner === 'function') showSpinner('Erstelle Payment in QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/record-payment', {
|
||||
|
|
@ -264,6 +265,7 @@ async function submitPayment() {
|
|||
} finally {
|
||||
submitBtn.innerHTML = '💰 Record Payment in QBO';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
119
server.js
119
server.js
|
|
@ -403,7 +403,7 @@ app.delete('/api/quotes/:id', async (req, res) => {
|
|||
app.get('/api/invoices', async (req, res) => {
|
||||
try {
|
||||
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
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
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
|
||||
async function startServer() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue