update
This commit is contained in:
parent
49aeff8cb6
commit
410faee6d1
|
|
@ -216,8 +216,10 @@ async function loadCustomers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. renderCustomers() — ERSETZE komplett ---
|
// =====================================================
|
||||||
// Zeigt QBO-Status, Credit-Betrag und Downpayment-Button
|
// 1. renderCustomers() — ERSETZE komplett
|
||||||
|
// Zeigt QBO-Status und Export-Button in der Kundenliste
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
function renderCustomers() {
|
function renderCustomers() {
|
||||||
const tbody = document.getElementById('customers-list');
|
const tbody = document.getElementById('customers-list');
|
||||||
|
|
@ -228,41 +230,25 @@ function renderCustomers() {
|
||||||
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||||
|
|
||||||
// QBO Status
|
// QBO Status
|
||||||
let qboCol;
|
const qboStatus = customer.qbo_id
|
||||||
if (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>`
|
||||||
qboCol = `<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>`;
|
||||||
} else {
|
|
||||||
qboCol = `<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">QBO Export</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downpayment button (only if in QBO)
|
|
||||||
const downpayBtn = customer.qbo_id
|
|
||||||
? `<button onclick="openDownpaymentModal(${customer.id}, '${customer.qbo_id}', '${customer.name.replace(/'/g, "\\'")}')" class="text-emerald-600 hover:text-emerald-800">Downpayment</button>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Credit placeholder (loaded async)
|
|
||||||
const creditSpan = customer.qbo_id
|
|
||||||
? `<span id="customer-credit-${customer.id}" class="text-xs text-gray-400">...</span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
${customer.name} ${qboCol} ${creditSpan}
|
${customer.name} ${qboStatus}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</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 text-gray-500">${customer.account_number || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<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="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
${downpayBtn}
|
|
||||||
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Load credits async for QBO customers
|
|
||||||
loadCustomerCredits();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Credits async laden ---
|
// --- 2. Credits async laden ---
|
||||||
async function loadCustomerCredits() {
|
async function loadCustomerCredits() {
|
||||||
const qboCustomers = customers.filter(c => c.qbo_id);
|
const qboCustomers = customers.filter(c => c.qbo_id);
|
||||||
|
|
|
||||||
|
|
@ -167,13 +167,14 @@ function renderInvoiceRow(invoice) {
|
||||||
// --- BUTTONS (Edit | QBO | PDF HTML | Payment | Del) ---
|
// --- 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 editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
|
||||||
|
|
||||||
// QBO Button — nur aktiv wenn Kunde eine qbo_id hat
|
// QBO Button — Export oder Sync
|
||||||
const customerHasQbo = !!invoice.customer_qbo_id;
|
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||||
let qboBtn;
|
let qboBtn;
|
||||||
if (hasQbo) {
|
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>`;
|
// Already in QBO — show sync button + reset option
|
||||||
|
qboBtn = `<button onclick="window.invoiceView.syncToQBO(${invoice.id})" class="text-purple-600 hover:text-purple-900" title="Sync changes to QBO (ID: ${invoice.qbo_id})">⟳ QBO Sync</button>`;
|
||||||
} else if (!customerHasQbo) {
|
} else if (!customerHasQbo) {
|
||||||
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Kunde muss erst nach QBO exportiert werden">QBO ⚠</span>`;
|
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||||||
} else {
|
} else {
|
||||||
qboBtn = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
qboBtn = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
||||||
}
|
}
|
||||||
|
|
@ -349,6 +350,18 @@ export async function exportToQBO(id) {
|
||||||
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function syncToQBO(id) {
|
||||||
|
if (!confirm('Sync changes to QuickBooks Online?')) return;
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) { alert(`✅ ${d.message}`); loadInvoices(); }
|
||||||
|
else alert(`❌ ${d.error}`);
|
||||||
|
} catch (e) { alert('Network error.'); }
|
||||||
|
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetQbo(id) {
|
export async function resetQbo(id) {
|
||||||
if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return;
|
if (!confirm('QBO-Verknüpfung zurücksetzen?\nRechnung muss zuerst in QBO gelöscht sein!')) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -385,6 +398,6 @@ export async function remove(id) {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.invoiceView = {
|
window.invoiceView = {
|
||||||
viewPDF, viewHTML, exportToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
|
viewPDF, viewHTML, exportToQBO, syncToQBO, resetQbo, markPaid, markUnpaid, edit, remove,
|
||||||
loadInvoices, renderInvoiceView, setStatus
|
loadInvoices, renderInvoiceView, setStatus
|
||||||
};
|
};
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// payment-modal.js — ES Module v3
|
// payment-modal.js — ES Module v3 (clean)
|
||||||
// Invoice payments only: multi-invoice, partial, editable amounts
|
// Invoice payments: multi-invoice, partial, overpay
|
||||||
// Downpayment is handled separately in customer view
|
// No downpayment functionality
|
||||||
|
|
||||||
let bankAccounts = [];
|
let bankAccounts = [];
|
||||||
let paymentMethods = [];
|
let paymentMethods = [];
|
||||||
let selectedInvoices = []; // { invoice, payAmount }
|
let selectedInvoices = []; // { invoice, payAmount }
|
||||||
let customerCredit = 0; // Unapplied credit from QBO
|
|
||||||
let dataLoaded = false;
|
let dataLoaded = false;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -32,7 +31,6 @@ async function loadQboData() {
|
||||||
export async function openPaymentModal(invoiceIds = []) {
|
export async function openPaymentModal(invoiceIds = []) {
|
||||||
await loadQboData();
|
await loadQboData();
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
customerCredit = 0;
|
|
||||||
|
|
||||||
for (const id of invoiceIds) {
|
for (const id of invoiceIds) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,18 +45,6 @@ export async function openPaymentModal(invoiceIds = []) {
|
||||||
} catch (e) { console.error('Error loading invoice:', id, e); }
|
} catch (e) { console.error('Error loading invoice:', id, e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check customer credit if we have invoices
|
|
||||||
if (selectedInvoices.length > 0) {
|
|
||||||
const custQboId = selectedInvoices[0].invoice.customer_qbo_id;
|
|
||||||
if (custQboId) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/qbo/customer-credit/${custQboId}`);
|
|
||||||
const data = await res.json();
|
|
||||||
customerCredit = data.credit || 0;
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureModalElement();
|
ensureModalElement();
|
||||||
renderModalContent();
|
renderModalContent();
|
||||||
document.getElementById('payment-modal').classList.add('active');
|
document.getElementById('payment-modal').classList.add('active');
|
||||||
|
|
@ -68,7 +54,6 @@ export function closePaymentModal() {
|
||||||
const modal = document.getElementById('payment-modal');
|
const modal = document.getElementById('payment-modal');
|
||||||
if (modal) modal.classList.remove('active');
|
if (modal) modal.classList.remove('active');
|
||||||
selectedInvoices = [];
|
selectedInvoices = [];
|
||||||
customerCredit = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -146,22 +131,10 @@ function renderModalContent() {
|
||||||
|
|
||||||
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
|
||||||
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
|
||||||
const methods = (filtered.length > 0 ? filtered : paymentMethods);
|
const methods = filtered.length > 0 ? filtered : paymentMethods;
|
||||||
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
// Credit banner
|
|
||||||
let creditBanner = '';
|
|
||||||
if (customerCredit > 0) {
|
|
||||||
creditBanner = `
|
|
||||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
|
||||||
<p class="text-sm text-green-800">
|
|
||||||
💰 <strong>Customer has $${customerCredit.toFixed(2)} unapplied credit.</strong>
|
|
||||||
This can be applied in QBO when processing the payment.
|
|
||||||
</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
|
@ -173,8 +146,6 @@ function renderModalContent() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${creditBanner}
|
|
||||||
|
|
||||||
<!-- Invoice List -->
|
<!-- Invoice List -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
|
||||||
|
|
@ -285,13 +256,11 @@ function updateTotal() {
|
||||||
|
|
||||||
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
|
||||||
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
|
||||||
|
|
||||||
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
totalEl.textContent = `$${payTotal.toFixed(2)}`;
|
||||||
|
|
||||||
if (noteEl) {
|
if (noteEl) {
|
||||||
if (payTotal > invTotal && invTotal > 0) {
|
if (payTotal > invTotal && invTotal > 0) {
|
||||||
const overpay = payTotal - invTotal;
|
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
|
||||||
noteEl.textContent = `⚠️ Overpayment of $${overpay.toFixed(2)} will be stored as customer credit in QBO.`;
|
|
||||||
noteEl.classList.remove('hidden');
|
noteEl.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
noteEl.classList.add('hidden');
|
noteEl.classList.add('hidden');
|
||||||
|
|
@ -336,7 +305,6 @@ async function submitPayment() {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mode: 'invoice',
|
|
||||||
invoice_payments: selectedInvoices.map(si => ({
|
invoice_payments: selectedInvoices.map(si => ({
|
||||||
invoice_id: si.invoice.id,
|
invoice_id: si.invoice.id,
|
||||||
amount: si.payAmount
|
amount: si.payAmount
|
||||||
|
|
|
||||||
196
server.js
196
server.js
|
|
@ -1660,41 +1660,8 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Customer Credit (unapplied payments) ---
|
|
||||||
app.get('/api/qbo/customer-credit/:qboCustomerId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { qboCustomerId } = req.params;
|
|
||||||
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';
|
|
||||||
|
|
||||||
const query = `SELECT * FROM Payment WHERE CustomerRef = '${qboCustomerId}' AND UnappliedAmt > '0'`;
|
// --- Record Payment (against invoices) ---
|
||||||
const response = await makeQboApiCall({
|
|
||||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
|
||||||
method: 'GET'
|
|
||||||
});
|
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
|
||||||
const payments = data.QueryResponse?.Payment || [];
|
|
||||||
|
|
||||||
const totalCredit = payments.reduce((sum, p) => sum + (parseFloat(p.UnappliedAmt) || 0), 0);
|
|
||||||
const details = payments.map(p => ({
|
|
||||||
qbo_id: p.Id,
|
|
||||||
date: p.TxnDate,
|
|
||||||
total: p.TotalAmt,
|
|
||||||
unapplied: p.UnappliedAmt,
|
|
||||||
ref: p.PaymentRefNum || ''
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({ credit: totalCredit, payments: details });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching customer credit:', error);
|
|
||||||
res.json({ credit: 0, payments: [] });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Record Payment (against invoices: normal, partial, multi, overpay) ---
|
|
||||||
app.post('/api/qbo/record-payment', async (req, res) => {
|
app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
invoice_payments, // [{ invoice_id, amount }]
|
invoice_payments, // [{ invoice_id, amount }]
|
||||||
|
|
@ -1772,7 +1739,6 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
const qboPaymentId = data.Payment.Id;
|
const qboPaymentId = data.Payment.Id;
|
||||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||||
|
|
||||||
// Local DB
|
|
||||||
await dbClient.query('BEGIN');
|
await dbClient.query('BEGIN');
|
||||||
const payResult = await dbClient.query(
|
const payResult = await dbClient.query(
|
||||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||||
|
|
@ -1791,7 +1757,6 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
[localPaymentId, ip.invoice_id, payAmt]
|
[localPaymentId, ip.invoice_id, payAmt]
|
||||||
);
|
);
|
||||||
// Mark paid only if fully covered
|
|
||||||
if (payAmt >= invTotal) {
|
if (payAmt >= invTotal) {
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
|
@ -1818,75 +1783,136 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Record Downpayment (unapplied, from customer view) ---
|
// =====================================================
|
||||||
app.post('/api/qbo/record-downpayment', async (req, res) => {
|
// QBO INVOICE UPDATE — Sync local changes to QBO
|
||||||
const {
|
// =====================================================
|
||||||
customer_id, // Local customer ID
|
// Aktualisiert eine bereits exportierte Invoice in QBO.
|
||||||
customer_qbo_id, // QBO customer ID
|
// Benötigt qbo_id + qbo_sync_token (Optimistic Locking).
|
||||||
amount,
|
// Sendet alle Items neu (QBO ersetzt die Line-Items komplett).
|
||||||
payment_date,
|
|
||||||
reference_number,
|
|
||||||
payment_method_id,
|
|
||||||
payment_method_name,
|
|
||||||
deposit_to_account_id,
|
|
||||||
deposit_to_account_name
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (!customer_qbo_id || !amount || amount <= 0) {
|
app.post('/api/invoices/:id/update-qbo', async (req, res) => {
|
||||||
return res.status(400).json({ error: 'Customer and amount required.' });
|
const { id } = req.params;
|
||||||
}
|
const QBO_LABOR_ID = '5';
|
||||||
|
const QBO_PARTS_ID = '9';
|
||||||
|
|
||||||
|
const dbClient = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
// 1. Lokale Rechnung + Items laden
|
||||||
|
const invoiceRes = await dbClient.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
|
||||||
|
if (!invoice.qbo_id) {
|
||||||
|
return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' });
|
||||||
|
}
|
||||||
|
if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') {
|
||||||
|
return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
|
||||||
|
const items = itemsRes.rows;
|
||||||
|
|
||||||
|
// 2. QBO vorbereiten
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
const companyId = oauthClient.getToken().realmId;
|
const companyId = oauthClient.getToken().realmId;
|
||||||
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
? 'https://quickbooks.api.intuit.com'
|
? 'https://quickbooks.api.intuit.com'
|
||||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
const qboPayment = {
|
// 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen
|
||||||
CustomerRef: { value: customer_qbo_id },
|
console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`);
|
||||||
TotalAmt: parseFloat(amount),
|
const currentQboRes = await makeQboApiCall({
|
||||||
TxnDate: payment_date,
|
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||||||
PaymentRefNum: reference_number || '',
|
method: 'GET'
|
||||||
PaymentMethodRef: { value: payment_method_id },
|
|
||||||
DepositToAccountRef: { value: deposit_to_account_id }
|
|
||||||
// No Line[] → unapplied payment
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`💰 Downpayment: $${amount} for customer QBO ${customer_qbo_id}`);
|
|
||||||
|
|
||||||
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;
|
const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json;
|
||||||
|
const currentQboInvoice = currentQboData.Invoice;
|
||||||
|
|
||||||
if (!data.Payment) {
|
if (!currentQboInvoice) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({ error: 'Could not load current invoice from QBO.' });
|
||||||
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const qboPaymentId = data.Payment.Id;
|
const currentSyncToken = currentQboInvoice.SyncToken;
|
||||||
|
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
|
||||||
|
|
||||||
// Local DB
|
// 4. Line Items bauen
|
||||||
await pool.query(
|
const lineItems = items.map(item => {
|
||||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id, notes)
|
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
||||||
[payment_date, reference_number || null, payment_method_name || 'Check',
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||||
deposit_to_account_name || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)']
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
|
|
||||||
|
return {
|
||||||
|
"DetailType": "SalesItemLineDetail",
|
||||||
|
"Amount": amount,
|
||||||
|
"Description": item.description,
|
||||||
|
"SalesItemLineDetail": {
|
||||||
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
|
"UnitPrice": rate,
|
||||||
|
"Qty": parseFloat(item.quantity) || 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. QBO Update Payload — sparse update
|
||||||
|
// Id + SyncToken sind Pflicht. Alles was mitgesendet wird, wird aktualisiert.
|
||||||
|
const updatePayload = {
|
||||||
|
"Id": invoice.qbo_id,
|
||||||
|
"SyncToken": currentSyncToken,
|
||||||
|
"sparse": true,
|
||||||
|
"Line": lineItems,
|
||||||
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||||
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||||
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`);
|
||||||
|
|
||||||
|
const updateResponse = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json;
|
||||||
|
const updatedInvoice = updateData.Invoice || updateData;
|
||||||
|
|
||||||
|
if (!updatedInvoice.Id) {
|
||||||
|
console.error("QBO Update Response:", JSON.stringify(updateData, null, 2));
|
||||||
|
throw new Error("QBO did not return an updated invoice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`);
|
||||||
|
|
||||||
|
// 6. Neuen SyncToken lokal speichern
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[updatedInvoice.SyncToken, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
qbo_payment_id: qboPaymentId,
|
qbo_id: updatedInvoice.Id,
|
||||||
message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
sync_token: updatedInvoice.SyncToken,
|
||||||
|
message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.`
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Downpayment Error:', error);
|
console.error("QBO Update Error:", error);
|
||||||
res.status(500).json({ error: 'Downpayment failed: ' + error.message });
|
let errorDetails = error.message;
|
||||||
|
if (error.response?.data?.Fault?.Error?.[0]) {
|
||||||
|
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "QBO Update failed: " + errorDetails });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue