This commit is contained in:
2026-07-03 16:42:52 -05:00
parent 729364eb41
commit dca60248f4
8 changed files with 1878 additions and 59 deletions

View File

@@ -4,7 +4,8 @@ const {
getOAuthClient: getClient,
saveTokens,
resetOAuthClient,
makeQboApiCall // <-- NEU: Direkt hier mit importieren
makeQboApiCall,
extractQboFault
} = require('../../qbo_helper');
function getOAuthClient() {
@@ -23,5 +24,6 @@ module.exports = {
getQboBaseUrl,
saveTokens,
resetOAuthClient,
makeQboApiCall // <-- NEU: Und sauber weiterreichen
makeQboApiCall,
extractQboFault
};

View File

@@ -1150,8 +1150,8 @@ router.post('/:id/check-payment', async (req, res) => {
return res.status(400).json({ error: 'No Stripe payment link exists for this invoice.' });
}
// Already fully processed?
if (invoice.stripe_payment_status === 'paid') {
// Already fully processed? (paid AND no pending QBO error)
if (invoice.stripe_payment_status === 'paid' && !invoice.qbo_payment_error) {
return res.json({
status: 'paid',
message: 'Stripe payment already recorded.',
@@ -1187,6 +1187,40 @@ router.post('/:id/check-payment', async (req, res) => {
const stripeFee = result.details.stripeFee;
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
const newTotalPaid = invoice.amount_paid + amountReceived;
const invoiceTotal = parseFloat(invoice.total) || 0;
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
let qboResult = null;
// ── QBO Buchung ZUERST, vor lokalen DB-Schreibvorgängen ──
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
qboResult = await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
result.details.paymentIntentId || ''
);
} catch (qboErr) {
// QBO booking FAILED — mark for retry, DON'T commit local payment
const errorText = `${new Date().toISOString()}: ${qboErr.message}`.substring(0, 5000);
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
qbo_payment_error = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[errorText, id]
);
return res.status(502).json({
status: 'paid',
paid: true,
qboError: qboErr.message,
message: `Stripe payment received but QBO booking failed (marked for retry): ${qboErr.message}`
});
}
}
// === QBO ok (or no QBO link) — write local records ===
await dbClient.query('BEGIN');
// 1. Record local payment (payment + payment_invoices)
@@ -1209,31 +1243,21 @@ router.post('/:id/check-payment', async (req, res) => {
[paymentId, id, amountReceived]
);
// 2. Check if invoice is fully paid
const newTotalPaid = invoice.amount_paid + amountReceived;
const invoiceTotal = parseFloat(invoice.total) || 0;
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01); // Cent-Toleranz
// 2. Update invoice — mark as paid, clear any previous error
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
payment_status = $1,
qbo_payment_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[fullyPaid ? 'Stripe' : 'Partial', id]
[fullyPaid ? 'Paid' : 'Partial', id]
);
// 3. Deactivate the payment link
await deactivatePaymentLink(invoice.stripe_payment_link_id);
// 4. QBO: Record Payment + Expense (if QBO-linked)
qboResult = await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
result.details.paymentIntentId || ''
// kein source-Parameter — default ist 'manual'
);
await dbClient.query('COMMIT');
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);

View File

@@ -3,7 +3,7 @@
* QuickBooks Online Service
* Handles QBO API interactions
*/
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
const { getOAuthClient, getQboBaseUrl, makeQboApiCall, extractQboFault } = require('../config/qbo');
// QBO Item IDs
const QBO_LABOR_ID = '5';
@@ -265,11 +265,47 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
});
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
if (paymentData.Fault) {
const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; ');
throw new Error('QBO Payment failed: ' + errMsg);
const paymentFault = extractQboFault(paymentData);
if (paymentFault) {
throw new Error(`QBO Payment failed: ${paymentFault.message}${paymentFault.detail ? ' - ' + paymentFault.detail : ''}`);
}
console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`);
if (!paymentData.Payment || !paymentData.Payment.Id) {
throw new Error(
`QBO Payment response missing Payment.Id for Invoice #${invoice.invoice_number}. ` +
`Response: ${JSON.stringify(paymentData).substring(0, 300)}`
);
}
const paymentId = paymentData.Payment.Id;
// ── 1b. Verify: QBO Invoice balance is now zero ──
const verifyUrl = `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`;
const verifyRes = await makeQboApiCall({
url: verifyUrl,
method: 'GET'
});
const verifyData = verifyRes.getJson ? verifyRes.getJson() : verifyRes.json;
const verifyFault = extractQboFault(verifyData);
if (verifyFault) {
throw new Error(`QBO Invoice GET failed during payment verification: ${verifyFault.message}`);
}
const qboInv = verifyData.Invoice;
if (!qboInv) {
throw new Error(`QBO Invoice ${invoice.qbo_id} not found during payment verification for #${invoice.invoice_number}`);
}
const invoiceBalance = parseFloat(qboInv.Balance) || 0;
if (invoiceBalance > 0.01) {
throw new Error(
`QBO Payment ${paymentId} created but invoice #${invoice.invoice_number} ` +
`still has balance $${invoiceBalance.toFixed(2)} — payment not applied.`
);
}
console.log(`✅ QBO Payment created and verified: ID ${paymentId}, Invoice #${invoice.invoice_number} Balance=0`);
// ── 2. Create QBO Expense for Stripe Fee ──
// Only if explicitly enabled via env flag. We get the fee details from Stripe payout reports
@@ -279,7 +315,7 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
if (stripeFee > 0 && !bookFee) {
console.log(` Stripe fee $${stripeFee.toFixed(2)} NOT booked in QBO (QBO_BOOK_STRIPE_FEES != 'true')`);
return {
paymentId: paymentData.Payment?.Id,
paymentId,
feeBooked: false,
feeSkipped: true
};
@@ -311,8 +347,9 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
});
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
if (expenseData.Fault) {
console.error('⚠️ QBO Expense booking failed:', JSON.stringify(expenseData.Fault));
const expenseFault = extractQboFault(expenseData);
if (expenseFault) {
console.error(`⚠️ QBO Expense booking failed: ${expenseFault.message}`);
// Don't throw — payment itself is valid
} else {
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
@@ -320,7 +357,7 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
}
return {
paymentId: paymentData.Payment?.Id,
paymentId,
feeBooked: stripeFee > 0 && bookFee,
feeSkipped: false
};

View File

@@ -24,15 +24,19 @@ async function pollStripePayments() {
const dbClient = await pool.connect();
try {
// Find all invoices with active (unpaid) Stripe links
// Find invoices with active Stripe links that aren't settled yet,
// OR invoices where a previous QBO booking failed and needs retry.
const result = await dbClient.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
WHERE i.stripe_payment_link_id IS NOT NULL
AND i.stripe_payment_status NOT IN ('paid')
AND i.paid_date IS NULL
AND (
i.stripe_payment_status NOT IN ('paid')
OR i.qbo_payment_error IS NOT NULL
)
`);
const openInvoices = result.rows;
@@ -49,6 +53,8 @@ async function pollStripePayments() {
let errorCount = 0;
for (const invoice of openInvoices) {
const isRetry = !!invoice.qbo_payment_error;
try {
const status = await checkPaymentStatus(invoice.stripe_payment_link_id);
@@ -66,7 +72,17 @@ async function pollStripePayments() {
continue;
}
if (!status.paid) continue;
if (!status.paid) {
// If this was a retry and Stripe no longer shows "paid", clear error
if (isRetry) {
await dbClient.query(
`UPDATE invoices SET qbo_payment_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[invoice.id]
);
console.log(` ⚠️ #${invoice.invoice_number}: Stripe payment no longer detected, retry aborted`);
}
continue;
}
// === PAID — process it ===
const amountReceived = status.details.amountReceived;
@@ -75,7 +91,35 @@ async function pollStripePayments() {
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
const newTotalPaid = invoice.amount_paid + amountReceived;
const invoiceTotal = parseFloat(invoice.total) || 0;
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
// ── QBO Buchung ZUERST, vor lokalen DB-Schreibvorgängen ──
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
status.details.paymentIntentId || '',
{ source: 'auto-polled' }
);
} catch (qboErr) {
// QBO booking FAILED — mark for retry, DON'T write local payment
const errorText = `${new Date().toISOString()}: ${qboErr.message}`.substring(0, 5000);
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
qbo_payment_error = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[errorText, invoice.id]
);
console.error(` ⚠️ QBO booking FAILED for #${invoice.invoice_number} — marked for retry: ${qboErr.message}`);
continue;
}
}
// === QBO ok (or no QBO link) — write local records ===
await dbClient.query('BEGIN');
// 1. Record local payment
@@ -97,40 +141,26 @@ async function pollStripePayments() {
[payResult.rows[0].id, invoice.id, amountReceived]
);
// 2. Check if fully paid
const newTotalPaid = invoice.amount_paid + amountReceived;
const invoiceTotal = parseFloat(invoice.total) || 0;
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
// 2. Update invoice — mark as paid, clear any previous error
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
payment_status = $1,
qbo_payment_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[fullyPaid ? 'Stripe' : 'Partial', invoice.id]
[fullyPaid ? 'Paid' : 'Partial', invoice.id]
);
// 3. Deactivate link
await deactivatePaymentLink(invoice.stripe_payment_link_id);
// 4. QBO booking
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
status.details.paymentIntentId || '',
{ source: 'auto-polled' } // ← NEU: ein zusätzlicher Optionen-Parameter
);
} catch (qboErr) {
console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message);
}
}
await dbClient.query('COMMIT');
paidCount++;
console.log(` ✅ #${invoice.invoice_number}: $${amountReceived.toFixed(2)} via Stripe ${methodLabel} (Fee: $${stripeFee.toFixed(2)})`);
const retryTag = isRetry ? ' [RETRY]' : '';
console.log(` ✅ #${invoice.invoice_number}${retryTag}: $${amountReceived.toFixed(2)} via Stripe ${methodLabel} (Fee: $${stripeFee.toFixed(2)})`);
} catch (err) {
await dbClient.query('ROLLBACK').catch(() => {});