diff --git a/public/app.js b/public/app.js
index b73b6c9..549eb25 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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() {
const tbody = document.getElementById('customers-list');
@@ -228,41 +230,25 @@ function renderCustomers() {
if (cityStateZip) fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
// QBO Status
- let qboCol;
- if (customer.qbo_id) {
- qboCol = `QBO ✓`;
- } else {
- qboCol = ``;
- }
-
- // Downpayment button (only if in QBO)
- const downpayBtn = customer.qbo_id
- ? ``
- : '';
-
- // Credit placeholder (loaded async)
- const creditSpan = customer.qbo_id
- ? `...`
- : '';
+ const qboStatus = customer.qbo_id
+ ? `QBO ✓`
+ : ``;
return `
@@ -173,8 +146,6 @@ function renderModalContent() {
- ${creditBanner}
-
@@ -285,13 +256,11 @@ function updateTotal() {
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
-
totalEl.textContent = `$${payTotal.toFixed(2)}`;
if (noteEl) {
if (payTotal > invTotal && invTotal > 0) {
- const overpay = payTotal - invTotal;
- noteEl.textContent = `⚠️ Overpayment of $${overpay.toFixed(2)} will be stored as customer credit in QBO.`;
+ noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
noteEl.classList.remove('hidden');
} else {
noteEl.classList.add('hidden');
@@ -336,7 +305,6 @@ async function submitPayment() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- mode: 'invoice',
invoice_payments: selectedInvoices.map(si => ({
invoice_id: si.invoice.id,
amount: si.payAmount
diff --git a/server.js b/server.js
index 166887b..e18b532 100644
--- a/server.js
+++ b/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'`;
- 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) ---
+// --- Record Payment (against invoices) ---
app.post('/api/qbo/record-payment', async (req, res) => {
const {
invoice_payments, // [{ invoice_id, amount }]
@@ -1772,7 +1739,6 @@ app.post('/api/qbo/record-payment', async (req, res) => {
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
- // Local DB
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)
@@ -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)',
[localPaymentId, ip.invoice_id, payAmt]
);
- // Mark paid only if fully covered
if (payAmt >= invTotal) {
await dbClient.query(
'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) => {
- const {
- customer_id, // Local customer ID
- customer_qbo_id, // QBO customer ID
- amount,
- payment_date,
- reference_number,
- payment_method_id,
- payment_method_name,
- deposit_to_account_id,
- deposit_to_account_name
- } = req.body;
+// =====================================================
+// QBO INVOICE UPDATE — Sync local changes to QBO
+// =====================================================
+// Aktualisiert eine bereits exportierte Invoice in QBO.
+// Benötigt qbo_id + qbo_sync_token (Optimistic Locking).
+// Sendet alle Items neu (QBO ersetzt die Line-Items komplett).
- if (!customer_qbo_id || !amount || amount <= 0) {
- return res.status(400).json({ error: 'Customer and amount required.' });
- }
+app.post('/api/invoices/:id/update-qbo', async (req, res) => {
+ const { id } = req.params;
+ const QBO_LABOR_ID = '5';
+ const QBO_PARTS_ID = '9';
+ const dbClient = await pool.connect();
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 companyId = oauthClient.getToken().realmId;
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
- const qboPayment = {
- CustomerRef: { value: customer_qbo_id },
- TotalAmt: parseFloat(amount),
- TxnDate: payment_date,
- PaymentRefNum: reference_number || '',
- 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)
+ // 3. Aktuelle Invoice aus QBO laden um den neuesten SyncToken zu holen
+ console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`);
+ const currentQboRes = await makeQboApiCall({
+ url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
+ method: 'GET'
});
- const data = response.getJson ? response.getJson() : response.json;
+ const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json;
+ const currentQboInvoice = currentQboData.Invoice;
- if (!data.Payment) {
- return res.status(500).json({
- error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
- });
+ if (!currentQboInvoice) {
+ return res.status(500).json({ error: 'Could not load current invoice from QBO.' });
}
- const qboPaymentId = data.Payment.Id;
+ const currentSyncToken = currentQboInvoice.SyncToken;
+ console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
- // Local DB
- await pool.query(
- `INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id, notes)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
- [payment_date, reference_number || null, payment_method_name || 'Check',
- deposit_to_account_name || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)']
+ // 4. Line Items bauen
+ const lineItems = items.map(item => {
+ const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
+ const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
+ const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
+ 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({
success: true,
- qbo_payment_id: qboPaymentId,
- message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).`
+ qbo_id: updatedInvoice.Id,
+ sync_token: updatedInvoice.SyncToken,
+ message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.`
});
+
} catch (error) {
- console.error('❌ Downpayment Error:', error);
- res.status(500).json({ error: 'Downpayment failed: ' + error.message });
+ console.error("QBO Update Error:", error);
+ 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();
}
});