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