update
This commit is contained in:
341
server.js
341
server.js
@@ -1612,12 +1612,14 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// QBO PAYMENT ENDPOINTS v2 — In server.js einfügen
|
||||
// Supports: multi-invoice, partial, unapplied (downpayment)
|
||||
// QBO PAYMENT ENDPOINTS v3 — In server.js einfügen
|
||||
// - Invoice payments (multi, partial, overpay)
|
||||
// - Downpayment (separate endpoint, called from customer view)
|
||||
// - Customer credit query
|
||||
// =====================================================
|
||||
|
||||
|
||||
// --- Bank-Konten aus QBO (für Deposit To) ---
|
||||
// --- Bank-Konten aus QBO ---
|
||||
app.get('/api/qbo/accounts', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
@@ -1625,7 +1627,6 @@ app.get('/api/qbo/accounts', async (req, res) => {
|
||||
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
@@ -1634,7 +1635,6 @@ app.get('/api/qbo/accounts', async (req, res) => {
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||
} catch (error) {
|
||||
console.error('Error fetching QBO accounts:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -1648,7 +1648,6 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
@@ -1657,23 +1656,48 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||
} catch (error) {
|
||||
console.error('Error fetching payment methods:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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';
|
||||
|
||||
// --- Record Payment (multi-invoice, partial, unapplied) ---
|
||||
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) ---
|
||||
app.post('/api/qbo/record-payment', async (req, res) => {
|
||||
const {
|
||||
mode, // 'invoice' | 'unapplied'
|
||||
// Mode 'invoice':
|
||||
invoice_payments, // [{ invoice_id, amount }]
|
||||
// Mode 'unapplied':
|
||||
customer_id, // Lokale Kunden-ID
|
||||
customer_qbo_id, // QBO Customer ID
|
||||
total_amount, // Betrag
|
||||
// Gemeinsam:
|
||||
payment_date,
|
||||
reference_number,
|
||||
payment_method_id,
|
||||
@@ -1682,7 +1706,135 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||
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 = process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
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}`);
|
||||
|
||||
// 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)
|
||||
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]
|
||||
);
|
||||
// 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',
|
||||
[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();
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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;
|
||||
|
||||
if (!customer_qbo_id || !amount || amount <= 0) {
|
||||
return res.status(400).json({ error: 'Customer and amount required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
@@ -1691,184 +1843,69 @@ app.post('/api/qbo/record-payment', async (req, res) => {
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
|
||||
let qboPayment;
|
||||
let localCustomerId;
|
||||
let totalAmt;
|
||||
let invoicesData = [];
|
||||
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
|
||||
};
|
||||
|
||||
if (mode === 'unapplied') {
|
||||
// ---- DOWNPAYMENT: kein LinkedTxn ----
|
||||
if (!customer_qbo_id || !total_amount) {
|
||||
return res.status(400).json({ error: 'Kunde und Betrag erforderlich.' });
|
||||
}
|
||||
console.log(`💰 Downpayment: $${amount} for customer QBO ${customer_qbo_id}`);
|
||||
|
||||
localCustomerId = customer_id;
|
||||
totalAmt = parseFloat(total_amount);
|
||||
|
||||
qboPayment = {
|
||||
CustomerRef: { value: customer_qbo_id },
|
||||
TotalAmt: totalAmt,
|
||||
TxnDate: payment_date,
|
||||
PaymentRefNum: reference_number || '',
|
||||
PaymentMethodRef: { value: payment_method_id },
|
||||
DepositToAccountRef: { value: deposit_to_account_id }
|
||||
// Kein Line[] → Unapplied Payment
|
||||
};
|
||||
|
||||
console.log(`💰 Downpayment: $${totalAmt.toFixed(2)} für Kunde QBO ${customer_qbo_id}`);
|
||||
|
||||
} else {
|
||||
// ---- INVOICE PAYMENT (normal, partial, multi) ----
|
||||
if (!invoice_payments || invoice_payments.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Rechnungen ausgewählt.' });
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
invoicesData = result.rows;
|
||||
|
||||
// Validierung
|
||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||
if (notInQbo.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Nicht 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: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
|
||||
}
|
||||
|
||||
localCustomerId = invoicesData[0].customer_id;
|
||||
|
||||
// Beträge zuordnen
|
||||
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
||||
totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
||||
|
||||
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' }]
|
||||
}))
|
||||
};
|
||||
|
||||
const hasPartial = invoicesData.some(inv => {
|
||||
const payAmt = paymentMap.get(inv.id) || 0;
|
||||
return payAmt < parseFloat(inv.total);
|
||||
});
|
||||
|
||||
console.log(`💰 Payment: $${totalAmt.toFixed(2)} für ${invoicesData.length} Rechnung(en)${hasPartial ? ' (Teilzahlung)' : ''}`);
|
||||
}
|
||||
|
||||
// --- QBO senden ---
|
||||
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) {
|
||||
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
|
||||
return res.status(500).json({
|
||||
error: 'QBO Fehler: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||
});
|
||||
}
|
||||
|
||||
const qboPaymentId = data.Payment.Id;
|
||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||
|
||||
// --- Lokal speichern ---
|
||||
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`,
|
||||
// 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 || '', totalAmt, localCustomerId, qboPaymentId]
|
||||
deposit_to_account_name || '', amount, customer_id, qboPaymentId, 'Downpayment (unapplied)']
|
||||
);
|
||||
const localPaymentId = payResult.rows[0].id;
|
||||
|
||||
// Invoices verknüpfen + als bezahlt markieren
|
||||
if (mode !== 'unapplied' && invoice_payments) {
|
||||
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;
|
||||
const isFullyPaid = payAmt >= invTotal;
|
||||
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[localPaymentId, ip.invoice_id, payAmt]
|
||||
);
|
||||
|
||||
if (isFullyPaid) {
|
||||
// Voll bezahlt → paid_date setzen
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[payment_date, ip.invoice_id]
|
||||
);
|
||||
}
|
||||
// Teilzahlung → paid_date bleibt NULL (Rechnung noch offen)
|
||||
}
|
||||
}
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
const modeLabel = mode === 'unapplied' ? 'Downpayment' : 'Payment';
|
||||
res.json({
|
||||
success: true,
|
||||
payment_id: localPaymentId,
|
||||
qbo_payment_id: qboPaymentId,
|
||||
total: totalAmt,
|
||||
invoices_paid: mode === 'unapplied' ? 0 : invoice_payments.length,
|
||||
message: `${modeLabel} $${totalAmt.toFixed(2)} erfasst (QBO: ${qboPaymentId}).`
|
||||
message: `Downpayment $${parseFloat(amount).toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Payment Error:', error);
|
||||
res.status(500).json({ error: 'Payment fehlgeschlagen: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
console.error('❌ Downpayment Error:', error);
|
||||
res.status(500).json({ error: 'Downpayment failed: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Lokale Payments auflisten ---
|
||||
// --- List local payments ---
|
||||
app.get('/api/payments', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT p.*, c.name as customer_name,
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'invoice_id', pi.invoice_id,
|
||||
'amount', pi.amount,
|
||||
'invoice_number', i.invoice_number
|
||||
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
|
||||
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||
FROM payments p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
||||
GROUP BY p.id, c.name
|
||||
ORDER BY p.payment_date DESC
|
||||
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching payments:', error);
|
||||
res.status(500).json({ error: 'Error fetching payments' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user