1// Contract Billing Worker: Automatically generates invoices for contracts
2const { Queue, Worker } = require('bullmq');
3const Redis = require('ioredis');
4const pool = require('./services/db');
6const redisConfig = require('./config/redis');
7const connection = new Redis({
9 maxRetriesPerRequest: null // BullMQ requires this
12// Create queue for billing jobs
13const billingQueue = new Queue('contract-billing', { connection });
15// Process billing jobs
16const billingWorker = new Worker('contract-billing', async job => {
17 console.log(`[ContractBilling] Processing job ${job.id} for contract ${job.data.contractId}`);
19 const { contractId } = job.data;
20 const client = await pool.connect();
23 await client.query('BEGIN');
25 // Get contract details with line items
26 const contractRes = await client.query(`
29 cu.name as customer_name,
30 cu.email as customer_email,
33 LEFT JOIN customers cu ON c.customer_id = cu.customer_id
34 LEFT JOIN tenants t ON c.tenant_id = t.tenant_id
35 WHERE c.contract_id = $1
36 AND c.auto_invoice_enabled = true
37 AND c.status = 'active'
40 if (contractRes.rows.length === 0) {
41 console.log(`[ContractBilling] Contract ${contractId} not found or not eligible for auto-billing`);
42 return { success: false, reason: 'Contract not eligible' };
45 const contract = contractRes.rows[0];
47 // Get contract line items
48 const lineItemsRes = await client.query(`
51 p.name as product_name,
52 p.description as product_description
53 FROM contract_line_items cli
54 LEFT JOIN products p ON cli.product_id = p.product_id
55 WHERE cli.contract_id = $1
56 ORDER BY cli.line_item_id
59 if (lineItemsRes.rows.length === 0) {
60 console.log(`[ContractBilling] Contract ${contractId} has no line items, skipping`);
61 return { success: false, reason: 'No line items' };
64 // Calculate invoice total and subtotal
65 const subtotal = lineItemsRes.rows.reduce((sum, item) => {
66 return sum + (parseFloat(item.quantity) * parseFloat(item.unit_price));
69 const taxRate = 0.10; // 10% GST (can be made configurable per contract)
70 const taxAmount = subtotal * taxRate;
71 const totalAmount = subtotal + taxAmount;
73 // Generate payment token for public payment links
74 const crypto = require('crypto');
75 const paymentToken = crypto.randomBytes(32).toString('hex');
77 // Create invoice using correct column names matching invoices table schema
78 const invoiceRes = await client.query(`
79 INSERT INTO invoices (
94 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
95 ) RETURNING invoice_id
100 contract.currency || 'AUD',
102 new Date(), // issued_date
103 new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // due_date (+30 days)
104 `Auto-generated from contract: ${contract.title}`,
112 const invoiceId = invoiceRes.rows[0].invoice_id;
114 // Copy line items to invoice
115 for (const lineItem of lineItemsRes.rows) {
117 INSERT INTO invoice_items (
125 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
129 lineItem.description || lineItem.product_name,
132 lineItem.tax_rate || 0,
133 parseFloat(lineItem.quantity) * parseFloat(lineItem.unit_price)
137 // Calculate next billing date
138 let nextBillingDate = null;
139 const currentBillingDate = contract.next_billing_date || contract.start_date;
141 if (contract.billing_interval === 'monthly') {
142 const date = new Date(currentBillingDate);
143 date.setMonth(date.getMonth() + 1);
144 if (contract.billing_day) {
145 const daysInNextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
146 date.setDate(Math.min(contract.billing_day, daysInNextMonth));
148 nextBillingDate = date.toISOString().split('T')[0];
149 } else if (contract.billing_interval === 'yearly') {
150 const date = new Date(currentBillingDate);
151 date.setFullYear(date.getFullYear() + 1);
152 nextBillingDate = date.toISOString().split('T')[0];
153 } else if (contract.billing_interval === 'custom' && contract.custom_billing_dates) {
154 const dates = JSON.parse(contract.custom_billing_dates);
155 const today = new Date().toISOString().split('T')[0];
156 const futureDates = dates.filter(d => d > today).sort();
157 if (futureDates.length > 0) {
158 nextBillingDate = futureDates[0];
162 // Update contract with next billing date and last billing date
166 next_billing_date = $1,
167 last_billing_date = CURRENT_DATE,
168 updated_at = CURRENT_TIMESTAMP
169 WHERE contract_id = $2
170 `, [nextBillingDate, contractId]);
172 // Record billing history
174 INSERT INTO contract_billing_history (
180 ) VALUES ($1, $2, CURRENT_DATE, $3, 'generated')
181 `, [contractId, invoiceId, totalAmount]);
183 await client.query('COMMIT');
185 console.log(`[ContractBilling] ✅ Generated invoice #${invoiceId} for contract ${contractId} (${contract.title})`);
186 console.log(`[ContractBilling] Amount: ${contract.currency || 'AUD'} ${totalAmount.toFixed(2)}`);
187 console.log(`[ContractBilling] Next billing date: ${nextBillingDate || 'N/A'}`);
197 await client.query('ROLLBACK');
198 console.error(`[ContractBilling] Error processing contract ${contractId}:`, error);
205// Schedule daily check at 2am
209async function scheduleDailyBillingCheck() {
210 // Remove existing repeatable jobs
211 const repeatableJobs = await billingQueue.getRepeatableJobs();
212 for (const job of repeatableJobs) {
213 await billingQueue.removeRepeatableByKey(job.key);
216 // Schedule daily at 2am
217 await billingQueue.add(
222 pattern: '0 2 * * *', // 2am daily
223 tz: 'Australia/Sydney'
228 console.log('[ContractBilling] Scheduled daily billing check at 2am Sydney time');
231// Process all contracts due for billing
233 * Checks for contracts due for billing, respecting per-tenant invoice advance days setting.
234 * Default advance period is 14 days (invoices generated 14 days before renewal).
236async function processAllDueContracts() {
237 console.log('[ContractBilling] Checking for contracts due for billing...');
239 const client = await pool.connect();
241 // First, ensure tenant_settings table exists
243 CREATE TABLE IF NOT EXISTS tenant_settings (
244 tenant_id INTEGER NOT NULL,
245 setting_key TEXT NOT NULL,
248 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
249 PRIMARY KEY (tenant_id, setting_key)
253 // Get all tenant-specific invoice_advance_days settings
254 const tenantSettingsRes = await client.query(`
255 SELECT tenant_id, setting_value
257 WHERE setting_key = 'invoice_advance_days'
260 const tenantAdvanceDays = {};
261 tenantSettingsRes.rows.forEach(row => {
262 const days = parseInt(row.setting_value, 10);
263 if (!isNaN(days) && days >= 0) {
264 tenantAdvanceDays[row.tenant_id] = days;
268 const defaultAdvanceDays = 14;
269 console.log(`[ContractBilling] Using default advance days: ${defaultAdvanceDays}`);
270 if (Object.keys(tenantAdvanceDays).length > 0) {
271 console.log(`[ContractBilling] Tenant overrides:`, tenantAdvanceDays);
274 // Find contracts where next_billing_date is within the advance period
275 // We'll check if next_billing_date <= CURRENT_DATE + advance_days
276 const result = await client.query(`
284 WHERE c.auto_invoice_enabled = true
285 AND c.status = 'active'
286 AND (c.end_date IS NULL OR c.end_date >= CURRENT_DATE)
287 ORDER BY c.next_billing_date
292 for (const contract of result.rows) {
293 // Get advance days for this tenant (default 14 if not set)
294 const advanceDays = tenantAdvanceDays[contract.tenant_id] ?? defaultAdvanceDays;
296 // Calculate the threshold date (today + advance days)
297 const today = new Date();
298 const thresholdDate = new Date(today);
299 thresholdDate.setDate(thresholdDate.getDate() + advanceDays);
301 const nextBillingDate = new Date(contract.next_billing_date);
303 // Queue if the billing date is on or before the threshold
304 if (nextBillingDate <= thresholdDate) {
305 console.log(`[ContractBilling] Queueing contract ${contract.contract_id}: ${contract.title} (due: ${contract.next_billing_date}, tenant advance: ${advanceDays} days)`);
306 await billingQueue.add(
308 { contractId: contract.contract_id },
315 console.log(`[ContractBilling] Queued ${queuedCount} out of ${result.rows.length} active contracts`);
322// Worker event handlers
323billingWorker.on('completed', (job, result) => {
324 if (result.success) {
325 console.log(`[ContractBilling] Job ${job.id} completed: Invoice #${result.invoiceId} generated`);
327 console.log(`[ContractBilling] Job ${job.id} completed with skip: ${result.reason}`);
331billingWorker.on('failed', (job, err) => {
332 console.error(`[ContractBilling] Job ${job?.id} failed:`, err.message);
335// Create a separate worker for daily check jobs
336const dailyCheckWorker = new Worker('contract-billing', async job => {
337 if (job.name === 'daily-check') {
338 console.log('[ContractBilling] Running daily billing check...');
339 const count = await processAllDueContracts();
340 return { checked: count };
344dailyCheckWorker.on('completed', (job, result) => {
345 if (job.name === 'daily-check') {
346 console.log(`[ContractBilling] Daily check completed. Processed ${result.checked} contracts`);
350dailyCheckWorker.on('failed', (job, err) => {
351 if (job.name === 'daily-check') {
352 console.error(`[ContractBilling] Daily check failed:`, err.message);
356// Initialize on startup
357scheduleDailyBillingCheck().catch(err => {
358 console.error('[ContractBilling] Failed to schedule daily check:', err);
361// Also run immediately on startup
362processAllDueContracts().catch(err => {
363 console.error('[ContractBilling] Failed to process contracts on startup:', err);
366console.log('[ContractBilling] Contract Billing Worker started');
368module.exports = { billingQueue, processAllDueContracts };