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

View File

@@ -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 = '<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>';
}
modal.innerHTML = `
<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">
@@ -47,9 +59,23 @@ function renderModalContent() {
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Melio Payment Link (Optional)</label>
<input type="url" id="email-melio-link" placeholder="https://melio.me/..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<label class="block text-sm font-medium text-gray-700 mb-1">
Stripe Payment Link${stripeBadgeHtml}
</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>
@@ -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 <strong>${dueDateStr}</strong>.`;
} else {
paymentText = 'which is due upon receipt.';
paymentText = 'Our terms are Net 30.';
}
// Der neue Standard-Text
const defaultHtml = `
<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>If you have any questions about the invoice, feel free to reply to this email.</p>
<p>Best regards,</p>
@@ -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 <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
// ============================================================
@@ -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
};

View File

@@ -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()),

View File

@@ -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'
? ' <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
? invoice.invoice_number
? invoice.invoice_number + stripeIndicator
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
// Status Badge (left side, next to invoice number)
let statusBadge = '';
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>`;
} 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) {
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) {
@@ -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 = `<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) {
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">
✔️ Mark Sent
</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">
📧 Send Email
</button>
`; }
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>`;
}
// if (hasQbo && !paid && !overdue) {
// 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">
// ✔️ Mark Sent
// </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">
// 📧 Send Email
// </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' : '';
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-900 font-semibold">${amountDisplay}</td>
<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>
</tr>`;
}
@@ -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
};