Files
invoice-system/public/js/views/invoice-view.js
2026-03-19 16:28:37 -05:00

589 lines
29 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// invoice-view.js — ES Module v5
// Sync from QBO, Paid/Deposited/Partial badges, no Unpaid button
let invoices = [];
let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = localStorage.getItem('inv_groupBy') || 'none';
const OVERDUE_DAYS = 30;
// ============================================================
// Date Helpers
// ============================================================
function parseLocalDate(dateStr) {
if (!dateStr) return null;
const str = String(dateStr).split('T')[0];
const parts = str.split('-');
if (parts.length !== 3) return null;
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
}
function formatDate(date) {
if (!date) return '—';
const d = parseLocalDate(date);
if (!d) return '—';
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
}
function formatDateTime(isoStr) {
if (!isoStr) return 'Never';
const d = new Date(isoStr);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) +
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
function daysSince(date) {
const d = parseLocalDate(date);
if (!d) return 0;
const now = new Date(); now.setHours(0, 0, 0, 0);
return Math.floor((now - d) / 86400000);
}
function getWeekNumber(date) {
const d = parseLocalDate(date);
if (!d) return { year: 0, week: 0 };
const copy = new Date(d.getTime());
copy.setHours(0, 0, 0, 0);
copy.setDate(copy.getDate() + 3 - ((copy.getDay() + 6) % 7));
const week1 = new Date(copy.getFullYear(), 0, 4);
return {
year: copy.getFullYear(),
week: 1 + Math.round(((copy - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
};
}
function getWeekRange(year, weekNum) {
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7;
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const fmt = (d) => `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
return { start: fmt(monday), end: fmt(sunday) };
}
function getMonthName(i) {
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
}
function isPaid(inv) { return !!inv.paid_date; }
function isDraft(inv) { return !inv.qbo_id; }
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
function isPartiallyPaid(inv) {
const amountPaid = parseFloat(inv.amount_paid) || 0;
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
return !inv.paid_date && amountPaid > 0 && balance > 0;
}
function isSent(inv) {
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent';
}
function isOpen(inv) {
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent';
}
function saveSettings() {
localStorage.setItem('inv_filterStatus', filterStatus);
localStorage.setItem('inv_groupBy', groupBy);
localStorage.setItem('inv_filterCustomer', filterCustomer);
}
// ============================================================
// Data
// ============================================================
export async function loadInvoices() {
try {
const response = await fetch('/api/invoices');
invoices = await response.json();
renderInvoiceView();
loadLastSync();
} catch (error) { console.error('Error loading invoices:', error); }
}
async function loadLastSync() {
try {
const res = await fetch('/api/qbo/last-sync');
const data = await res.json();
const el = document.getElementById('last-sync-time');
if (el) el.textContent = data.last_sync ? `Last synced: ${formatDateTime(data.last_sync)}` : 'Never synced';
} catch (e) { /* ignore */ }
}
export function getInvoicesData() { return invoices; }
// ============================================================
// Filter / Sort / Group
// ============================================================
function getFilteredInvoices() {
let f = [...invoices];
if (filterStatus === 'unpaid') f = f.filter(i => !isPaid(i));
else if (filterStatus === 'paid') f = f.filter(i => isPaid(i));
else if (filterStatus === 'overdue') f = f.filter(i => isOverdue(i));
else if (filterStatus === 'partial') f = f.filter(i => isPartiallyPaid(i));
else if (filterStatus === 'sent') f = f.filter(i => isSent(i));
else if (filterStatus === 'open') f = f.filter(i => isOpen(i));
if (filterCustomer.trim()) {
const s = filterCustomer.toLowerCase();
f = f.filter(i => (i.customer_name || '').toLowerCase().includes(s));
}
f.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
return f;
}
// Effective amount: for unpaid/partial show balance, for paid show total
function effectiveAmount(inv) {
const total = parseFloat(inv.total) || 0;
const amountPaid = parseFloat(inv.amount_paid) || 0;
if (inv.paid_date) return total; // Paid → show full total
if (amountPaid > 0) return total - amountPaid; // Partial → show balance
return total; // Unpaid → show total
}
function groupInvoices(filtered) {
if (groupBy === 'none') return null;
const groups = new Map();
filtered.forEach(inv => {
const d = parseLocalDate(inv.invoice_date);
if (!d) return;
let key, label;
if (groupBy === 'week') {
const wk = getWeekNumber(inv.invoice_date);
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
const range = getWeekRange(wk.year, wk.week);
label = `Week ${wk.week}, ${wk.year} (${range.start} ${range.end})`;
} else {
key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`;
label = `${getMonthName(d.getMonth())} ${d.getFullYear()}`;
}
if (!groups.has(key)) groups.set(key, { label, invoices: [], total: 0 });
const g = groups.get(key);
g.invoices.push(inv);
g.total += effectiveAmount(inv);
});
for (const g of groups.values()) {
g.invoices.sort((a, b) => (parseLocalDate(b.invoice_date) || 0) - (parseLocalDate(a.invoice_date) || 0));
}
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
}
// ============================================================
// Render
// ============================================================
function renderInvoiceRow(invoice) {
const hasQbo = !!invoice.qbo_id;
const paid = isPaid(invoice);
const overdue = isOverdue(invoice);
const draft = isDraft(invoice);
const amountPaid = parseFloat(invoice.amount_paid) || 0;
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 + 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) {
// Partial: show delivery status badge + Partial badge
if (hasQbo && invoice.email_status === 'sent') {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span> `;
} else if (hasQbo) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span> `;
}
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
} else if (overdue) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
} else if (hasQbo && invoice.email_status === 'sent') {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
} else if (hasQbo) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span>`;
}
// Send Date
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = parseLocalDate(invoice.scheduled_send_date);
const today = new Date(); today.setHours(0, 0, 0, 0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date);
if (!paid && invoice.email_status !== 'sent') {
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
}
}
// Amount column — show balance when partially paid
let amountDisplay;
if (partial) {
amountDisplay = `<span class="text-yellow-700">$${balance.toFixed(2)}</span> <span class="text-gray-400 text-xs line-through">$${parseFloat(invoice.total).toFixed(2)}</span>`;
} else {
amountDisplay = `$${parseFloat(invoice.total).toFixed(2)}`;
}
// --- BUTTONS: Edit | QBO | PDF HTML | Payment | Del ---
const editBtn = `<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>`;
const customerHasQbo = !!invoice.customer_qbo_id;
let qboBtn;
if (hasQbo) {
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
} else if (!customerHasQbo) {
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
} else {
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
}
const pdfBtn = draft
? `<span class="text-gray-300 text-sm cursor-not-allowed" title="PDF available after QBO Export">PDF</span>`
: `<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>`;
const htmlBtn = `<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>`;
// Payment button — only for QBO invoices that are not fully paid
let paidBtn = '';
if (!paid && hasQbo) {
paidBtn = `<button onclick="window.paymentModal.open([${invoice.id}])" class="text-emerald-600 hover:text-emerald-800" title="Record Payment in QBO">💰 Payment</button>`;
}
// 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>
// `; }
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 `
<tr class="${rowClass}">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</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 font-medium space-x-1">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
</td>
</tr>`;
}
function renderGroupHeader(label) {
return `<tr class="bg-blue-50"><td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">📅 ${label}</td></tr>`;
}
function renderGroupFooter(total, count) {
return `<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td><td></td></tr>`;
}
export function renderInvoiceView() {
const tbody = document.getElementById('invoices-list');
if (!tbody) return;
const filtered = getFilteredInvoices();
const groups = groupInvoices(filtered);
let html = '', grandTotal = 0;
if (groups) {
for (const [, group] of groups) {
html += renderGroupHeader(group.label);
group.invoices.forEach(inv => { html += renderInvoiceRow(inv); });
html += renderGroupFooter(group.total, group.invoices.length);
grandTotal += group.total;
}
if (groups.size > 1) {
html += `<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
} else {
filtered.forEach(inv => { html += renderInvoiceRow(inv); grandTotal += effectiveAmount(inv); });
if (filtered.length > 0) {
html += `<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td><td></td></tr>`;
}
}
if (filtered.length === 0) html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
tbody.innerHTML = html;
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
updateStatusButtons();
}
function updateStatusButtons() {
document.querySelectorAll('[data-status-filter]').forEach(btn => {
const s = btn.getAttribute('data-status-filter');
btn.classList.toggle('bg-blue-600', s === filterStatus);
btn.classList.toggle('text-white', s === filterStatus);
btn.classList.toggle('bg-white', s !== filterStatus);
btn.classList.toggle('text-gray-600', s !== filterStatus);
});
const counts = {
unpaid: invoices.filter(i => !isPaid(i)).length,
open: invoices.filter(i => isOpen(i)).length,
sent: invoices.filter(i => isSent(i)).length,
partial: invoices.filter(i => isPartiallyPaid(i)).length,
paid: invoices.filter(i => isPaid(i)).length,
overdue: invoices.filter(i => isOverdue(i)).length
};
['unpaid', 'open', 'sent', 'partial', 'paid', 'overdue'].forEach(key => {
const el = document.getElementById(`${key}-badge`);
if (el) {
el.textContent = counts[key];
el.classList.toggle('hidden', counts[key] === 0);
}
});
}
// ============================================================
// Toolbar
// ============================================================
export function injectToolbar() {
const c = document.getElementById('invoice-toolbar');
if (!c) return;
c.innerHTML = `
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
<button data-status-filter="all" onclick="window.invoiceView.setStatus('all')"
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">All</button>
<button data-status-filter="unpaid" onclick="window.invoiceView.setStatus('unpaid')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Unpaid
<span id="unpaid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-gray-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="open" onclick="window.invoiceView.setStatus('open')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Open
<span id="open-badge" class="hidden absolute -top-1.5 -right-1.5 bg-orange-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="sent" onclick="window.invoiceView.setStatus('sent')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Sent
<span id="sent-badge" class="hidden absolute -top-1.5 -right-1.5 bg-cyan-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="partial" onclick="window.invoiceView.setStatus('partial')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Partial
<span id="partial-badge" class="hidden absolute -top-1.5 -right-1.5 bg-yellow-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="paid" onclick="window.invoiceView.setStatus('paid')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Paid
<span id="paid-badge" class="hidden absolute -top-1.5 -right-1.5 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
<button data-status-filter="overdue" onclick="window.invoiceView.setStatus('overdue')"
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">Overdue
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span></button>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Customer:</label>
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..." value="${filterCustomer}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Group:</label>
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
<option value="none" ${groupBy === 'none' ? 'selected' : ''}>None</option>
<option value="week" ${groupBy === 'week' ? 'selected' : ''}>Week</option>
<option value="month" ${groupBy === 'month' ? 'selected' : ''}>Month</option>
</select>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<button onclick="window.invoiceView.syncFromQBO()" class="px-3 py-1.5 bg-indigo-600 text-white rounded-md text-xs font-medium hover:bg-indigo-700">
⟳ Sync from QBO
</button>
</div>
<div class="ml-auto flex items-center gap-4">
<span id="last-sync-time" class="text-xs text-gray-400">...</span>
<span class="text-sm text-gray-500">
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
</span>
</div>
</div>`;
updateStatusButtons();
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value; saveSettings(); renderInvoiceView();
});
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
groupBy = e.target.value; saveSettings(); renderInvoiceView();
});
}
// ============================================================
// Actions
// ============================================================
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
export async function exportToQBO(id) {
if (!confirm('Export invoice to QuickBooks Online?')) return;
if (typeof showSpinner === 'function') showSpinner('Exporting invoice to QBO...');
try {
const r = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
const d = await r.json();
if (r.ok) { alert(`✅ QBO ID: ${d.qbo_id}, Nr: ${d.qbo_doc_number}`); loadInvoices(); }
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function syncToQBO(id) {
if (!confirm('Sync changes to QuickBooks Online?')) return;
if (typeof showSpinner === 'function') showSpinner('Syncing invoice to QBO...');
try {
const r = await fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' });
const d = await r.json();
if (r.ok) { alert(`${d.message}`); loadInvoices(); }
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function syncFromQBO() {
if (typeof showSpinner === 'function') showSpinner('Syncing payments from QBO...');
try {
const r = await fetch('/api/qbo/sync-payments', { method: 'POST' });
const d = await r.json();
if (r.ok) {
alert(`${d.message}`);
loadInvoices();
} else {
alert(`${d.error}`);
}
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function setEmailStatus(id, status) {
const label = status === 'sent' ? 'Mark as sent' : 'Mark as not sent';
if (!confirm(`${label}?`)) return;
if (typeof showSpinner === 'function') showSpinner(`Updating status in QBO...`);
try {
const r = await fetch(`/api/invoices/${id}/email-status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const d = await r.json();
if (r.ok) loadInvoices();
else alert(`${d.error}`);
} catch (e) { alert('Network error.'); }
finally { if (typeof hideSpinner === 'function') hideSpinner(); }
}
export async function resetQbo(id) {
if (!confirm('Reset QBO link?\nInvoice must be deleted in QBO first!')) return;
try {
const r = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
if (r.ok) loadInvoices(); else { const e = await r.json(); alert(e.error); }
} catch (e) { console.error(e); }
}
export async function markPaid(id) {
try {
const r = await fetch(`/api/invoices/${id}/mark-paid`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
});
if (r.ok) loadInvoices();
} catch (e) { console.error(e); }
}
export async function edit(id) { if (typeof window.openInvoiceModal === 'function') await window.openInvoiceModal(id); }
export async function remove(id) {
if (!confirm('Delete this invoice?')) return;
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, checkStripePayment
};