This commit is contained in:
2026-03-19 16:28:37 -05:00
parent 5a7ba66c27
commit 229e658831
9 changed files with 687 additions and 46 deletions

View File

@@ -13,6 +13,7 @@ const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, format
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
const { sendInvoiceEmail } = require('../services/email-service');
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
function calculateNextRecurringDate(invoiceDate, interval) {
const d = new Date(invoiceDate);
@@ -819,7 +820,7 @@ router.get('/:id/html', async (req, res) => {
});
router.post('/:id/send-email', async (req, res) => {
const { id } = req.params;
const { recipientEmail, customText, melioLink } = req.body;
const { recipientEmail, customText } = req.body;
if (!recipientEmail) {
return res.status(400).json({ error: 'Recipient email is required.' });
@@ -866,7 +867,8 @@ router.post('/:id/send-email', async (req, res) => {
const pdfBuffer = await generatePdfFromHtml(html);
// 3. E-Mail über SES versenden
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer);
const stripeLink = invoice.stripe_payment_link_url || null;
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer);
// 4. (Optional) Status in der DB aktualisieren
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
@@ -878,4 +880,284 @@ router.post('/:id/send-email', async (req, res) => {
res.status(500).json({ error: 'Failed to send email: ' + error.message });
}
});
// POST create Stripe Payment Link
router.post('/:id/create-payment-link', async (req, res) => {
const { id } = req.params;
try {
// Load invoice with balance
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name,
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.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
if (invoice.balance <= 0) {
return res.status(400).json({ error: 'Invoice has no balance due.' });
}
// Deactivate existing payment link if present
if (invoice.stripe_payment_link_id) {
await deactivatePaymentLink(invoice.stripe_payment_link_id);
console.log(`♻️ Old payment link deactivated for Invoice #${invoice.invoice_number}`);
}
// Create new payment link
const { paymentLinkId, paymentLinkUrl } = await createPaymentLink(invoice);
// Save to DB
await pool.query(
`UPDATE invoices
SET stripe_payment_link_id = $1,
stripe_payment_link_url = $2,
stripe_payment_status = 'pending',
updated_at = CURRENT_TIMESTAMP
WHERE id = $3`,
[paymentLinkId, paymentLinkUrl, id]
);
res.json({
success: true,
paymentLinkId,
paymentLinkUrl,
amount: invoice.balance
});
} catch (error) {
console.error('Stripe Payment Link Error:', error);
res.status(500).json({ error: 'Failed to create payment link: ' + error.message });
}
});
// POST check Stripe payment status
// POST check Stripe payment status (with QBO payment + fee booking)
router.post('/:id/check-payment', async (req, res) => {
const { id } = req.params;
const dbClient = await pool.connect();
try {
const invoiceResult = 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.id = $1
`, [id]);
if (invoiceResult.rows.length === 0) {
return res.status(404).json({ error: 'Invoice not found' });
}
const invoice = invoiceResult.rows[0];
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
if (!invoice.stripe_payment_link_id) {
return res.status(400).json({ error: 'No Stripe payment link exists for this invoice.' });
}
// Already fully processed?
if (invoice.stripe_payment_status === 'paid') {
return res.json({
status: 'paid',
message: 'Stripe payment already recorded.',
alreadyProcessed: true
});
}
const result = await checkPaymentStatus(invoice.stripe_payment_link_id);
// Update stripe_payment_status in DB regardless
if (result.status !== invoice.stripe_payment_status) {
await dbClient.query(
'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[result.status, id]
);
}
// If not paid yet, return current status
if (!result.paid) {
return res.json({
status: result.status,
paid: false,
details: result.details,
message: result.status === 'processing'
? 'ACH payment is processing (3-5 business days).'
: 'No payment received yet.'
});
}
// === PAID — full processing ===
const amountReceived = result.details.amountReceived;
const paymentMethod = result.details.paymentMethod;
const stripeFee = result.details.stripeFee;
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
await dbClient.query('BEGIN');
// 1. Record local payment (payment + payment_invoices)
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at)
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING id`,
[
`Stripe ${methodLabel}`,
amountReceived,
invoice.customer_id,
result.details.paymentIntentId || result.details.sessionId,
`Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)}`
]
);
const paymentId = payResult.rows[0].id;
await dbClient.query(
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
[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
await dbClient.query(
`UPDATE invoices SET
stripe_payment_status = 'paid',
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
payment_status = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[fullyPaid ? 'Stripe' : 'Partial', id]
);
// 3. Deactivate the payment link
await deactivatePaymentLink(invoice.stripe_payment_link_id);
// 4. QBO: Record Payment + Expense (if QBO-linked)
let qboResult = null;
if (invoice.qbo_id && invoice.customer_qbo_id) {
try {
qboResult = await recordStripePaymentInQbo(
invoice, amountReceived, methodLabel, stripeFee,
result.details.paymentIntentId || ''
);
} catch (qboErr) {
console.error(`⚠️ QBO booking failed for Invoice #${invoice.invoice_number}:`, qboErr.message);
qboResult = { error: qboErr.message };
}
}
await dbClient.query('COMMIT');
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);
res.json({
status: 'paid',
paid: true,
fullyPaid,
details: result.details,
qbo: qboResult,
message: `Payment received: $${amountReceived.toFixed(2)} via Stripe ${methodLabel}. Fee: $${stripeFee.toFixed(2)}.`
});
} catch (error) {
await dbClient.query('ROLLBACK').catch(() => {});
console.error('Stripe Check Payment Error:', error);
res.status(500).json({ error: 'Failed to check payment: ' + error.message });
} finally {
dbClient.release();
}
});
/**
* Record Stripe payment in QBO: Payment on Invoice + Expense for Stripe Fee.
*/
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
const oauthClient = getOAuthClient();
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// --- 1. Create QBO Payment ---
const paymentPayload = {
CustomerRef: { value: invoice.customer_qbo_id },
TotalAmt: amount,
TxnDate: new Date().toISOString().split('T')[0],
PaymentRefNum: reference ? reference.substring(0, 21) : 'Stripe',
PrivateNote: `Stripe ${methodLabel} — processed via Payment Link`,
Line: [{
Amount: amount,
LinkedTxn: [{
TxnId: invoice.qbo_id,
TxnType: 'Invoice'
}]
}],
// Deposit to Undeposited Funds (Stripe will payout to bank later)
DepositToAccountRef: { value: '221' } // Undeposited Funds
};
console.log(`📤 QBO: Recording Stripe payment $${amount.toFixed(2)} for Invoice #${invoice.invoice_number}...`);
const paymentRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(paymentPayload)
});
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);
}
console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`);
// --- 2. Create QBO Expense for Stripe Fee ---
if (stripeFee > 0) {
const expensePayload = {
AccountRef: { value: '244', name: 'PlainsCapital Bank' }, // Checking
TxnDate: new Date().toISOString().split('T')[0],
PaymentType: 'Check',
PrivateNote: `Stripe processing fee for Invoice #${invoice.invoice_number} (${methodLabel})`,
Line: [{
DetailType: 'AccountBasedExpenseLineDetail',
Amount: stripeFee,
AccountBasedExpenseLineDetail: {
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
},
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
}]
};
console.log(`📤 QBO: Booking Stripe fee $${stripeFee.toFixed(2)}...`);
const expenseRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/purchase`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(expensePayload)
});
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
if (expenseData.Fault) {
console.error('⚠️ QBO Expense booking failed:', JSON.stringify(expenseData.Fault));
// Don't throw — payment is still valid even if fee booking fails
} else {
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
}
}
return {
paymentId: paymentData.Payment?.Id,
feeBooked: stripeFee > 0
};
}
module.exports = router;