This commit is contained in:
Andreas Knuth 2026-02-17 14:41:58 -06:00
parent 52dcdce8bb
commit 03e0516c08
4 changed files with 267 additions and 3 deletions

View File

@ -1201,4 +1201,65 @@ async function checkQboOverdue() {
btn.innerHTML = originalText; btn.innerHTML = originalText;
btn.disabled = false; btn.disabled = false;
} }
} }
async function importFromQBO() {
if (!confirm(
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
'• Bereits importierte werden übersprungen\n' +
'• Nur Kunden die lokal verknüpft sind\n\n' +
'Fortfahren?'
)) return;
const btn = document.querySelector('button[onclick="importFromQBO()"]');
const resultDiv = document.getElementById('qbo-import-result');
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Importiere aus QBO...';
btn.disabled = true;
resultDiv.classList.add('hidden');
try {
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
const result = await response.json();
resultDiv.classList.remove('hidden');
if (response.ok) {
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
html += `<ul class="text-sm text-gray-700 space-y-1">`;
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
if (result.skipped > 0) {
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
}
if (result.skippedNoCustomer > 0) {
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
}
html += `</ul></div>`;
resultDiv.innerHTML = html;
// Invoice-Liste aktualisieren
if (result.imported > 0) {
loadInvoices();
}
} else {
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
<p class="text-sm text-red-600 mt-1">${result.error}</p>
</div>`;
}
} catch (error) {
console.error('Import Error:', error);
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-600">Netzwerkfehler beim Import.</p>
</div>`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}

View File

@ -147,8 +147,23 @@
<div id="upload-status" class="mt-4"></div> <div id="upload-status" class="mt-4"></div>
<hr class="my-8 border-gray-200"> <hr class="my-8 border-gray-200">
<h3 class="text-xl font-semibold mb-4 text-gray-800">QBO Rechnungs-Import</h3>
<p class="text-gray-600 mb-2">
Importiert alle <strong>unbezahlten</strong> Rechnungen aus QuickBooks Online in dein lokales System.
</p>
<ul class="text-sm text-gray-500 mb-4 list-disc list-inside">
<li>Bereits importierte Rechnungen werden übersprungen</li>
<li>Nur Kunden die lokal mit QBO verknüpft sind</li>
<li>Line Items (Labor/Parts) werden mit importiert</li>
</ul>
<button onclick="importFromQBO()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
<span class="mr-2">📥</span> Unbezahlte Rechnungen importieren
</button>
<div id="qbo-import-result" class="mt-4 hidden"></div>
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3> <h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
<p class="text-gray-600 mb-4"> <p class="text-gray-600 mb-4">
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt, Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,

View File

@ -35,10 +35,18 @@ const getOAuthClient = () => {
oauthClient.setToken(savedToken); oauthClient.setToken(savedToken);
console.log("✅ Gespeicherter Token aus qbo_token.json geladen."); console.log("✅ Gespeicherter Token aus qbo_token.json geladen.");
} else { } else {
// WICHTIG: intuit-oauth braucht ein VOLLSTÄNDIGES Token-Objekt!
// Nur access_token + refresh_token reicht NICHT — die Library
// prüft intern auf token_type, expires_in, createdAt etc.
// und wirft "The Refresh token is invalid" wenn die fehlen.
const envToken = { const envToken = {
token_type: 'bearer',
access_token: process.env.QBO_ACCESS_TOKEN || '', access_token: process.env.QBO_ACCESS_TOKEN || '',
refresh_token: process.env.QBO_REFRESH_TOKEN || '', refresh_token: process.env.QBO_REFRESH_TOKEN || '',
realmId: process.env.QBO_REALM_ID expires_in: 3600,
x_refresh_token_expires_in: 8726400,
realmId: process.env.QBO_REALM_ID,
createdAt: new Date().toISOString()
}; };
if (envToken.refresh_token) { if (envToken.refresh_token) {
oauthClient.setToken(envToken); oauthClient.setToken(envToken);

180
server.js
View File

@ -1354,6 +1354,186 @@ app.get('/api/qbo/status', (req, res) => {
} }
}); });
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();
}
});
// Start server and browser // Start server and browser