This commit is contained in:
2026-02-19 21:01:23 -06:00
parent a9465aa812
commit 171450400a
2 changed files with 401 additions and 171 deletions

234
server.js
View File

@@ -1612,12 +1612,12 @@ app.patch('/api/invoices/:id/reset-qbo', async (req, res) => {
});
// =====================================================
// QBO PAYMENT ENDPOINTS — In server.js einfügen
// Speichert Payments sowohl in lokaler DB als auch in QBO
// QBO PAYMENT ENDPOINTS v2 — In server.js einfügen
// Supports: multi-invoice, partial, unapplied (downpayment)
// =====================================================
// --- 1. Bank-Konten aus QBO laden (für "Deposit To" Dropdown) ---
// --- Bank-Konten aus QBO (für Deposit To) ---
app.get('/api/qbo/accounts', async (req, res) => {
try {
const oauthClient = getOAuthClient();
@@ -1631,23 +1631,16 @@ app.get('/api/qbo/accounts', async (req, res) => {
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const accounts = (data.QueryResponse?.Account || []).map(acc => ({
id: acc.Id,
name: acc.Name,
fullName: acc.FullyQualifiedName || acc.Name
}));
res.json(accounts);
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 fetching bank accounts: ' + error.message });
res.status(500).json({ error: error.message });
}
});
// --- 2. Payment Methods aus QBO laden ---
// --- Payment Methods aus QBO ---
app.get('/api/qbo/payment-methods', async (req, res) => {
try {
const oauthClient = getOAuthClient();
@@ -1661,37 +1654,34 @@ app.get('/api/qbo/payment-methods', async (req, res) => {
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const methods = (data.QueryResponse?.PaymentMethod || []).map(pm => ({
id: pm.Id,
name: pm.Name
}));
res.json(methods);
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 fetching payment methods: ' + error.message });
res.status(500).json({ error: error.message });
}
});
// --- 3. Payment erstellen: Lokal + QBO ---
// --- Record Payment (multi-invoice, partial, unapplied) ---
app.post('/api/qbo/record-payment', async (req, res) => {
const {
invoice_ids, // Array von lokalen Invoice IDs
payment_date, // 'YYYY-MM-DD'
reference_number, // Check # oder ACH Referenz
payment_method_id, // QBO PaymentMethod ID
payment_method_name, // 'Check' oder 'ACH' (für lokale DB)
deposit_to_account_id, // QBO Bank Account ID
deposit_to_account_name // Bankname (für lokale DB)
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,
payment_method_name,
deposit_to_account_id,
deposit_to_account_name
} = req.body;
if (!invoice_ids || invoice_ids.length === 0) {
return res.status(400).json({ error: 'Keine Rechnungen ausgewählt.' });
}
const dbClient = await pool.connect();
try {
@@ -1701,54 +1691,93 @@ app.post('/api/qbo/record-payment', async (req, res) => {
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
// Lokale Invoices laden
const invoicesResult = 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)`,
[invoice_ids]
);
const invoicesData = invoicesResult.rows;
let qboPayment;
let localCustomerId;
let totalAmt;
let invoicesData = [];
// 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(', ')}`
if (mode === 'unapplied') {
// ---- DOWNPAYMENT: kein LinkedTxn ----
if (!customer_qbo_id || !total_amount) {
return res.status(400).json({ error: 'Kunde und Betrag erforderlich.' });
}
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)' : ''}`);
}
const customerIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
if (customerIds.length > 1) {
return res.status(400).json({ error: 'Alle Rechnungen müssen zum selben Kunden gehören.' });
}
const customerQboId = customerIds[0];
const customerId = invoicesData[0].customer_id;
const totalAmount = invoicesData.reduce((sum, inv) => sum + parseFloat(inv.total), 0);
// ----- QBO Payment Objekt -----
const payment = {
CustomerRef: { value: customerQboId },
TotalAmt: totalAmount,
TxnDate: payment_date,
PaymentRefNum: reference_number || '',
PaymentMethodRef: { value: payment_method_id },
DepositToAccountRef: { value: deposit_to_account_id },
Line: invoicesData.map(inv => ({
Amount: parseFloat(inv.total),
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
}))
};
console.log(`💰 QBO Payment: $${totalAmount.toFixed(2)} für ${invoicesData.length} Rechnung(en)`);
// --- QBO senden ---
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payment)
body: JSON.stringify(qboPayment)
});
const data = response.getJson ? response.getJson() : response.json;
@@ -1756,45 +1785,58 @@ app.post('/api/qbo/record-payment', async (req, res) => {
if (!data.Payment) {
console.error('❌ QBO Payment Fehler:', JSON.stringify(data));
return res.status(500).json({
error: 'QBO Payment fehlgeschlagen: ' + JSON.stringify(data.Fault?.Error?.[0]?.Message || data)
error: 'QBO Fehler: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
});
}
const qboPaymentId = data.Payment.Id;
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
// ----- Lokal in DB speichern -----
// --- Lokal speichern ---
await dbClient.query('BEGIN');
// Payment-Datensatz
const paymentResult = 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)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[payment_date, reference_number || null, payment_method_name || 'Check', deposit_to_account_name || '', totalAmount, customerId, qboPaymentId]
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[payment_date, reference_number || null, payment_method_name || 'Check',
deposit_to_account_name || '', totalAmt, localCustomerId, qboPaymentId]
);
const localPaymentId = paymentResult.rows[0].id;
const localPaymentId = payResult.rows[0].id;
// Invoices mit Payment verknüpfen + als bezahlt markieren
for (const inv of invoicesData) {
await dbClient.query(
`INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)`,
[localPaymentId, inv.id, parseFloat(inv.total)]
);
await dbClient.query(
`UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
[payment_date, inv.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: totalAmount,
invoices_paid: invoicesData.length,
message: `Payment $${totalAmount.toFixed(2)} erfasst (QBO: ${qboPaymentId}, Lokal: ${localPaymentId}).`
total: totalAmt,
invoices_paid: mode === 'unapplied' ? 0 : invoice_payments.length,
message: `${modeLabel} $${totalAmt.toFixed(2)} erfasst (QBO: ${qboPaymentId}).`
});
} catch (error) {
@@ -1807,16 +1849,16 @@ app.post('/api/qbo/record-payment', async (req, res) => {
});
// --- 4. Lokale Payments auflisten (optional, für spätere Übersicht) ---
// --- Lokale Payments auflisten ---
app.get('/api/payments', async (req, res) => {
try {
const result = await pool.query(`
SELECT p.*, c.name as customer_name,
json_agg(json_build_object(
COALESCE(json_agg(json_build_object(
'invoice_id', pi.invoice_id,
'amount', pi.amount,
'invoice_number', i.invoice_number
)) as invoices
)) 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