diff --git a/public/index.html b/public/index.html index 41eef6c..f295214 100644 --- a/public/index.html +++ b/public/index.html @@ -413,6 +413,21 @@ +
+
+ + +
+ +
diff --git a/public/js/modals/invoice-modal.js b/public/js/modals/invoice-modal.js index acfc230..7afd681 100644 --- a/public/js/modals/invoice-modal.js +++ b/public/js/modals/invoice-modal.js @@ -1,6 +1,10 @@ /** * invoice-modal.js — Invoice create/edit modal * Uses shared item-editor for accordion items + * + * Features: + * - Auto-sets tax-exempt based on customer's taxable flag + * - Recurring invoice support (monthly/yearly) */ import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js'; import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; @@ -8,9 +12,6 @@ import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; let currentInvoiceId = null; let qboLaborRate = null; -/** - * Load labor rate from QBO (called once at startup) - */ export async function loadLaborRate() { try { const response = await fetch('/api/qbo/labor-rate'); @@ -20,23 +21,34 @@ export async function loadLaborRate() { console.log(`💰 Labor Rate geladen: $${qboLaborRate}`); } } catch (e) { - console.log('Labor Rate konnte nicht geladen werden, verwende keinen Default.'); + console.log('Labor Rate konnte nicht geladen werden.'); } } -export function getLaborRate() { - return qboLaborRate; +export function getLaborRate() { return qboLaborRate; } + +/** + * Auto-set tax exempt based on customer's taxable flag + */ +function applyCustomerTaxStatus(customerId) { + const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []); + const customer = allCust.find(c => c.id === parseInt(customerId)); + if (customer) { + const cb = document.getElementById('invoice-tax-exempt'); + if (cb) { + cb.checked = (customer.taxable === false); + updateInvoiceTotals(); + } + } } export async function openInvoiceModal(invoiceId = null) { currentInvoiceId = invoiceId; - if (invoiceId) { await loadInvoiceForEdit(invoiceId); } else { prepareNewInvoice(); } - document.getElementById('invoice-modal').classList.add('active'); } @@ -47,7 +59,6 @@ export function closeInvoiceModal() { async function loadInvoiceForEdit(invoiceId) { document.getElementById('invoice-modal-title').textContent = 'Edit Invoice'; - const response = await fetch(`/api/invoices/${invoiceId}`); const data = await response.json(); @@ -79,22 +90,25 @@ async function loadInvoiceForEdit(invoiceId) { const sendDateEl = document.getElementById('invoice-send-date'); if (sendDateEl) { sendDateEl.value = data.invoice.scheduled_send_date - ? data.invoice.scheduled_send_date.split('T')[0] - : ''; + ? data.invoice.scheduled_send_date.split('T')[0] : ''; } - // Load items using shared editor + // Recurring fields + const recurringCb = document.getElementById('invoice-recurring'); + const recurringInterval = document.getElementById('invoice-recurring-interval'); + const recurringGroup = document.getElementById('invoice-recurring-group'); + if (recurringCb) { + recurringCb.checked = data.invoice.is_recurring || false; + if (recurringInterval) recurringInterval.value = data.invoice.recurring_interval || 'monthly'; + if (recurringGroup) recurringGroup.style.display = data.invoice.is_recurring ? 'block' : 'none'; + } + + // Load items document.getElementById('invoice-items').innerHTML = ''; resetItemCounter(); data.items.forEach(item => { - addItem('invoice-items', { - item, - type: 'invoice', - laborRate: qboLaborRate, - onUpdate: updateInvoiceTotals - }); + addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals }); }); - updateInvoiceTotals(); } @@ -105,39 +119,32 @@ function prepareNewInvoice() { document.getElementById('invoice-terms').value = 'Net 30'; document.getElementById('invoice-number').value = ''; document.getElementById('invoice-send-date').value = ''; + + // Reset recurring + const recurringCb = document.getElementById('invoice-recurring'); + const recurringGroup = document.getElementById('invoice-recurring-group'); + if (recurringCb) recurringCb.checked = false; + if (recurringGroup) recurringGroup.style.display = 'none'; + resetItemCounter(); setDefaultDate(); - - // Add one default item - addItem('invoice-items', { - type: 'invoice', - laborRate: qboLaborRate, - onUpdate: updateInvoiceTotals - }); + addItem('invoice-items', { type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals }); } export function addInvoiceItem(item = null) { - addItem('invoice-items', { - item, - type: 'invoice', - laborRate: qboLaborRate, - onUpdate: updateInvoiceTotals - }); + addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals }); } export function updateInvoiceTotals() { const items = getItems('invoice-items'); const taxExempt = document.getElementById('invoice-tax-exempt').checked; - let subtotal = 0; items.forEach(item => { const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0; subtotal += amount; }); - const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100); const total = subtotal + taxAmount; - document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`; document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`; @@ -146,6 +153,9 @@ export function updateInvoiceTotals() { export async function handleInvoiceSubmit(e) { e.preventDefault(); + const isRecurring = document.getElementById('invoice-recurring')?.checked || false; + const recurringInterval = isRecurring + ? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null; const data = { invoice_number: document.getElementById('invoice-number').value || null, @@ -156,44 +166,27 @@ export async function handleInvoiceSubmit(e) { tax_exempt: document.getElementById('invoice-tax-exempt').checked, scheduled_send_date: document.getElementById('invoice-send-date')?.value || null, bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null, + is_recurring: isRecurring, + recurring_interval: recurringInterval, items: getItems('invoice-items') }; - if (!data.customer_id) { - alert('Please select a customer.'); - return; - } - if (!data.items || data.items.length === 0) { - alert('Please add at least one item.'); - return; - } + if (!data.customer_id) { alert('Please select a customer.'); return; } + if (!data.items || data.items.length === 0) { alert('Please add at least one item.'); return; } const invoiceId = currentInvoiceId; const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices'; const method = invoiceId ? 'PUT' : 'POST'; - showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...'); try { - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) - }); - + const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); - if (response.ok) { closeInvoiceModal(); - - if (result.qbo_doc_number) { - console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`); - } else if (result.qbo_synced) { - console.log('✅ Invoice saved & synced to QBO'); - } else { - console.log('✅ Invoice saved locally (QBO sync pending)'); - } - + if (result.qbo_doc_number) console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`); + else if (result.qbo_synced) console.log('✅ Invoice saved & synced to QBO'); + else console.log('✅ Invoice saved locally (QBO sync pending)'); if (window.invoiceView) window.invoiceView.loadInvoices(); } else { alert(`Error: ${result.error}`); @@ -206,16 +199,35 @@ export async function handleInvoiceSubmit(e) { } } -// Wire up form submit and tax-exempt checkbox export function initInvoiceModal() { const form = document.getElementById('invoice-form'); if (form) form.addEventListener('submit', handleInvoiceSubmit); const taxExempt = document.getElementById('invoice-tax-exempt'); if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals); + + // Recurring toggle + const recurringCb = document.getElementById('invoice-recurring'); + const recurringGroup = document.getElementById('invoice-recurring-group'); + if (recurringCb && recurringGroup) { + recurringCb.addEventListener('change', () => { + recurringGroup.style.display = recurringCb.checked ? 'block' : 'none'; + }); + } + + // Watch for customer selection → auto-set tax exempt (only for new invoices) + const customerHidden = document.getElementById('invoice-customer'); + if (customerHidden) { + const observer = new MutationObserver(() => { + // Only auto-apply when creating new (not editing existing) + if (!currentInvoiceId && customerHidden.value) { + applyCustomerTaxStatus(customerHidden.value); + } + }); + observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] }); + } } -// Expose for onclick handlers window.openInvoiceModal = openInvoiceModal; window.closeInvoiceModal = closeInvoiceModal; window.addInvoiceItem = addInvoiceItem; \ No newline at end of file diff --git a/public/js/modals/quote-modal.js b/public/js/modals/quote-modal.js index e3bc601..1d75801 100644 --- a/public/js/modals/quote-modal.js +++ b/public/js/modals/quote-modal.js @@ -7,6 +7,21 @@ import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js'; let currentQuoteId = null; +/** + * Auto-set tax exempt based on customer's taxable flag + */ +function applyCustomerTaxStatus(customerId) { + const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []); + const customer = allCust.find(c => c.id === parseInt(customerId)); + if (customer) { + const cb = document.getElementById('quote-tax-exempt'); + if (cb) { + cb.checked = (customer.taxable === false); + updateQuoteTotals(); + } + } +} + export function openQuoteModal(quoteId = null) { currentQuoteId = quoteId; @@ -146,6 +161,17 @@ export function initQuoteModal() { const taxExempt = document.getElementById('quote-tax-exempt'); if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals); + + // Watch for customer selection → auto-set tax exempt (only for new quotes) + const customerHidden = document.getElementById('quote-customer'); + if (customerHidden) { + const observer = new MutationObserver(() => { + if (!currentQuoteId && customerHidden.value) { + applyCustomerTaxStatus(customerHidden.value); + } + }); + observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] }); + } } // Expose for onclick handlers diff --git a/public/js/views/invoice-view.js b/public/js/views/invoice-view.js index 6565e12..013d388 100644 --- a/public/js/views/invoice-view.js +++ b/public/js/views/invoice-view.js @@ -195,7 +195,13 @@ function renderInvoiceRow(invoice) { } else if (paid) { statusBadge = `Paid`; } else if (partial) { - statusBadge = `Partial $${amountPaid.toFixed(2)}`; + // Partial: show delivery status badge + Partial badge + if (hasQbo && invoice.email_status === 'sent') { + statusBadge = `Sent `; + } else if (hasQbo) { + statusBadge = `Open `; + } + statusBadge += `Partial`; } else if (overdue) { statusBadge = `Overdue`; } else if (hasQbo && invoice.email_status === 'sent') { diff --git a/src/index.js b/src/index.js index 7c88291..f005d68 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,9 @@ const settingsRoutes = require('./routes/settings'); // Import PDF service for browser initialization const { setBrowser } = require('./services/pdf-service'); +// Import recurring invoice scheduler +const { startRecurringScheduler } = require('./services/recurring-service'); + const app = express(); const PORT = process.env.PORT || 3000; @@ -113,6 +116,9 @@ async function startServer() { app.listen(PORT, () => { console.log(`Quote System running on port ${PORT}`); }); + + // Start recurring invoice scheduler (checks every 24h) + startRecurringScheduler(); } // Graceful shutdown diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 5655282..402c80d 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -13,6 +13,16 @@ const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, format const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service'); const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); +function calculateNextRecurringDate(invoiceDate, interval) { + const d = new Date(invoiceDate); + if (interval === 'monthly') { + d.setMonth(d.getMonth() + 1); + } else if (interval === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + } + return d.toISOString().split('T')[0]; +} + // GET all invoices router.get('/', async (req, res) => { try { @@ -81,7 +91,7 @@ router.get('/:id', async (req, res) => { // POST create invoice router.post('/', async (req, res) => { - const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id } = req.body; + const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval } = req.body; const client = await pool.connect(); try { @@ -112,11 +122,11 @@ router.post('/', async (req, res) => { const tax_rate = 8.25; const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; - + const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null; const invoiceResult = await client.query( - `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *`, - [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id] + `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, next_recurring_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *`, + [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id, is_recurring || false, recurring_interval || null, next_recurring_date] ); const invoiceId = invoiceResult.rows[0].id; @@ -158,7 +168,7 @@ router.post('/', async (req, res) => { // PUT update invoice router.put('/:id', async (req, res) => { const { id } = req.params; - const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name } = req.body; + const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, is_recurring, recurring_interval } = req.body; const client = await pool.connect(); try { @@ -204,7 +214,11 @@ router.put('/:id', async (req, res) => { [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id] ); } - + const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null; + await client.query( + 'UPDATE invoices SET is_recurring = $1, recurring_interval = $2, next_recurring_date = $3 WHERE id = $4', + [is_recurring || false, recurring_interval || null, next_recurring_date, id] + ); // Delete and re-insert items await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]); for (let i = 0; i < items.length; i++) { diff --git a/src/services/recurring-service.js b/src/services/recurring-service.js new file mode 100644 index 0000000..29d730e --- /dev/null +++ b/src/services/recurring-service.js @@ -0,0 +1,174 @@ +/** + * Recurring Invoice Service + * Checks daily for recurring invoices that are due and creates new copies. + * + * Logic: + * - Runs every 24h (and once on startup after 60s delay) + * - Finds invoices where is_recurring=true AND next_recurring_date <= today + * - Creates a copy with updated invoice_date = next_recurring_date + * - Advances next_recurring_date by the interval (monthly/yearly) + * - Auto-exports to QBO if customer is linked + */ +const { pool } = require('../config/database'); +const { exportInvoiceToQbo } = require('./qbo-service'); + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours +const STARTUP_DELAY_MS = 60 * 1000; // 60 seconds after boot + +/** + * Calculate next date based on interval + */ +function advanceDate(dateStr, interval) { + const d = new Date(dateStr); + if (interval === 'monthly') { + d.setMonth(d.getMonth() + 1); + } else if (interval === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + } + return d.toISOString().split('T')[0]; +} + +/** + * Process all due recurring invoices + */ +async function processRecurringInvoices() { + const today = new Date().toISOString().split('T')[0]; + console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`); + + const client = await pool.connect(); + try { + // Find all recurring invoices that are due + const dueResult = await client.query(` + SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + WHERE i.is_recurring = true + AND i.next_recurring_date IS NOT NULL + AND i.next_recurring_date <= $1 + `, [today]); + + if (dueResult.rows.length === 0) { + console.log('🔄 [RECURRING] No recurring invoices due.'); + return { created: 0 }; + } + + console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`); + + let created = 0; + + for (const source of dueResult.rows) { + await client.query('BEGIN'); + + try { + // Load items from the source invoice + const itemsResult = await client.query( + 'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', + [source.id] + ); + + const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0]; + + // Create the new invoice (no invoice_number — QBO will assign one) + const newInvoice = await client.query( + `INSERT INTO invoices ( + invoice_number, customer_id, invoice_date, terms, auth_code, + tax_exempt, tax_rate, subtotal, tax_amount, total, + bill_to_name, recurring_source_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [ + `DRAFT-${Date.now()}`, // Temporary, QBO export will assign real number + source.customer_id, + newInvoiceDate, + source.terms, + source.auth_code, + source.tax_exempt, + source.tax_rate, + source.subtotal, + source.tax_amount, + source.total, + source.bill_to_name, + source.id + ] + ); + + const newInvoiceId = newInvoice.rows[0].id; + + // Copy items + for (let i = 0; i < itemsResult.rows.length; i++) { + const item = itemsResult.rows[i]; + await client.query( + `INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [newInvoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9'] + ); + } + + // Advance the source invoice's next_recurring_date + const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval); + await client.query( + 'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [nextDate, source.id] + ); + + await client.query('COMMIT'); + + console.log(` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`); + + // Auto-export to QBO (outside transaction, non-blocking) + try { + const dbClient = await pool.connect(); + try { + const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient); + if (qboResult.success) { + console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`); + } else if (qboResult.skipped) { + console.log(` â„šī¸ QBO export skipped: ${qboResult.reason}`); + } + } finally { + dbClient.release(); + } + } catch (qboErr) { + console.error(` âš ī¸ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message); + } + + created++; + } catch (err) { + await client.query('ROLLBACK'); + console.error(` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, err.message); + } + } + + console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s).`); + return { created }; + } catch (error) { + console.error('❌ [RECURRING] Error:', error.message); + return { created: 0, error: error.message }; + } finally { + client.release(); + } +} + +/** + * Start the recurring invoice scheduler + */ +function startRecurringScheduler() { + // First check after startup delay + setTimeout(() => { + console.log('🔄 [RECURRING] Initial check...'); + processRecurringInvoices(); + }, STARTUP_DELAY_MS); + + // Then every 24 hours + setInterval(() => { + processRecurringInvoices(); + }, CHECK_INTERVAL_MS); + + console.log(`🔄 [RECURRING] Scheduler started (checks every 24h, first check in ${STARTUP_DELAY_MS / 1000}s)`); +} + +module.exports = { + processRecurringInvoices, + startRecurringScheduler, + advanceDate +}; \ No newline at end of file