recurring, tax exempt, badge

This commit is contained in:
2026-03-04 18:21:40 -06:00
parent e333628f1c
commit e9d88b1400
7 changed files with 323 additions and 70 deletions

View File

@@ -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

View File

@@ -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++) {

View File

@@ -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
};