invoice-system/public/payment-modal.js

364 lines
16 KiB
JavaScript
Raw Permalink 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.

// payment-modal.js — ES Module v3 (clean)
// Invoice payments: multi-invoice, partial, overpay
// No downpayment functionality
let bankAccounts = [];
let paymentMethods = [];
let selectedInvoices = []; // { invoice, payAmount }
let dataLoaded = false;
// ============================================================
// Load QBO Data
// ============================================================
async function loadQboData() {
if (dataLoaded) return;
try {
const [accRes, pmRes] = await Promise.all([
fetch('/api/qbo/accounts'),
fetch('/api/qbo/payment-methods')
]);
if (accRes.ok) bankAccounts = await accRes.json();
if (pmRes.ok) paymentMethods = await pmRes.json();
dataLoaded = true;
} catch (e) { console.error('Error loading QBO data:', e); }
}
// ============================================================
// Open / Close
// ============================================================
export async function openPaymentModal(invoiceIds = []) {
await loadQboData();
selectedInvoices = [];
for (const id of invoiceIds) {
try {
const res = await fetch(`/api/invoices/${id}`);
const data = await res.json();
if (data.invoice) {
const total = parseFloat(data.invoice.total);
const amountPaid = parseFloat(data.invoice.amount_paid) || 0;
const balance = total - amountPaid;
selectedInvoices.push({
invoice: data.invoice,
payAmount: balance > 0 ? balance : total
});
}
} catch (e) { console.error('Error loading invoice:', id, e); }
}
ensureModalElement();
renderModalContent();
document.getElementById('payment-modal').classList.add('active');
}
export function closePaymentModal() {
const modal = document.getElementById('payment-modal');
if (modal) modal.classList.remove('active');
selectedInvoices = [];
}
// ============================================================
// Add / Remove Invoices
// ============================================================
async function addInvoiceById() {
const input = document.getElementById('payment-add-invoice-id');
const searchVal = input.value.trim();
if (!searchVal) return;
try {
const res = await fetch('/api/invoices');
const allInvoices = await res.json();
const match = allInvoices.find(inv =>
String(inv.id) === searchVal || String(inv.invoice_number) === searchVal
);
if (!match) { alert(`No invoice with #/ID "${searchVal}" found.`); return; }
if (!match.qbo_id) { alert('This invoice has not been exported to QBO yet.'); return; }
if (match.paid_date) { alert('This invoice is already paid.'); return; }
if (selectedInvoices.find(si => si.invoice.id === match.id)) { alert('Invoice already in list.'); return; }
if (selectedInvoices.length > 0 && match.customer_id !== selectedInvoices[0].invoice.customer_id) {
alert('All invoices must belong to the same customer.'); return;
}
const detailRes = await fetch(`/api/invoices/${match.id}`);
const detailData = await detailRes.json();
const detailInv = detailData.invoice;
const detailTotal = parseFloat(detailInv.total);
const detailPaid = parseFloat(detailInv.amount_paid) || 0;
const detailBalance = detailTotal - detailPaid;
selectedInvoices.push({
invoice: detailInv,
payAmount: detailBalance > 0 ? detailBalance : detailTotal
});
renderInvoiceList();
updateTotal();
input.value = '';
} catch (e) {
console.error('Error adding invoice:', e);
alert('Error searching for invoice.');
}
}
function removeInvoice(invoiceId) {
selectedInvoices = selectedInvoices.filter(si => si.invoice.id !== invoiceId);
renderInvoiceList();
updateTotal();
}
function updatePayAmount(invoiceId, newAmount) {
const si = selectedInvoices.find(s => s.invoice.id === invoiceId);
if (si) {
si.payAmount = Math.max(0, parseFloat(newAmount) || 0);
}
renderInvoiceList();
updateTotal();
}
// ============================================================
// DOM
// ============================================================
function ensureModalElement() {
let modal = document.getElementById('payment-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'payment-modal';
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto';
document.body.appendChild(modal);
}
}
function renderModalContent() {
const modal = document.getElementById('payment-modal');
if (!modal) return;
const accountOptions = bankAccounts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
const filtered = paymentMethods.filter(p => /check|ach/i.test(p.name));
const methods = filtered.length > 0 ? filtered : paymentMethods;
const methodOptions = methods.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
const today = new Date().toISOString().split('T')[0];
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-2xl w-full max-w-2xl mx-auto p-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold text-gray-800">💰 Record Payment</h2>
<button onclick="window.paymentModal.close()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Invoice List -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Invoices</label>
<div id="payment-invoice-list" class="border border-gray-200 rounded-lg max-h-60 overflow-y-auto"></div>
<div class="mt-2 flex items-center gap-2">
<input type="text" id="payment-add-invoice-id" placeholder="Add by Invoice # or ID..."
class="flex-1 px-3 py-1.5 border border-gray-300 rounded-md text-sm"
onkeydown="if(event.key==='Enter'){event.preventDefault();window.paymentModal.addById();}">
<button onclick="window.paymentModal.addById()"
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700">+ Add</button>
</div>
</div>
<!-- Payment Details -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Date</label>
<input type="date" id="payment-date" value="${today}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reference # (Check / ACH)</label>
<input type="text" id="payment-reference" placeholder="Check # or ACH ref"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select id="payment-method"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
${methodOptions}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Deposit To</label>
<select id="payment-deposit-to"
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:ring-blue-500 focus:border-blue-500">
${accountOptions}
</select>
</div>
</div>
<!-- Total -->
<div class="bg-gray-50 p-4 rounded-lg mb-6">
<div class="flex justify-between items-center">
<span class="text-lg font-bold text-gray-700">Total Payment:</span>
<span id="payment-total" class="text-2xl font-bold text-blue-600">$0.00</span>
</div>
<div id="payment-overpay-note" class="hidden mt-2 text-sm text-yellow-700"></div>
</div>
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button onclick="window.paymentModal.close()"
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
<button onclick="window.paymentModal.submit()" id="payment-submit-btn"
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 font-semibold">
💰 Record Payment in QBO
</button>
</div>
</div>`;
renderInvoiceList();
updateTotal();
}
function renderInvoiceList() {
const container = document.getElementById('payment-invoice-list');
if (!container) return;
if (selectedInvoices.length === 0) {
container.innerHTML = `<div class="p-4 text-center text-gray-400 text-sm">No invoices selected — add below</div>`;
return;
}
container.innerHTML = selectedInvoices.map(si => {
const inv = si.invoice;
const total = parseFloat(inv.total);
const amountPaid = parseFloat(inv.amount_paid) || 0;
const balance = total - amountPaid;
const isPartial = si.payAmount < balance;
const isOver = si.payAmount > balance;
const paidInfo = amountPaid > 0
? `<span class="text-green-600 text-xs ml-1">Paid: $${amountPaid.toFixed(2)}</span>`
: '';
return `
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 last:border-0 hover:bg-gray-50">
<div class="flex-1 min-w-0">
<span class="font-medium text-gray-900">#${inv.invoice_number || 'Draft'}</span>
<span class="text-gray-500 text-sm ml-2 truncate">${inv.customer_name || ''}</span>
<span class="text-gray-400 text-xs ml-2">(Total: $${total.toFixed(2)})</span>
${paidInfo}
${isPartial ? '<span class="text-xs text-yellow-600 ml-1 font-semibold">Partial</span>' : ''}
${isOver ? '<span class="text-xs text-blue-600 ml-1 font-semibold">Overpay</span>' : ''}
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span class="text-gray-500 text-sm">$</span>
<input type="number" step="0.01" min="0.01"
value="${si.payAmount.toFixed(2)}"
onchange="window.paymentModal.updateAmount(${inv.id}, this.value)"
class="w-28 px-2 py-1 border rounded text-sm text-right font-semibold
${isPartial ? 'bg-yellow-50 border-yellow-300' : isOver ? 'bg-blue-50 border-blue-300' : 'border-gray-300'}">
<button onclick="window.paymentModal.removeInvoice(${inv.id})"
class="text-red-400 hover:text-red-600 text-sm ml-1">✕</button>
</div>
</div>`;
}).join('');
}
function updateTotal() {
const totalEl = document.getElementById('payment-total');
const noteEl = document.getElementById('payment-overpay-note');
if (!totalEl) return;
const payTotal = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
totalEl.textContent = `$${payTotal.toFixed(2)}`;
if (noteEl) {
if (payTotal > invTotal && invTotal > 0) {
noteEl.textContent = `⚠️ Overpayment of $${(payTotal - invTotal).toFixed(2)} will be stored as customer credit in QBO.`;
noteEl.classList.remove('hidden');
} else {
noteEl.classList.add('hidden');
}
}
}
// ============================================================
// Submit
// ============================================================
async function submitPayment() {
if (selectedInvoices.length === 0) { alert('Please add at least one invoice.'); return; }
const paymentDate = document.getElementById('payment-date').value;
const reference = document.getElementById('payment-reference').value;
const methodSelect = document.getElementById('payment-method');
const depositSelect = document.getElementById('payment-deposit-to');
if (!paymentDate || !methodSelect.value || !depositSelect.value) {
alert('Please fill in all fields.'); return;
}
const total = selectedInvoices.reduce((s, si) => s + si.payAmount, 0);
const invTotal = selectedInvoices.reduce((s, si) => s + parseFloat(si.invoice.total), 0);
const nums = selectedInvoices.map(si => `#${si.invoice.invoice_number || si.invoice.id}`).join(', ');
const hasPartial = selectedInvoices.some(si => si.payAmount < parseFloat(si.invoice.total));
const hasOverpay = total > invTotal;
let msg = `Record payment of $${total.toFixed(2)} for ${nums}?`;
if (hasPartial) msg += '\n⚠ Contains partial payment(s).';
if (hasOverpay) msg += `\n⚠️ $${(total - invTotal).toFixed(2)} overpayment → customer credit.`;
if (!confirm(msg)) return;
const submitBtn = document.getElementById('payment-submit-btn');
submitBtn.innerHTML = '⏳ Processing...';
submitBtn.disabled = true;
if (typeof showSpinner === 'function') showSpinner('Recording payment in QBO...');
try {
const response = await fetch('/api/qbo/record-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invoice_payments: selectedInvoices.map(si => ({
invoice_id: si.invoice.id,
amount: si.payAmount
})),
payment_date: paymentDate,
reference_number: reference,
payment_method_id: methodSelect.value,
payment_method_name: methodSelect.options[methodSelect.selectedIndex]?.text || '',
deposit_to_account_id: depositSelect.value,
deposit_to_account_name: depositSelect.options[depositSelect.selectedIndex]?.text || ''
})
});
const result = await response.json();
if (response.ok) {
alert(`${result.message}`);
closePaymentModal();
if (window.invoiceView) window.invoiceView.loadInvoices();
} else {
alert(`❌ Error: ${result.error}`);
}
} catch (e) {
console.error('Payment error:', e);
alert('Network error.');
} finally {
submitBtn.innerHTML = '💰 Record Payment in QBO';
submitBtn.disabled = false;
if (typeof hideSpinner === 'function') hideSpinner();
}
}
// ============================================================
// Expose
// ============================================================
window.paymentModal = {
open: openPaymentModal,
close: closePaymentModal,
submit: submitPayment,
addById: addInvoiceById,
removeInvoice: removeInvoice,
updateAmount: updatePayAmount,
updateTotal: updateTotal
};