update
This commit is contained in:
parent
52dcdce8bb
commit
03e0516c08
|
|
@ -1202,3 +1202,64 @@ async function checkQboOverdue() {
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
180
server.js
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue