qbo fix
This commit is contained in:
1
migrations/add-qbo-payment-error.sql
Normal file
1
migrations/add-qbo-payment-error.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS qbo_payment_error TEXT;
|
||||||
@@ -346,8 +346,6 @@ function renderInvoiceRow(invoice) {
|
|||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
if (paid && invoice.payment_status === 'Deposited') {
|
if (paid && invoice.payment_status === 'Deposited') {
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||||||
} else if (paid && invoice.payment_status === 'Stripe') {
|
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800" title="Stripe payment ${formatDate(invoice.paid_date)}">Stripe</span>`;
|
|
||||||
} else if (paid) {
|
} else if (paid) {
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||||
} else if (partial) {
|
} else if (partial) {
|
||||||
@@ -366,6 +364,14 @@ function renderInvoiceRow(invoice) {
|
|||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QBO booking error indicator
|
||||||
|
if (invoice.qbo_payment_error) {
|
||||||
|
const errPreview = invoice.qbo_payment_error.length > 80
|
||||||
|
? invoice.qbo_payment_error.substring(0, 80) + '...'
|
||||||
|
: invoice.qbo_payment_error;
|
||||||
|
statusBadge += ` <span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-200 text-red-800 cursor-help" title="${invoice.qbo_payment_error}">QBO ⚠</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Send Date — show actual sent dates if available, otherwise scheduled
|
// Send Date — show actual sent dates if available, otherwise scheduled
|
||||||
let sendDateDisplay = '—';
|
let sendDateDisplay = '—';
|
||||||
const sentDates = invoice.sent_dates || [];
|
const sentDates = invoice.sent_dates || [];
|
||||||
@@ -450,9 +456,9 @@ function renderInvoiceRow(invoice) {
|
|||||||
</button>`
|
</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
|
const stripeCheckBtn = (invoice.stripe_payment_link_id && (!paid || invoice.qbo_payment_error))
|
||||||
? `<button onclick="window.invoiceView.checkStripePayment(${invoice.id})" title="Check Stripe Payment Status" class="px-2 py-1 bg-purple-50 text-purple-600 rounded hover:bg-purple-100 text-xs font-semibold">🔍 Check</button>`
|
? `<button onclick="window.invoiceView.checkStripePayment(${invoice.id})" title="Check Stripe Payment Status" class="px-2 py-1 bg-purple-50 text-purple-600 rounded hover:bg-purple-100 text-xs font-semibold">🔍 Check</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,51 @@ function saveTokens() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert ein QBO Fault-Objekt unabhängig vom Case (Fault/fault)
|
||||||
|
* und von der Error-Array-Struktur.
|
||||||
|
* @param {object} data - Geparste QBO JSON-Response
|
||||||
|
* @returns {object|null} { code, message, detail } oder null wenn kein Fault
|
||||||
|
*/
|
||||||
|
function extractQboFault(data) {
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
|
||||||
|
// Prüfe beide Case-Varianten: data.Fault und data.fault
|
||||||
|
for (const key of ['Fault', 'fault']) {
|
||||||
|
const fault = data[key];
|
||||||
|
if (!fault) continue;
|
||||||
|
|
||||||
|
// Error-Array (QBO Standard)
|
||||||
|
const errors = fault.Error || fault.error;
|
||||||
|
if (Array.isArray(errors) && errors.length > 0) {
|
||||||
|
const first = errors[0];
|
||||||
|
return {
|
||||||
|
code: first.code || first.Code || 'UNKNOWN',
|
||||||
|
message: first.Message || first.message || 'Unknown QBO error',
|
||||||
|
detail: first.Detail || first.detail || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelnes Error-Objekt
|
||||||
|
if (errors && (errors.code || errors.Code || errors.Message || errors.message)) {
|
||||||
|
return {
|
||||||
|
code: errors.code || errors.Code || 'UNKNOWN',
|
||||||
|
message: errors.Message || errors.message || 'Unknown QBO error',
|
||||||
|
detail: errors.Detail || errors.detail || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekannte Fault-Struktur
|
||||||
|
return {
|
||||||
|
code: 'UNKNOWN',
|
||||||
|
message: JSON.stringify(fault).substring(0, 500),
|
||||||
|
detail: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function makeQboApiCall(requestOptions) {
|
async function makeQboApiCall(requestOptions) {
|
||||||
const client = getOAuthClient();
|
const client = getOAuthClient();
|
||||||
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
|
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
|
||||||
@@ -145,14 +190,14 @@ async function makeQboApiCall(requestOptions) {
|
|||||||
const response = await client.makeApiCall(requestOptions);
|
const response = await client.makeApiCall(requestOptions);
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
if (data.fault && data.fault.error) {
|
const qboFault = extractQboFault(data);
|
||||||
const errorCode = data.fault.error[0].code;
|
if (qboFault) {
|
||||||
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
if (qboFault.code === '3200' || qboFault.code === '3202' || qboFault.code === '3100') {
|
||||||
console.log(`[${ts()}] ⚠️ QBO Token-Fehler (${errorCode}) – Refresh & Retry...`);
|
console.log(`[${ts()}] ⚠️ QBO Token-Fehler (${qboFault.code}) – Refresh & Retry...`);
|
||||||
await doRefresh();
|
await doRefresh();
|
||||||
return await client.makeApiCall(requestOptions);
|
return await client.makeApiCall(requestOptions);
|
||||||
}
|
}
|
||||||
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
throw new Error(`QBO API Error ${qboFault.code}: ${qboFault.message}${qboFault.detail ? ' - ' + qboFault.detail : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kein saveTokens() hier – Token hat sich nicht geändert ──
|
// ── Kein saveTokens() hier – Token hat sich nicht geändert ──
|
||||||
@@ -177,5 +222,6 @@ module.exports = {
|
|||||||
getOAuthClient,
|
getOAuthClient,
|
||||||
makeQboApiCall,
|
makeQboApiCall,
|
||||||
saveTokens,
|
saveTokens,
|
||||||
resetOAuthClient
|
resetOAuthClient,
|
||||||
|
extractQboFault
|
||||||
};
|
};
|
||||||
1673
session-ses_0d6e.md
Normal file
1673
session-ses_0d6e.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@ const {
|
|||||||
getOAuthClient: getClient,
|
getOAuthClient: getClient,
|
||||||
saveTokens,
|
saveTokens,
|
||||||
resetOAuthClient,
|
resetOAuthClient,
|
||||||
makeQboApiCall // <-- NEU: Direkt hier mit importieren
|
makeQboApiCall,
|
||||||
|
extractQboFault
|
||||||
} = require('../../qbo_helper');
|
} = require('../../qbo_helper');
|
||||||
|
|
||||||
function getOAuthClient() {
|
function getOAuthClient() {
|
||||||
@@ -23,5 +24,6 @@ module.exports = {
|
|||||||
getQboBaseUrl,
|
getQboBaseUrl,
|
||||||
saveTokens,
|
saveTokens,
|
||||||
resetOAuthClient,
|
resetOAuthClient,
|
||||||
makeQboApiCall // <-- NEU: Und sauber weiterreichen
|
makeQboApiCall,
|
||||||
|
extractQboFault
|
||||||
};
|
};
|
||||||
@@ -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.' });
|
return res.status(400).json({ error: 'No Stripe payment link exists for this invoice.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already fully processed?
|
// Already fully processed? (paid AND no pending QBO error)
|
||||||
if (invoice.stripe_payment_status === 'paid') {
|
if (invoice.stripe_payment_status === 'paid' && !invoice.qbo_payment_error) {
|
||||||
return res.json({
|
return res.json({
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
message: 'Stripe payment already recorded.',
|
message: 'Stripe payment already recorded.',
|
||||||
@@ -1187,6 +1187,40 @@ router.post('/:id/check-payment', async (req, res) => {
|
|||||||
const stripeFee = result.details.stripeFee;
|
const stripeFee = result.details.stripeFee;
|
||||||
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
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');
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
// 1. Record local payment (payment + payment_invoices)
|
// 1. Record local payment (payment + payment_invoices)
|
||||||
@@ -1209,31 +1243,21 @@ router.post('/:id/check-payment', async (req, res) => {
|
|||||||
[paymentId, id, amountReceived]
|
[paymentId, id, amountReceived]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Check if invoice is fully paid
|
// 2. Update invoice — mark as paid, clear any previous error
|
||||||
const newTotalPaid = invoice.amount_paid + amountReceived;
|
|
||||||
const invoiceTotal = parseFloat(invoice.total) || 0;
|
|
||||||
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01); // Cent-Toleranz
|
|
||||||
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
`UPDATE invoices SET
|
`UPDATE invoices SET
|
||||||
stripe_payment_status = 'paid',
|
stripe_payment_status = 'paid',
|
||||||
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
||||||
payment_status = $1,
|
payment_status = $1,
|
||||||
|
qbo_payment_error = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2`,
|
WHERE id = $2`,
|
||||||
[fullyPaid ? 'Stripe' : 'Partial', id]
|
[fullyPaid ? 'Paid' : 'Partial', id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Deactivate the payment link
|
// 3. Deactivate the payment link
|
||||||
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
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');
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);
|
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* QuickBooks Online Service
|
* QuickBooks Online Service
|
||||||
* Handles QBO API interactions
|
* Handles QBO API interactions
|
||||||
*/
|
*/
|
||||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall, extractQboFault } = require('../config/qbo');
|
||||||
|
|
||||||
// QBO Item IDs
|
// QBO Item IDs
|
||||||
const QBO_LABOR_ID = '5';
|
const QBO_LABOR_ID = '5';
|
||||||
@@ -265,11 +265,47 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
|
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
|
||||||
if (paymentData.Fault) {
|
|
||||||
const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; ');
|
const paymentFault = extractQboFault(paymentData);
|
||||||
throw new Error('QBO Payment failed: ' + errMsg);
|
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 ──
|
// ── 2. Create QBO Expense for Stripe Fee ──
|
||||||
// Only if explicitly enabled via env flag. We get the fee details from Stripe payout reports
|
// 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) {
|
if (stripeFee > 0 && !bookFee) {
|
||||||
console.log(`ℹ️ Stripe fee $${stripeFee.toFixed(2)} NOT booked in QBO (QBO_BOOK_STRIPE_FEES != 'true')`);
|
console.log(`ℹ️ Stripe fee $${stripeFee.toFixed(2)} NOT booked in QBO (QBO_BOOK_STRIPE_FEES != 'true')`);
|
||||||
return {
|
return {
|
||||||
paymentId: paymentData.Payment?.Id,
|
paymentId,
|
||||||
feeBooked: false,
|
feeBooked: false,
|
||||||
feeSkipped: true
|
feeSkipped: true
|
||||||
};
|
};
|
||||||
@@ -311,8 +347,9 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
|
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
|
||||||
if (expenseData.Fault) {
|
const expenseFault = extractQboFault(expenseData);
|
||||||
console.error('⚠️ QBO Expense booking failed:', JSON.stringify(expenseData.Fault));
|
if (expenseFault) {
|
||||||
|
console.error(`⚠️ QBO Expense booking failed: ${expenseFault.message}`);
|
||||||
// Don't throw — payment itself is valid
|
// Don't throw — payment itself is valid
|
||||||
} else {
|
} else {
|
||||||
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
|
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
|
||||||
@@ -320,7 +357,7 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentId: paymentData.Payment?.Id,
|
paymentId,
|
||||||
feeBooked: stripeFee > 0 && bookFee,
|
feeBooked: stripeFee > 0 && bookFee,
|
||||||
feeSkipped: false
|
feeSkipped: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ async function pollStripePayments() {
|
|||||||
|
|
||||||
const dbClient = await pool.connect();
|
const dbClient = await pool.connect();
|
||||||
try {
|
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(`
|
const result = await dbClient.query(`
|
||||||
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
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
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
WHERE i.stripe_payment_link_id IS NOT NULL
|
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.paid_date IS NULL
|
||||||
|
AND (
|
||||||
|
i.stripe_payment_status NOT IN ('paid')
|
||||||
|
OR i.qbo_payment_error IS NOT NULL
|
||||||
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const openInvoices = result.rows;
|
const openInvoices = result.rows;
|
||||||
@@ -49,6 +53,8 @@ async function pollStripePayments() {
|
|||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const invoice of openInvoices) {
|
for (const invoice of openInvoices) {
|
||||||
|
const isRetry = !!invoice.qbo_payment_error;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await checkPaymentStatus(invoice.stripe_payment_link_id);
|
const status = await checkPaymentStatus(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
@@ -66,7 +72,17 @@ async function pollStripePayments() {
|
|||||||
continue;
|
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 ===
|
// === PAID — process it ===
|
||||||
const amountReceived = status.details.amountReceived;
|
const amountReceived = status.details.amountReceived;
|
||||||
@@ -75,7 +91,35 @@ async function pollStripePayments() {
|
|||||||
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
||||||
|
|
||||||
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
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');
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
// 1. Record local payment
|
// 1. Record local payment
|
||||||
@@ -97,40 +141,26 @@ async function pollStripePayments() {
|
|||||||
[payResult.rows[0].id, invoice.id, amountReceived]
|
[payResult.rows[0].id, invoice.id, amountReceived]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Check if fully paid
|
// 2. Update invoice — mark as paid, clear any previous error
|
||||||
const newTotalPaid = invoice.amount_paid + amountReceived;
|
|
||||||
const invoiceTotal = parseFloat(invoice.total) || 0;
|
|
||||||
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
|
|
||||||
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
`UPDATE invoices SET
|
`UPDATE invoices SET
|
||||||
stripe_payment_status = 'paid',
|
stripe_payment_status = 'paid',
|
||||||
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
||||||
payment_status = $1,
|
payment_status = $1,
|
||||||
|
qbo_payment_error = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2`,
|
WHERE id = $2`,
|
||||||
[fullyPaid ? 'Stripe' : 'Partial', invoice.id]
|
[fullyPaid ? 'Paid' : 'Partial', invoice.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Deactivate link
|
// 3. Deactivate link
|
||||||
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
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');
|
await dbClient.query('COMMIT');
|
||||||
paidCount++;
|
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) {
|
} catch (err) {
|
||||||
await dbClient.query('ROLLBACK').catch(() => {});
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
|||||||
Reference in New Issue
Block a user