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