update
This commit is contained in:
parent
52dcdce8bb
commit
03e0516c08
|
|
@ -1202,3 +1202,64 @@ async function checkQboOverdue() {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,21 @@
|
|||
|
||||
<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>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||
|
|
|
|||
|
|
@ -35,10 +35,18 @@ const getOAuthClient = () => {
|
|||
oauthClient.setToken(savedToken);
|
||||
console.log("✅ Gespeicherter Token aus qbo_token.json geladen.");
|
||||
} 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 = {
|
||||
token_type: 'bearer',
|
||||
access_token: process.env.QBO_ACCESS_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) {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue