stripe
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user