EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
contractBillingWorker.js
Go to the documentation of this file.
1// Contract Billing Worker: Automatically generates invoices for contracts
2const { Queue, Worker } = require('bullmq');
3const Redis = require('ioredis');
4const pool = require('./services/db');
5
6const redisConfig = require('./config/redis');
7const connection = new Redis({
8 ...redisConfig,
9 maxRetriesPerRequest: null // BullMQ requires this
10});
11
12// Create queue for billing jobs
13const billingQueue = new Queue('contract-billing', { connection });
14
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}`);
18
19 const { contractId } = job.data;
20 const client = await pool.connect();
21
22 try {
23 await client.query('BEGIN');
24
25 // Get contract details with line items
26 const contractRes = await client.query(`
27 SELECT
28 c.*,
29 cu.name as customer_name,
30 cu.email as customer_email,
31 t.name as tenant_name
32 FROM contracts c
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'
38 `, [contractId]);
39
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' };
43 }
44
45 const contract = contractRes.rows[0];
46
47 // Get contract line items
48 const lineItemsRes = await client.query(`
49 SELECT
50 cli.*,
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
57 `, [contractId]);
58
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' };
62 }
63
64 // Calculate invoice total and subtotal
65 const subtotal = lineItemsRes.rows.reduce((sum, item) => {
66 return sum + (parseFloat(item.quantity) * parseFloat(item.unit_price));
67 }, 0);
68
69 const taxRate = 0.10; // 10% GST (can be made configurable per contract)
70 const taxAmount = subtotal * taxRate;
71 const totalAmount = subtotal + taxAmount;
72
73 // Generate payment token for public payment links
74 const crypto = require('crypto');
75 const paymentToken = crypto.randomBytes(32).toString('hex');
76
77 // Create invoice using correct column names matching invoices table schema
78 const invoiceRes = await client.query(`
79 INSERT INTO invoices (
80 customer_id,
81 tenant_id,
82 amount,
83 currency,
84 status,
85 issued_date,
86 due_date,
87 description,
88 subtotal,
89 tax_rate,
90 tax_amount,
91 total,
92 payment_token
93 ) VALUES (
94 $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
95 ) RETURNING invoice_id
96 `, [
97 contract.customer_id,
98 contract.tenant_id,
99 totalAmount,
100 contract.currency || 'AUD',
101 'draft',
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}`,
105 subtotal,
106 taxRate,
107 taxAmount,
108 totalAmount,
109 paymentToken
110 ]);
111
112 const invoiceId = invoiceRes.rows[0].invoice_id;
113
114 // Copy line items to invoice
115 for (const lineItem of lineItemsRes.rows) {
116 await client.query(`
117 INSERT INTO invoice_items (
118 invoice_id,
119 product_id,
120 description,
121 quantity,
122 unit_price,
123 tax_rate,
124 line_total
125 ) VALUES ($1, $2, $3, $4, $5, $6, $7)
126 `, [
127 invoiceId,
128 lineItem.product_id,
129 lineItem.description || lineItem.product_name,
130 lineItem.quantity,
131 lineItem.unit_price,
132 lineItem.tax_rate || 0,
133 parseFloat(lineItem.quantity) * parseFloat(lineItem.unit_price)
134 ]);
135 }
136
137 // Calculate next billing date
138 let nextBillingDate = null;
139 const currentBillingDate = contract.next_billing_date || contract.start_date;
140
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));
147 }
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];
159 }
160 }
161
162 // Update contract with next billing date and last billing date
163 await client.query(`
164 UPDATE contracts
165 SET
166 next_billing_date = $1,
167 last_billing_date = CURRENT_DATE,
168 updated_at = CURRENT_TIMESTAMP
169 WHERE contract_id = $2
170 `, [nextBillingDate, contractId]);
171
172 // Record billing history
173 await client.query(`
174 INSERT INTO contract_billing_history (
175 contract_id,
176 invoice_id,
177 billing_date,
178 amount,
179 status
180 ) VALUES ($1, $2, CURRENT_DATE, $3, 'generated')
181 `, [contractId, invoiceId, totalAmount]);
182
183 await client.query('COMMIT');
184
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'}`);
188
189 return {
190 success: true,
191 invoiceId,
192 amount: totalAmount,
193 nextBillingDate
194 };
195
196 } catch (error) {
197 await client.query('ROLLBACK');
198 console.error(`[ContractBilling] Error processing contract ${contractId}:`, error);
199 throw error;
200 } finally {
201 client.release();
202 }
203}, { connection });
204
205// Schedule daily check at 2am
206/**
207 *
208 */
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);
214 }
215
216 // Schedule daily at 2am
217 await billingQueue.add(
218 'daily-check',
219 {},
220 {
221 repeat: {
222 pattern: '0 2 * * *', // 2am daily
223 tz: 'Australia/Sydney'
224 }
225 }
226 );
227
228 console.log('[ContractBilling] Scheduled daily billing check at 2am Sydney time');
229}
230
231// Process all contracts due for billing
232/**
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).
235 */
236async function processAllDueContracts() {
237 console.log('[ContractBilling] Checking for contracts due for billing...');
238
239 const client = await pool.connect();
240 try {
241 // First, ensure tenant_settings table exists
242 await client.query(`
243 CREATE TABLE IF NOT EXISTS tenant_settings (
244 tenant_id INTEGER NOT NULL,
245 setting_key TEXT NOT NULL,
246 setting_value TEXT,
247 description TEXT,
248 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
249 PRIMARY KEY (tenant_id, setting_key)
250 )
251 `);
252
253 // Get all tenant-specific invoice_advance_days settings
254 const tenantSettingsRes = await client.query(`
255 SELECT tenant_id, setting_value
256 FROM tenant_settings
257 WHERE setting_key = 'invoice_advance_days'
258 `);
259
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;
265 }
266 });
267
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);
272 }
273
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(`
277 SELECT
278 c.contract_id,
279 c.title,
280 c.next_billing_date,
281 c.customer_id,
282 c.tenant_id
283 FROM contracts c
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
288 `);
289
290 let queuedCount = 0;
291
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;
295
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);
300
301 const nextBillingDate = new Date(contract.next_billing_date);
302
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(
307 'generate-invoice',
308 { contractId: contract.contract_id },
309 { priority: 1 }
310 );
311 queuedCount++;
312 }
313 }
314
315 console.log(`[ContractBilling] Queued ${queuedCount} out of ${result.rows.length} active contracts`);
316 return queuedCount;
317 } finally {
318 client.release();
319 }
320}
321
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`);
326 } else {
327 console.log(`[ContractBilling] Job ${job.id} completed with skip: ${result.reason}`);
328 }
329});
330
331billingWorker.on('failed', (job, err) => {
332 console.error(`[ContractBilling] Job ${job?.id} failed:`, err.message);
333});
334
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 };
341 }
342}, { connection });
343
344dailyCheckWorker.on('completed', (job, result) => {
345 if (job.name === 'daily-check') {
346 console.log(`[ContractBilling] Daily check completed. Processed ${result.checked} contracts`);
347 }
348});
349
350dailyCheckWorker.on('failed', (job, err) => {
351 if (job.name === 'daily-check') {
352 console.error(`[ContractBilling] Daily check failed:`, err.message);
353 }
354});
355
356// Initialize on startup
357scheduleDailyBillingCheck().catch(err => {
358 console.error('[ContractBilling] Failed to schedule daily check:', err);
359});
360
361// Also run immediately on startup
362processAllDueContracts().catch(err => {
363 console.error('[ContractBilling] Failed to process contracts on startup:', err);
364});
365
366console.log('[ContractBilling] Contract Billing Worker started');
367
368module.exports = { billingQueue, processAllDueContracts };