update
This commit is contained in:
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'`;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user