recurring, tax exempt, badge
This commit is contained in:
parent
e333628f1c
commit
e9d88b1400
|
|
@ -413,6 +413,21 @@
|
|||
<input type="text" id="invoice-authorization"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="P.O. Number, Authorization Code, etc.">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="invoice-recurring"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-recurring" class="ml-2 block text-sm text-gray-900">Recurring Invoice</label>
|
||||
</div>
|
||||
<div id="invoice-recurring-group" style="display: none;" class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-gray-700">Interval:</label>
|
||||
<select id="invoice-recurring-interval"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -195,7 +195,13 @@ function renderInvoiceRow(invoice) {
|
|||
} 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) {
|
||||
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 $${amountPaid.toFixed(2)}</span>`;
|
||||
// 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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
Loading…
Reference in New Issue