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.
-
Melio Payment Link (Optional)
-
+
+ Stripe Payment Link${stripeBadgeHtml}
+
+
+
+
+ ${existingStripeUrl ? '♻️ Regenerate' : '💳 Generate'}
+
+
+
+ ${existingStripeUrl
+ ? 'Link exists. Regenerate will create a new link for the current balance.'
+ : 'Generates a Stripe Payment Link for Card and ACH payments.'}
+
@@ -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 = `📤 Mark Sent `;
- // }
- if (hasQbo && !paid && !overdue) {
- sendBtn = `
-
- ✔️ Mark Sent
-
-
- 📧 Send Email
-
- `; }
+ if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
+ sendBtn = `📤 Mark Sent `;
+ }
+ // if (hasQbo && !paid && !overdue) {
+ // sendBtn = `
+ //
+ // ✔️ Mark Sent
+ //
+ //
+ // 📧 Send Email
+ //
+ // `; }
+
const delBtn = `Del `;
+ const stripeEmailBtn = (!paid && hasQbo)
+ ? `💳 Pay Link `
+ : '';
+
+ const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
+ ? `🔍 Check `
+ : '';
+
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