diff --git a/docker-compose.yml b/docker-compose.yml index 5bd4586..6b55891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} volumes: - ./public/uploads:/app/public/uploads - ./templates:/app/templates # NEU! diff --git a/package-lock.json b/package-lock.json index 3de5ee9..3ca7a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.2", "pg": "^8.13.1", - "puppeteer": "^23.11.1" + "puppeteer": "^23.11.1", + "stripe": "^20.4.1" }, "devDependencies": { "nodemon": "^3.0.2" @@ -5716,6 +5717,23 @@ "node": ">=8" } }, + "node_modules/stripe": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", diff --git a/package.json b/package.json index a580222..c30bdb7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^8.0.2", "pg": "^8.13.1", - "puppeteer": "^23.11.1" + "puppeteer": "^23.11.1", + "stripe": "^20.4.1" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/public/js/modals/email-modal.js b/public/js/modals/email-modal.js index f297fd4..a019633 100644 --- a/public/js/modals/email-modal.js +++ b/public/js/modals/email-modal.js @@ -1,5 +1,6 @@ // email-modal.js — ES Module // Modal to review and send invoice emails via AWS SES +// With Stripe Payment Link integration import { showSpinner, hideSpinner } from '../utils/helpers.js'; @@ -25,8 +26,19 @@ function renderModalContent() { if (!modal) return; const defaultEmail = currentInvoice.email || ''; - - // Editor-Container hat jetzt eine feste, kompaktere Höhe (h-48 = 12rem/192px) und scrollt bei viel Text + const existingStripeUrl = currentInvoice.stripe_payment_link_url || ''; + const stripeStatus = currentInvoice.stripe_payment_status || ''; + + // Status indicator for existing link + let stripeBadgeHtml = ''; + if (existingStripeUrl && stripeStatus === 'paid') { + stripeBadgeHtml = 'Paid'; + } else if (existingStripeUrl && stripeStatus === 'processing') { + stripeBadgeHtml = 'Processing'; + } else if (existingStripeUrl) { + stripeBadgeHtml = 'Active'; + } + modal.innerHTML = `
@@ -47,9 +59,23 @@ function renderModalContent() {

You can override this for testing.

- - + +
+ + +
+
@@ -100,20 +126,19 @@ function renderModalContent() { dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' }); } - // Dynamischer Text für die Fälligkeit (Löst das "payable by Upon Receipt" Problem) + // Dynamischer Text für die Fälligkeit let paymentText = ''; if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) { - paymentText = 'which is due upon receipt.'; + paymentText = 'Our terms are Net 30.'; } else if (dueDateStr !== 'Upon Receipt') { paymentText = `payable by ${dueDateStr}.`; } else { - paymentText = 'which is due upon receipt.'; + paymentText = 'Our terms are Net 30.'; } - // Der neue Standard-Text const defaultHtml = `

Good afternoon,

-

Attached is invoice #${invoiceNum} for service performed at your location. The total amount due is $${totalDue}, ${paymentText}

+

Attached is invoice #${invoiceNum} for service performed at your location. The total amount due is $${totalDue}. ${paymentText}

Please pay at your earliest convenience. We appreciate your continued business.

If you have any questions about the invoice, feel free to reply to this email.

Best regards,

@@ -127,6 +152,55 @@ function renderModalContent() { document.getElementById('email-send-form').addEventListener('submit', submitEmail); } +// ============================================================ +// Stripe Payment Link Generation +// ============================================================ + +async function generateStripeLink() { + const btn = document.getElementById('stripe-generate-btn'); + const input = document.getElementById('email-stripe-link'); + const info = document.getElementById('stripe-link-info'); + + const originalBtnText = btn.innerHTML; + btn.innerHTML = '⏳ Creating...'; + btn.disabled = true; + + try { + const response = await fetch(`/api/invoices/${currentInvoice.id}/create-payment-link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + + if (response.ok) { + input.value = result.paymentLinkUrl; + currentInvoice.stripe_payment_link_url = result.paymentLinkUrl; + currentInvoice.stripe_payment_link_id = result.paymentLinkId; + currentInvoice.stripe_payment_status = 'pending'; + + btn.innerHTML = '♻️ Regenerate'; + info.innerHTML = `✅ Payment link created for $${result.amount.toFixed(2)}. Will be included in the email.`; + info.classList.remove('text-gray-400'); + info.classList.add('text-green-600'); + } else { + info.textContent = `❌ ${result.error}`; + info.classList.remove('text-gray-400'); + info.classList.add('text-red-500'); + } + } catch (e) { + console.error('Stripe link generation error:', e); + info.textContent = '❌ Network error creating payment link.'; + info.classList.remove('text-gray-400'); + info.classList.add('text-red-500'); + } finally { + btn.disabled = false; + if (btn.innerHTML === '⏳ Creating...') { + btn.innerHTML = originalBtnText; + } + } +} + // ============================================================ // Logic & API // ============================================================ @@ -145,7 +219,6 @@ export async function openEmailModal(invoiceId) { renderModalContent(); - // Tailwind hidden toggle document.getElementById('email-modal').classList.remove('hidden'); document.getElementById('email-modal').classList.add('flex'); @@ -171,7 +244,6 @@ async function submitEmail(e) { e.preventDefault(); const recipientEmail = document.getElementById('email-recipient').value.trim(); - const melioLink = document.getElementById('email-melio-link').value.trim(); const customText = quillInstance.root.innerHTML; if (!recipientEmail) { @@ -191,7 +263,6 @@ async function submitEmail(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipientEmail, - melioLink, customText }) }); @@ -201,7 +272,6 @@ async function submitEmail(e) { if (response.ok) { alert('✅ Invoice sent successfully!'); closeEmailModal(); - // Reload the invoice view so the "Sent" badge updates if (window.invoiceView) window.invoiceView.loadInvoices(); } else { alert(`❌ Error: ${result.error}`); @@ -221,5 +291,6 @@ async function submitEmail(e) { // ============================================================ window.emailModal = { open: openEmailModal, - close: closeEmailModal + close: closeEmailModal, + generateStripeLink }; \ No newline at end of file diff --git a/public/js/utils/api.js b/public/js/utils/api.js index d974557..42ffc39 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -86,6 +86,18 @@ const API = { }).then(r => r.json()) }, + // NEU: Stripe API + stripe: { + createPaymentLink: (invoiceId) => fetch(`/api/invoices/${invoiceId}/create-payment-link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }).then(r => r.json()), + checkPayment: (invoiceId) => fetch(`/api/invoices/${invoiceId}/check-payment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }).then(r => r.json()) + }, + // QBO API qbo: { getStatus: () => fetch('/api/qbo/status').then(r => r.json()), diff --git a/public/js/views/invoice-view.js b/public/js/views/invoice-view.js index 51e3358..8045111 100644 --- a/public/js/views/invoice-view.js +++ b/public/js/views/invoice-view.js @@ -184,14 +184,22 @@ function renderInvoiceRow(invoice) { const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid); const partial = isPartiallyPaid(invoice); + const stripeIndicator = invoice.stripe_payment_link_id + ? (invoice.stripe_payment_status === 'paid' + ? ' 💳✓' + : ' 💳') + : ''; + const invNumDisplay = invoice.invoice_number - ? invoice.invoice_number + ? invoice.invoice_number + stripeIndicator : `Draft`; // Status Badge (left side, next to invoice number) let statusBadge = ''; if (paid && invoice.payment_status === 'Deposited') { statusBadge = `Deposited`; + } else if (paid && invoice.payment_status === 'Stripe') { + statusBadge = `Stripe`; } else if (paid) { statusBadge = `Paid`; } else if (partial) { @@ -258,20 +266,29 @@ function renderInvoiceRow(invoice) { // Mark Sent button (right side) — only when open, not paid/partial let sendBtn = ''; - // if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') { - // sendBtn = ``; - // } - if (hasQbo && !paid && !overdue) { - sendBtn = ` - - - `; } + if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') { + sendBtn = ``; + } + // if (hasQbo && !paid && !overdue) { + // sendBtn = ` + // + // + // `; } + const delBtn = ``; + const stripeEmailBtn = (!paid && hasQbo) + ? `` + : ''; + + const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid) + ? `` + : ''; + 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' : ''; return ` @@ -283,7 +300,7 @@ function renderInvoiceRow(invoice) { ${invoice.terms} ${amountDisplay} - ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn} + ${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn} `; } @@ -518,12 +535,55 @@ export async function remove(id) { try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); } catch (e) { console.error(e); } } +async function checkStripePayment(invoiceId) { + if (typeof showSpinner === 'function') showSpinner('Checking Stripe payment status...'); + try { + const response = await fetch(`/api/invoices/${invoiceId}/check-payment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + + if (response.ok) { + if (result.paid) { + let msg = `✅ ${result.message}`; + if (result.qbo) { + if (result.qbo.error) { + msg += `\n\n⚠️ QBO booking failed: ${result.qbo.error}`; + } else { + msg += `\n\n📗 QBO Payment recorded (ID: ${result.qbo.paymentId})`; + if (result.qbo.feeBooked) msg += '\n📗 Processing fee booked'; + } + } + if (!result.fullyPaid) { + msg += '\n\n⚠️ Partial payment — invoice is not fully paid yet.'; + } + alert(msg); + loadInvoices(); // Refresh the list + } else if (result.alreadyProcessed) { + alert('ℹ️ Stripe payment was already recorded for this invoice.'); + } else if (result.status === 'processing') { + alert('⏳ ACH payment is processing (typically 3-5 business days).\n\nCheck again later.'); + } else { + alert('ℹ️ No payment received yet.\n\nThe customer may not have clicked the payment link, or the payment is still being processed.'); + } + } else { + alert(`❌ Error: ${result.error}`); + } + } catch (e) { + console.error('Check Stripe payment error:', e); + alert('Network error checking payment status.'); + } finally { + if (typeof hideSpinner === 'function') hideSpinner(); + } +} // ============================================================ // Expose // ============================================================ window.invoiceView = { viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove, - loadInvoices, renderInvoiceView, setStatus + loadInvoices, renderInvoiceView, setStatus, checkStripePayment }; \ No newline at end of file diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 08087b4..e490856 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -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; diff --git a/src/services/email-service.js b/src/services/email-service.js index 29d1fe3..368c5a1 100644 --- a/src/services/email-service.js +++ b/src/services/email-service.js @@ -14,14 +14,33 @@ const transporter = nodemailer.createTransport({ } }); -function generateInvoiceEmailHtml(invoice, customText, melioLink) { +function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) { const formattedText = customText || ''; - const buttonMjml = melioLink - ? ` - Pay Now (Free ACH) - ` - : ''; + // Stripe Pay Button — only if payment link exists + let paymentButtonMjml = ''; + if (stripePaymentUrl) { + paymentButtonMjml = ` + + + + Pay Online — Credit Card or ACH + + + ACH payments incur lower processing fees. Secure payment powered by Stripe. + + + `; + } const template = ` @@ -62,10 +81,14 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) { ${formattedText} - - ${buttonMjml} + + - + ${paymentButtonMjml} + + + + Prefer to pay by check?
@@ -79,7 +102,6 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
`; - // validationLevel: 'strict' fängt falsche Attribute ab, bevor sie an den Kunden gehen const result = mjml2html(template, { validationLevel: 'strict' }); if (result.errors && result.errors.length > 0) { @@ -89,8 +111,8 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) { return result.html; } -async function sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer) { - const htmlContent = generateInvoiceEmailHtml(invoice, customText, melioLink); +async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) { + const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl); const mailOptions = { from: '"Bay Area Affiliates Inc. Accounting" ', diff --git a/src/services/stripe-service.js b/src/services/stripe-service.js new file mode 100644 index 0000000..82ddb18 --- /dev/null +++ b/src/services/stripe-service.js @@ -0,0 +1,173 @@ +// src/services/stripe-service.js +/** + * Stripe Payment Links Service + * Creates payment links for invoices, checks payment status via API polling. + * + * No webhooks needed — the app is not internet-facing. + * Status is checked on-demand via checkPaymentStatus(). + */ + +const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); + +/** + * Create a Stripe Payment Link for an invoice. + * + * @param {object} invoice - Invoice record from DB + * @param {number} invoice.id + * @param {string} invoice.invoice_number + * @param {number} invoice.total - Total in dollars (e.g. 194.85) + * @param {number} invoice.balance - Remaining balance (total - amount_paid) + * @param {string} [invoice.customer_name] + * @returns {object} { paymentLinkId, paymentLinkUrl } + */ +async function createPaymentLink(invoice) { + const amountDue = parseFloat(invoice.balance ?? invoice.total); + if (!amountDue || amountDue <= 0) { + throw new Error('Invoice has no balance due.'); + } + + const unitAmount = Math.round(amountDue * 100); // Convert dollars to cents + const invoiceLabel = `Invoice #${invoice.invoice_number || invoice.id}`; + + console.log(`💳 Creating Stripe Payment Link for ${invoiceLabel} — $${amountDue.toFixed(2)}...`); + + const paymentLink = await stripe.paymentLinks.create({ + line_items: [{ + price_data: { + currency: 'usd', + product_data: { + name: invoiceLabel, + description: invoice.customer_name + ? `Bay Area Affiliates, Inc. — ${invoice.customer_name}` + : 'Bay Area Affiliates, Inc.' + }, + unit_amount: unitAmount, + }, + quantity: 1, + }], + metadata: { + invoice_id: String(invoice.id), + invoice_number: String(invoice.invoice_number || ''), + source: 'quote-invoice-system' + }, + payment_method_types: ['card', 'us_bank_account'], + // After payment, show a simple confirmation + after_completion: { + type: 'hosted_confirmation', + hosted_confirmation: { + custom_message: `Thank you! Your payment for ${invoiceLabel} has been received. Bay Area Affiliates, Inc. will send a confirmation.` + } + } + }); + + console.log(`✅ Stripe Payment Link created: ${paymentLink.url}`); + + return { + paymentLinkId: paymentLink.id, + paymentLinkUrl: paymentLink.url + }; +} + +/** + * Check payment status for a Stripe Payment Link. + * Polls completed Checkout Sessions associated with the payment link. + * + * @param {string} paymentLinkId - Stripe Payment Link ID (plink_xxx) + * @returns {object} { paid, status, details } + */ +async function checkPaymentStatus(paymentLinkId) { + if (!paymentLinkId) { + return { paid: false, status: 'no_link', details: null }; + } + + console.log(`🔍 Checking Stripe payment status for ${paymentLinkId}...`); + + // List checkout sessions created via this payment link + const sessions = await stripe.checkout.sessions.list({ + payment_link: paymentLinkId, + limit: 10, + expand: ['data.payment_intent'] + }); + + // Find a completed/paid session + const paidSession = sessions.data.find(s => s.payment_status === 'paid'); + + if (paidSession) { + const pi = paidSession.payment_intent; + const paymentMethod = pi?.payment_method_types?.[0] || 'unknown'; + const amountReceived = (pi?.amount_received || 0) / 100; + const stripeFee = calculateStripeFee(amountReceived, paymentMethod); + + console.log(`✅ Payment found! $${amountReceived.toFixed(2)} via ${paymentMethod}`); + + return { + paid: true, + status: 'paid', + details: { + sessionId: paidSession.id, + paymentIntentId: pi?.id, + amountReceived, + paymentMethod, // 'card' or 'us_bank_account' + customerEmail: paidSession.customer_details?.email, + paidAt: new Date(paidSession.created * 1000).toISOString(), + stripeFee + } + }; + } + + // Check for pending ACH payments (processing state) + const pendingSession = sessions.data.find(s => s.payment_status === 'unpaid' && s.status === 'complete'); + if (pendingSession) { + return { + paid: false, + status: 'processing', + details: { note: 'ACH payment is processing (may take 3-5 business days).' } + }; + } + + return { + paid: false, + status: sessions.data.length > 0 ? 'attempted' : 'pending', + details: null + }; +} + +/** + * Deactivate a payment link (e.g. when invoice is voided or amount changes). + * + * @param {string} paymentLinkId + */ +async function deactivatePaymentLink(paymentLinkId) { + if (!paymentLinkId) return; + + try { + await stripe.paymentLinks.update(paymentLinkId, { active: false }); + console.log(`🚫 Stripe Payment Link ${paymentLinkId} deactivated.`); + } catch (e) { + console.error(`⚠️ Could not deactivate payment link ${paymentLinkId}:`, e.message); + } +} + +/** + * Calculate estimated Stripe fee for reference/QBO booking. + * Card: 2.9% + $0.30 + * ACH: 0.8%, capped at $5.00 + * + * @param {number} amount - Amount in dollars + * @param {string} method - 'card' or 'us_bank_account' + * @returns {number} Estimated fee in dollars + */ +function calculateStripeFee(amount, method) { + if (method === 'us_bank_account') { + return Math.min(amount * 0.008, 5.00); + } + // Default: card + return (amount * 0.029) + 0.30; +} + +module.exports = { + createPaymentLink, + checkPaymentStatus, + deactivatePaymentLink, + calculateStripeFee +}; \ No newline at end of file