diff --git a/public/js/modals/email-modal.js b/public/js/modals/email-modal.js index ade5985..3a4d92a 100644 --- a/public/js/modals/email-modal.js +++ b/public/js/modals/email-modal.js @@ -25,6 +25,98 @@ function getEmailModalTitle(invoice, isOverdue) { if (invoice.email_status === 'sent') return `๐ Resend Invoice #${invoice.invoice_number || invoice.id}`; return `๐ค Send Invoice #${invoice.invoice_number || invoice.id}`; } +function parseLocalDate(dateValue) { + if (!dateValue) return null; + + if (dateValue instanceof Date) { + return new Date(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); + } + + const dateStr = String(dateValue).split('T')[0]; + const parts = dateStr.split('-').map(Number); + + if (parts.length === 3 && parts.every(Number.isFinite)) { + return new Date(parts[0], parts[1] - 1, parts[2]); + } + + const d = new Date(dateValue); + return Number.isNaN(d.getTime()) ? null : d; +} + +function getLastSentDate(invoice) { + const sentDates = Array.isArray(invoice.sent_dates) + ? invoice.sent_dates.filter(Boolean) + : []; + + if (sentDates.length > 0) { + return sentDates[sentDates.length - 1]; + } + + return null; +} + +function getTermDays(invoice) { + const terms = String(invoice.terms || '').toLowerCase(); + + if (terms.includes('due on receipt') || terms.includes('upon receipt')) { + return 0; + } + + const match = terms.match(/net\s*(\d+)/i); + if (match) { + return parseInt(match[1], 10); + } + + return 30; +} + +function addDays(dateValue, days) { + const d = parseLocalDate(dateValue); + if (!d) return null; + + d.setDate(d.getDate() + days); + return d; +} + +function getEffectiveDueDate(invoice) { + const lastSentDate = getLastSentDate(invoice); + + // Nie versendet = kein Reminder/Overdue Text + if (!lastSentDate) { + return null; + } + + if (invoice.due_date) { + return parseLocalDate(invoice.due_date); + } + + return addDays(lastSentDate, getTermDays(invoice)); +} + +function getOverdueDays(invoice) { + const dueDate = getEffectiveDueDate(invoice); + if (!dueDate) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + dueDate.setHours(0, 0, 0, 0); + + return Math.max(0, Math.floor((today - dueDate) / 86400000)); +} + +function isInvoiceOverdue(invoice) { + const dueDate = getEffectiveDueDate(invoice); + if (!dueDate) return false; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + dueDate.setHours(0, 0, 0, 0); + + return !invoice.paid_date && dueDate < today; +} + function renderModalContent() { const modal = document.getElementById('email-modal'); if (!modal) return; @@ -34,13 +126,8 @@ function renderModalContent() { const stripeStatus = currentInvoice.stripe_payment_status || ''; // Detect overdue: unpaid + older than 30 days - const invoiceDateParsed = currentInvoice.invoice_date - ? new Date(currentInvoice.invoice_date.split('T')[0]) - : null; - const daysSinceInvoice = invoiceDateParsed - ? Math.floor((new Date() - invoiceDateParsed) / 86400000) - : 0; - const isOverdue = !currentInvoice.paid_date && daysSinceInvoice > 30; + const isOverdue = isInvoiceOverdue(currentInvoice); + const daysOverdue = getOverdueDays(currentInvoice); const modalTitle = getEmailModalTitle(currentInvoice, isOverdue); @@ -159,7 +246,7 @@ function renderModalContent() { defaultHtml = `
Dear ${customerName},
We hope this message finds you well. Our records indicate that invoice #${invoiceNum} in the amount of $${totalDue}, dated ${formatDate(currentInvoice.invoice_date)}, remains unpaid.
-This invoice is now ${daysSinceInvoice} days past the invoice date. We kindly request prompt payment at your earliest convenience.
+This invoice is now ${daysOverdue} days overdue. We kindly request prompt payment at your earliest convenience.
For your convenience, you can pay securely online using the payment link included below. We accept both Credit Card and ACH bank transfer.
If payment has already been sent, please disregard this notice. Should you have any questions or need to discuss payment arrangements, please do not hesitate to reply to this email.
Thank you for your attention to this matter. We value your business and look forward to continuing our partnership.
diff --git a/public/js/views/invoice-view.js b/public/js/views/invoice-view.js index 697d704..6e53550 100644 --- a/public/js/views/invoice-view.js +++ b/public/js/views/invoice-view.js @@ -69,19 +69,121 @@ 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; } +// ============================================================ +// Status Helpers +// ============================================================ + +function isPaid(inv) { + return !!inv.paid_date; +} + +function isDraft(inv) { + return !inv.qbo_id; +} + 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 getLastSentDate(inv) { + const sentDates = Array.isArray(inv.sent_dates) ? inv.sent_dates.filter(Boolean) : []; + + if (sentDates.length > 0) { + return sentDates[sentDates.length - 1]; + } + + return null; } + +function getTermDays(inv) { + const terms = String(inv.terms || '').toLowerCase(); + + if (terms.includes('due on receipt') || terms.includes('upon receipt')) { + return 0; + } + + const match = terms.match(/net\s*(\d+)/i); + if (match) { + return parseInt(match[1], 10); + } + + // Default in deiner App ist Net 30 + return 30; +} + +function addDays(dateValue, days) { + const d = parseLocalDate(dateValue); + if (!d) return null; + + d.setDate(d.getDate() + days); + return d; +} + +function isBeforeToday(dateObj) { + if (!dateObj) return false; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const compare = new Date(dateObj); + compare.setHours(0, 0, 0, 0); + + return compare < today; +} + +function getEffectiveDueDate(inv) { + const lastSentDate = getLastSentDate(inv); + + // Wichtig: Nie versendete Rechnungen kรถnnen nicht overdue sein. + if (!lastSentDate) { + return null; + } + + // Wenn due_date vorhanden ist, darf es genutzt werden, + // aber nur nachdem die Rechnung tatsรคchlich gesendet wurde. + if (inv.due_date) { + return parseLocalDate(inv.due_date); + } + + return addDays(lastSentDate, getTermDays(inv)); +} + +function getOverdueDays(inv) { + const dueDate = getEffectiveDueDate(inv); + if (!dueDate) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + dueDate.setHours(0, 0, 0, 0); + + return Math.max(0, Math.floor((today - dueDate) / 86400000)); +} + +function isOverdue(inv) { + return !!inv.qbo_id + && !isPaid(inv) + && !isPartiallyPaid(inv) + && !!getLastSentDate(inv) + && isBeforeToday(getEffectiveDueDate(inv)); +} + +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'; + return !!inv.qbo_id + && !isPaid(inv) + && !isPartiallyPaid(inv) + && !isOverdue(inv) + && inv.email_status !== 'sent'; } function saveSettings() { @@ -235,7 +337,7 @@ function renderInvoiceRow(invoice) { } statusBadge += `Partial`; } else if (overdue) { - statusBadge = `Overdue`; + statusBadge = `Overdue`; } else if (hasQbo && invoice.email_status === 'sent') { statusBadge = `Sent`; } else if (hasQbo) { diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 9ed7de1..a746dbd 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -135,7 +135,23 @@ router.post('/', async (req, res) => { return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); } } + // Validate items + if (!Array.isArray(items) || items.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Please add at least one item.' }); + } + // Recurring invoices must contain at least one Subscription item + if (is_recurring) { + const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115'); + + if (subscriptionItems.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: 'Recurring invoices must contain at least one Subscription item.' + }); + } + } let subtotal = 0; for (const item of items) { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); @@ -210,7 +226,23 @@ router.put('/:id', async (req, res) => { return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` }); } } + // Validate items + if (!Array.isArray(items) || items.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Please add at least one item.' }); + } + // Recurring invoices must contain at least one Subscription item + if (is_recurring) { + const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115'); + + if (subscriptionItems.length === 0) { + await client.query('ROLLBACK'); + return res.status(400).json({ + error: 'Recurring invoices must contain at least one Subscription item.' + }); + } + } let subtotal = 0; for (const item of items) { const amount = parseFloat(item.amount.replace(/[$,]/g, '')); diff --git a/src/services/recurring-service.js b/src/services/recurring-service.js index 29d730e..3904c7e 100644 --- a/src/services/recurring-service.js +++ b/src/services/recurring-service.js @@ -36,13 +36,13 @@ async function processRecurringInvoices() { 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 + WHERE i.is_recurring = true AND i.next_recurring_date IS NOT NULL AND i.next_recurring_date <= $1 `, [today]); @@ -55,38 +55,86 @@ async function processRecurringInvoices() { console.log(`๐ [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`); let created = 0; + let skipped = 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', + ` + SELECT * + FROM invoice_items + WHERE invoice_id = $1 + AND qbo_item_id::text = '115' + ORDER BY item_order + `, [source.id] ); + const subscriptionItems = itemsResult.rows; + + const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval); + + if (subscriptionItems.length === 0) { + console.warn( + ` โ ๏ธ Recurring invoice #${source.invoice_number || source.id} has no Subscription items. No new invoice created.` + ); + + await client.query( + 'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', + [nextDate, source.id] + ); + + await client.query('COMMIT'); + skipped++; + continue; + } + const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0]; - // Create the new invoice (no invoice_number โ QBO will assign one) + let subtotal = 0; + for (const item of subscriptionItems) { + const amount = parseFloat(String(item.amount || '0').replace(/[$,]/g, '')); + if (!Number.isNaN(amount)) { + subtotal += amount; + } + } + + const taxRate = parseFloat(source.tax_rate) || 8.25; + const taxAmount = source.tax_exempt ? 0 : (subtotal * taxRate / 100); + const total = subtotal + taxAmount; + 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 *`, + ` + 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 + `DRAFT-${Date.now()}`, source.customer_id, newInvoiceDate, source.terms, source.auth_code, source.tax_exempt, - source.tax_rate, - source.subtotal, - source.tax_amount, - source.total, + taxRate, + subtotal, + taxAmount, + total, source.bill_to_name, source.id ] @@ -94,18 +142,34 @@ async function processRecurringInvoices() { const newInvoiceId = newInvoice.rows[0].id; - // Copy items - for (let i = 0; i < itemsResult.rows.length; i++) { - const item = itemsResult.rows[i]; + for (let i = 0; i < subscriptionItems.length; i++) { + const item = subscriptionItems[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'] + ` + 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 || '115' + ] ); } - // 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] @@ -113,13 +177,16 @@ async function processRecurringInvoices() { await client.query('COMMIT'); - console.log(` โ Created recurring invoice from #${source.invoice_number || source.id} โ new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`); + 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) { @@ -129,18 +196,24 @@ async function processRecurringInvoices() { dbClient.release(); } } catch (qboErr) { - console.error(` โ ๏ธ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message); + 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.error( + ` โ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, + err.message + ); } } - console.log(`๐ [RECURRING] Done. Created ${created} invoice(s).`); - return { created }; + console.log(`๐ [RECURRING] Done. Created ${created} invoice(s), skipped ${skipped}.`); + return { created, skipped }; } catch (error) { console.error('โ [RECURRING] Error:', error.message); return { created: 0, error: error.message };