stripe
This commit is contained in:
@@ -41,6 +41,8 @@ services:
|
|||||||
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
- ./templates:/app/templates # NEU!
|
- ./templates:/app/templates # NEU!
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -17,7 +17,8 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"puppeteer": "^23.11.1"
|
"puppeteer": "^23.11.1",
|
||||||
|
"stripe": "^20.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
@@ -5716,6 +5717,23 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/strnum": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"puppeteer": "^23.11.1"
|
"puppeteer": "^23.11.1",
|
||||||
|
"stripe": "^20.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// email-modal.js — ES Module
|
// email-modal.js — ES Module
|
||||||
// Modal to review and send invoice emails via AWS SES
|
// Modal to review and send invoice emails via AWS SES
|
||||||
|
// With Stripe Payment Link integration
|
||||||
|
|
||||||
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||||
|
|
||||||
@@ -25,8 +26,19 @@ function renderModalContent() {
|
|||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
||||||
const defaultEmail = currentInvoice.email || '';
|
const defaultEmail = currentInvoice.email || '';
|
||||||
|
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 = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">Paid</span>';
|
||||||
|
} else if (existingStripeUrl && stripeStatus === 'processing') {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Processing</span>';
|
||||||
|
} else if (existingStripeUrl) {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">Active</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Editor-Container hat jetzt eine feste, kompaktere Höhe (h-48 = 12rem/192px) und scrollt bei viel Text
|
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-3xl mx-auto p-8">
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-3xl mx-auto p-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
@@ -47,9 +59,23 @@ function renderModalContent() {
|
|||||||
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Melio Payment Link (Optional)</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<input type="url" id="email-melio-link" placeholder="https://melio.me/..."
|
Stripe Payment Link${stripeBadgeHtml}
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="url" id="email-stripe-link" value="${existingStripeUrl}" readonly
|
||||||
|
placeholder="Click Generate to create link..."
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-600 focus:ring-purple-500 focus:border-purple-500">
|
||||||
|
<button type="button" id="stripe-generate-btn" onclick="window.emailModal.generateStripeLink()"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm font-semibold whitespace-nowrap">
|
||||||
|
${existingStripeUrl ? '♻️ Regenerate' : '💳 Generate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1" id="stripe-link-info">
|
||||||
|
${existingStripeUrl
|
||||||
|
? 'Link exists. Regenerate will create a new link for the current balance.'
|
||||||
|
: 'Generates a Stripe Payment Link for Card and ACH payments.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,20 +126,19 @@ function renderModalContent() {
|
|||||||
dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' });
|
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 = '';
|
let paymentText = '';
|
||||||
if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) {
|
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') {
|
} else if (dueDateStr !== 'Upon Receipt') {
|
||||||
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
||||||
} else {
|
} else {
|
||||||
paymentText = 'which is due upon receipt.';
|
paymentText = 'Our terms are Net 30.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Der neue Standard-Text
|
|
||||||
const defaultHtml = `
|
const defaultHtml = `
|
||||||
<p>Good afternoon,</p>
|
<p>Good afternoon,</p>
|
||||||
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>, ${paymentText}</p>
|
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>. ${paymentText}</p>
|
||||||
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
|
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
|
||||||
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
|
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
|
||||||
<p>Best regards,</p>
|
<p>Best regards,</p>
|
||||||
@@ -127,6 +152,55 @@ function renderModalContent() {
|
|||||||
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
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 <strong>$${result.amount.toFixed(2)}</strong>. 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
|
// Logic & API
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -145,7 +219,6 @@ export async function openEmailModal(invoiceId) {
|
|||||||
|
|
||||||
renderModalContent();
|
renderModalContent();
|
||||||
|
|
||||||
// Tailwind hidden toggle
|
|
||||||
document.getElementById('email-modal').classList.remove('hidden');
|
document.getElementById('email-modal').classList.remove('hidden');
|
||||||
document.getElementById('email-modal').classList.add('flex');
|
document.getElementById('email-modal').classList.add('flex');
|
||||||
|
|
||||||
@@ -171,7 +244,6 @@ async function submitEmail(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
||||||
const melioLink = document.getElementById('email-melio-link').value.trim();
|
|
||||||
const customText = quillInstance.root.innerHTML;
|
const customText = quillInstance.root.innerHTML;
|
||||||
|
|
||||||
if (!recipientEmail) {
|
if (!recipientEmail) {
|
||||||
@@ -191,7 +263,6 @@ async function submitEmail(e) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
melioLink,
|
|
||||||
customText
|
customText
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -201,7 +272,6 @@ async function submitEmail(e) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('✅ Invoice sent successfully!');
|
alert('✅ Invoice sent successfully!');
|
||||||
closeEmailModal();
|
closeEmailModal();
|
||||||
// Reload the invoice view so the "Sent" badge updates
|
|
||||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
} else {
|
} else {
|
||||||
alert(`❌ Error: ${result.error}`);
|
alert(`❌ Error: ${result.error}`);
|
||||||
@@ -221,5 +291,6 @@ async function submitEmail(e) {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
window.emailModal = {
|
window.emailModal = {
|
||||||
open: openEmailModal,
|
open: openEmailModal,
|
||||||
close: closeEmailModal
|
close: closeEmailModal,
|
||||||
|
generateStripeLink
|
||||||
};
|
};
|
||||||
@@ -86,6 +86,18 @@ const API = {
|
|||||||
}).then(r => r.json())
|
}).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 API
|
||||||
qbo: {
|
qbo: {
|
||||||
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
||||||
|
|||||||
@@ -184,14 +184,22 @@ function renderInvoiceRow(invoice) {
|
|||||||
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
||||||
const partial = isPartiallyPaid(invoice);
|
const partial = isPartiallyPaid(invoice);
|
||||||
|
|
||||||
|
const stripeIndicator = invoice.stripe_payment_link_id
|
||||||
|
? (invoice.stripe_payment_status === 'paid'
|
||||||
|
? ' <span title="Stripe payment received" class="text-purple-500 text-xs">💳✓</span>'
|
||||||
|
: ' <span title="Stripe payment link active" class="text-purple-400 text-xs">💳</span>')
|
||||||
|
: '';
|
||||||
|
|
||||||
const invNumDisplay = invoice.invoice_number
|
const invNumDisplay = invoice.invoice_number
|
||||||
? invoice.invoice_number
|
? invoice.invoice_number + stripeIndicator
|
||||||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||||
|
|
||||||
// Status Badge (left side, next to invoice number)
|
// Status Badge (left side, next to invoice number)
|
||||||
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) {
|
||||||
@@ -258,20 +266,29 @@ function renderInvoiceRow(invoice) {
|
|||||||
|
|
||||||
// Mark Sent button (right side) — only when open, not paid/partial
|
// Mark Sent button (right side) — only when open, not paid/partial
|
||||||
let sendBtn = '';
|
let sendBtn = '';
|
||||||
// if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
||||||
// sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||||||
// }
|
}
|
||||||
if (hasQbo && !paid && !overdue) {
|
// if (hasQbo && !paid && !overdue) {
|
||||||
sendBtn = `
|
// sendBtn = `
|
||||||
<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
|
// <button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
|
||||||
✔️ Mark Sent
|
// ✔️ Mark Sent
|
||||||
</button>
|
// </button>
|
||||||
<button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
|
// <button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
|
||||||
📧 Send Email
|
// 📧 Send Email
|
||||||
</button>
|
// </button>
|
||||||
`; }
|
// `; }
|
||||||
|
|
||||||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||||
|
|
||||||
|
const stripeEmailBtn = (!paid && hasQbo)
|
||||||
|
? `<button onclick="window.emailModal.open(${invoice.id})" title="Email with Stripe Payment Link" class="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 text-xs font-semibold">💳 Pay Link</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
|
||||||
|
? `<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' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -283,7 +300,7 @@ function renderInvoiceRow(invoice) {
|
|||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn}
|
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
@@ -518,12 +535,55 @@ export async function remove(id) {
|
|||||||
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
||||||
catch (e) { console.error(e); }
|
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
|
// Expose
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.invoiceView = {
|
window.invoiceView = {
|
||||||
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
||||||
loadInvoices, renderInvoiceView, setStatus
|
loadInvoices, renderInvoiceView, setStatus, checkStripePayment
|
||||||
};
|
};
|
||||||
@@ -13,6 +13,7 @@ const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, format
|
|||||||
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
||||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { sendInvoiceEmail } = require('../services/email-service');
|
const { sendInvoiceEmail } = require('../services/email-service');
|
||||||
|
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
||||||
|
|
||||||
function calculateNextRecurringDate(invoiceDate, interval) {
|
function calculateNextRecurringDate(invoiceDate, interval) {
|
||||||
const d = new Date(invoiceDate);
|
const d = new Date(invoiceDate);
|
||||||
@@ -819,7 +820,7 @@ router.get('/:id/html', async (req, res) => {
|
|||||||
});
|
});
|
||||||
router.post('/:id/send-email', async (req, res) => {
|
router.post('/:id/send-email', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { recipientEmail, customText, melioLink } = req.body;
|
const { recipientEmail, customText } = req.body;
|
||||||
|
|
||||||
if (!recipientEmail) {
|
if (!recipientEmail) {
|
||||||
return res.status(400).json({ error: 'Recipient email is required.' });
|
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);
|
const pdfBuffer = await generatePdfFromHtml(html);
|
||||||
|
|
||||||
// 3. E-Mail über SES versenden
|
// 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
|
// 4. (Optional) Status in der DB aktualisieren
|
||||||
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
|
//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 });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -14,14 +14,33 @@ const transporter = nodemailer.createTransport({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) {
|
||||||
const formattedText = customText || '';
|
const formattedText = customText || '';
|
||||||
|
|
||||||
const buttonMjml = melioLink
|
// Stripe Pay Button — only if payment link exists
|
||||||
? `<mj-button background-color="#2563eb" color="white" border-radius="6px" href="${melioLink}" font-weight="600" font-size="16px" padding-top="25px">
|
let paymentButtonMjml = '';
|
||||||
Pay Now (Free ACH)
|
if (stripePaymentUrl) {
|
||||||
</mj-button>`
|
paymentButtonMjml = `
|
||||||
: '';
|
<mj-section background-color="#ffffff" padding="0 30px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-button
|
||||||
|
background-color="#635bff"
|
||||||
|
color="white"
|
||||||
|
border-radius="6px"
|
||||||
|
href="${stripePaymentUrl}"
|
||||||
|
font-weight="600"
|
||||||
|
font-size="16px"
|
||||||
|
padding="25px 0 10px 0"
|
||||||
|
inner-padding="14px 30px"
|
||||||
|
width="100%">
|
||||||
|
Pay Online — Credit Card or ACH
|
||||||
|
</mj-button>
|
||||||
|
<mj-text font-size="12px" color="#94a3b8" align="center" padding="0 0 20px 0">
|
||||||
|
ACH payments incur lower processing fees. Secure payment powered by Stripe.
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>`;
|
||||||
|
}
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<mjml>
|
<mjml>
|
||||||
@@ -62,10 +81,14 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
|||||||
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
||||||
${formattedText}
|
${formattedText}
|
||||||
</mj-text>
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
${buttonMjml}
|
${paymentButtonMjml}
|
||||||
|
|
||||||
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="30px" padding-bottom="20px" />
|
<mj-section background-color="#ffffff" padding="0 30px 30px 30px" border-radius="0 0 8px 8px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="10px" padding-bottom="20px" />
|
||||||
|
|
||||||
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
||||||
<strong>Prefer to pay by check?</strong><br/>
|
<strong>Prefer to pay by check?</strong><br/>
|
||||||
@@ -79,7 +102,6 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
|||||||
</mjml>
|
</mjml>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// validationLevel: 'strict' fängt falsche Attribute ab, bevor sie an den Kunden gehen
|
|
||||||
const result = mjml2html(template, { validationLevel: 'strict' });
|
const result = mjml2html(template, { validationLevel: 'strict' });
|
||||||
|
|
||||||
if (result.errors && result.errors.length > 0) {
|
if (result.errors && result.errors.length > 0) {
|
||||||
@@ -89,8 +111,8 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
|||||||
return result.html;
|
return result.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer) {
|
async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) {
|
||||||
const htmlContent = generateInvoiceEmailHtml(invoice, customText, melioLink);
|
const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl);
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||||
|
|||||||
173
src/services/stripe-service.js
Normal file
173
src/services/stripe-service.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user