602 lines
23 KiB
JavaScript
602 lines
23 KiB
JavaScript
/**
|
|
* QBO Routes
|
|
* Handles QBO OAuth, sync, and data operations
|
|
*/
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const { pool } = require('../config/database');
|
|
const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo');
|
|
const { makeQboApiCall } = require('../../qbo_helper');
|
|
|
|
// GET QBO status
|
|
router.get('/status', (req, res) => {
|
|
try {
|
|
const client = getOAuthClient();
|
|
const token = client.getToken();
|
|
const hasToken = !!(token && token.refresh_token);
|
|
res.json({
|
|
connected: hasToken,
|
|
realmId: token?.realmId || null
|
|
});
|
|
} catch (e) {
|
|
res.json({ connected: false });
|
|
}
|
|
});
|
|
|
|
// GET auth URL - redirects to Intuit
|
|
router.get('/auth', (req, res) => {
|
|
const client = getOAuthClient();
|
|
const authUri = client.authorizeUri({
|
|
scope: [require('../config/qbo').OAuthClient.scopes.Accounting],
|
|
state: 'intuit-qbo-auth'
|
|
});
|
|
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
|
res.redirect(authUri);
|
|
});
|
|
|
|
// OAuth callback
|
|
router.get('/auth/callback', async (req, res) => {
|
|
const client = getOAuthClient();
|
|
try {
|
|
const authResponse = await client.createToken(req.url);
|
|
console.log('✅ QBO Authorization erfolgreich!');
|
|
saveTokens();
|
|
res.redirect('/#settings');
|
|
} catch (e) {
|
|
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
|
res.status(500).send(`
|
|
<h2>QBO Authorization Failed</h2>
|
|
<p>${e.message || e}</p>
|
|
<a href="/">Zurück zur App</a>
|
|
`);
|
|
}
|
|
});
|
|
|
|
// GET bank accounts from QBO
|
|
router.get('/accounts', async (req, res) => {
|
|
try {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
method: 'GET'
|
|
});
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET payment methods from QBO
|
|
router.get('/payment-methods', async (req, res) => {
|
|
try {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
method: 'GET'
|
|
});
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET labor rate from QBO
|
|
router.get('/labor-rate', async (req, res) => {
|
|
try {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
|
|
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);
|
|
res.json({ rate: null });
|
|
}
|
|
});
|
|
|
|
// GET last sync timestamp
|
|
router.get('/last-sync', async (req, res) => {
|
|
try {
|
|
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
|
|
res.json({ last_sync: result.rows[0]?.value || null });
|
|
} catch (error) {
|
|
res.json({ last_sync: null });
|
|
}
|
|
});
|
|
|
|
// GET overdue invoices from QBO
|
|
router.get('/overdue', async (req, res) => {
|
|
try {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - 30);
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
|
|
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
|
|
|
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
|
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
method: 'GET'
|
|
});
|
|
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
const invoices = data.QueryResponse?.Invoice || [];
|
|
|
|
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
|
res.json(invoices);
|
|
|
|
} catch (error) {
|
|
console.error("QBO Report Error:", error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// POST import unpaid invoices from QBO
|
|
router.post('/import-unpaid', async (req, res) => {
|
|
const dbClient = await pool.connect();
|
|
|
|
try {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
|
|
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.'
|
|
});
|
|
}
|
|
|
|
// Load local customers
|
|
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));
|
|
|
|
// Get already imported QBO invoices
|
|
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));
|
|
|
|
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);
|
|
|
|
if (existingQboIds.has(qboId)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const docNumber = qboInv.DocNumber || '';
|
|
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
|
|
const syncToken = qboInv.SyncToken || '';
|
|
|
|
let terms = 'Net 30';
|
|
if (qboInv.SalesTermRef?.name) {
|
|
terms = qboInv.SalesTermRef.name;
|
|
}
|
|
|
|
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
|
|
const taxExempt = taxAmount === 0;
|
|
|
|
const total = parseFloat(qboInv.TotalAmt) || 0;
|
|
const subtotal = total - taxAmount;
|
|
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
|
|
|
|
const authCode = qboInv.CustomerMemo?.value || '';
|
|
|
|
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;
|
|
|
|
const lines = qboInv.Line || [];
|
|
let itemOrder = 0;
|
|
|
|
for (const line of lines) {
|
|
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 || '';
|
|
|
|
const itemRefValue = detail.ItemRef?.value || '9';
|
|
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
|
|
let qboItemId = '9';
|
|
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();
|
|
}
|
|
});
|
|
|
|
// POST record payment in QBO
|
|
router.post('/record-payment', async (req, res) => {
|
|
const {
|
|
invoice_payments,
|
|
payment_date,
|
|
reference_number,
|
|
payment_method_id,
|
|
payment_method_name,
|
|
deposit_to_account_id,
|
|
deposit_to_account_name
|
|
} = req.body;
|
|
|
|
if (!invoice_payments || invoice_payments.length === 0) {
|
|
return res.status(400).json({ error: 'No invoices selected.' });
|
|
}
|
|
|
|
const dbClient = await pool.connect();
|
|
try {
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
|
|
const ids = invoice_payments.map(ip => ip.invoice_id);
|
|
const result = await dbClient.query(
|
|
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
|
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
|
|
WHERE i.id = ANY($1)`, [ids]
|
|
);
|
|
const invoicesData = result.rows;
|
|
|
|
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
|
if (notInQbo.length > 0) {
|
|
return res.status(400).json({
|
|
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
|
});
|
|
}
|
|
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
|
if (custIds.length > 1) {
|
|
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
|
|
}
|
|
|
|
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
|
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
|
|
|
const qboPayment = {
|
|
CustomerRef: { value: custIds[0] },
|
|
TotalAmt: totalAmt,
|
|
TxnDate: payment_date,
|
|
PaymentRefNum: reference_number || '',
|
|
PaymentMethodRef: { value: payment_method_id },
|
|
DepositToAccountRef: { value: deposit_to_account_id },
|
|
Line: invoicesData.map(inv => ({
|
|
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
|
|
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
|
}))
|
|
};
|
|
|
|
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
|
|
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(qboPayment)
|
|
});
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
|
|
if (!data.Payment) {
|
|
return res.status(500).json({
|
|
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
|
});
|
|
}
|
|
|
|
const qboPaymentId = data.Payment.Id;
|
|
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
|
|
|
await dbClient.query('BEGIN');
|
|
const payResult = 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 id`,
|
|
[payment_date, reference_number || null, payment_method_name || 'Check',
|
|
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
|
|
);
|
|
const localPaymentId = payResult.rows[0].id;
|
|
|
|
for (const ip of invoice_payments) {
|
|
const payAmt = parseFloat(ip.amount);
|
|
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
|
const invTotal = inv ? parseFloat(inv.total) : 0;
|
|
|
|
await dbClient.query(
|
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
|
[localPaymentId, ip.invoice_id, payAmt]
|
|
);
|
|
if (payAmt >= invTotal) {
|
|
await dbClient.query(
|
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
[payment_date, ip.invoice_id]
|
|
);
|
|
}
|
|
}
|
|
await dbClient.query('COMMIT');
|
|
|
|
res.json({
|
|
success: true,
|
|
payment_id: localPaymentId,
|
|
qbo_payment_id: qboPaymentId,
|
|
total: totalAmt,
|
|
invoices_paid: invoice_payments.length,
|
|
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
|
});
|
|
} catch (error) {
|
|
await dbClient.query('ROLLBACK').catch(() => {});
|
|
console.error('❌ Payment Error:', error);
|
|
res.status(500).json({ error: 'Payment failed: ' + error.message });
|
|
} finally {
|
|
dbClient.release();
|
|
}
|
|
});
|
|
|
|
// POST sync payments from QBO
|
|
router.post('/sync-payments', async (req, res) => {
|
|
const dbClient = await pool.connect();
|
|
try {
|
|
const openResult = await dbClient.query(`
|
|
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
|
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
|
|
FROM invoices i
|
|
WHERE i.qbo_id IS NOT NULL
|
|
`);
|
|
|
|
const openInvoices = openResult.rows;
|
|
if (openInvoices.length === 0) {
|
|
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
|
|
return res.json({ synced: 0, message: 'All invoices up to date.' });
|
|
}
|
|
|
|
const oauthClient = getOAuthClient();
|
|
const companyId = oauthClient.getToken().realmId;
|
|
const baseUrl = getQboBaseUrl();
|
|
|
|
const batchSize = 50;
|
|
const qboInvoices = new Map();
|
|
|
|
for (let i = 0; i < openInvoices.length; i += batchSize) {
|
|
const batch = openInvoices.slice(i, i + batchSize);
|
|
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
|
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
|
|
|
const response = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
method: 'GET'
|
|
});
|
|
const data = response.getJson ? response.getJson() : response.json;
|
|
const invoices = data.QueryResponse?.Invoice || [];
|
|
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
|
}
|
|
|
|
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
|
|
|
let updated = 0;
|
|
let newPayments = 0;
|
|
|
|
await dbClient.query('BEGIN');
|
|
|
|
for (const localInv of openInvoices) {
|
|
const qboInv = qboInvoices.get(localInv.qbo_id);
|
|
if (!qboInv) continue;
|
|
|
|
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
|
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
|
const localPaid = parseFloat(localInv.local_paid) || 0;
|
|
|
|
if (qboBalance === 0 && qboTotal > 0) {
|
|
const UNDEPOSITED_FUNDS_ID = '221';
|
|
let status = 'Paid';
|
|
|
|
if (qboInv.LinkedTxn) {
|
|
for (const txn of qboInv.LinkedTxn) {
|
|
if (txn.TxnType === 'Payment') {
|
|
try {
|
|
const pmRes = await makeQboApiCall({
|
|
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
|
method: 'GET'
|
|
});
|
|
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
|
const payment = pmData.Payment;
|
|
if (payment && payment.DepositToAccountRef &&
|
|
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
|
|
status = 'Deposited';
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
|
|
if (needsUpdate) {
|
|
await dbClient.query(
|
|
`UPDATE invoices SET
|
|
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
|
payment_status = $1,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $2`,
|
|
[status, localInv.id]
|
|
);
|
|
updated++;
|
|
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
|
|
}
|
|
|
|
const diff = qboTotal - localPaid;
|
|
if (diff > 0.01) {
|
|
const payResult = await dbClient.query(
|
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[diff, localInv.id]
|
|
);
|
|
await dbClient.query(
|
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
|
[payResult.rows[0].id, localInv.id, diff]
|
|
);
|
|
newPayments++;
|
|
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
|
}
|
|
|
|
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
|
const qboPaid = qboTotal - qboBalance;
|
|
const diff = qboPaid - localPaid;
|
|
|
|
const needsUpdate = localInv.payment_status !== 'Partial';
|
|
if (needsUpdate) {
|
|
await dbClient.query(
|
|
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
|
['Partial', localInv.id]
|
|
);
|
|
updated++;
|
|
}
|
|
|
|
if (diff > 0.01) {
|
|
const payResult = await dbClient.query(
|
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[diff, localInv.id]
|
|
);
|
|
await dbClient.query(
|
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
|
[payResult.rows[0].id, localInv.id, diff]
|
|
);
|
|
newPayments++;
|
|
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await dbClient.query(`
|
|
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
|
|
ON CONFLICT (key) DO UPDATE SET value = $1
|
|
`, [new Date().toISOString()]);
|
|
|
|
await dbClient.query('COMMIT');
|
|
|
|
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
|
res.json({
|
|
synced: updated,
|
|
new_payments: newPayments,
|
|
total_checked: openInvoices.length,
|
|
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
|
|
});
|
|
|
|
} catch (error) {
|
|
await dbClient.query('ROLLBACK').catch(() => {});
|
|
console.error('❌ Sync Error:', error);
|
|
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
|
} finally {
|
|
dbClient.release();
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|