import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; const MONTH_NAMES = [ '', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const MONTH_ABBREV = [ '', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; @Injectable() export class InvoicesService { constructor(private tenant: TenantService) {} async findAll() { return this.tenant.query(` SELECT i.*, u.unit_number, u.owner_name, ag.name as assessment_group_name, ag.frequency, (i.amount - i.amount_paid) as balance_due FROM invoices i JOIN units u ON u.id = i.unit_id LEFT JOIN assessment_groups ag ON ag.id = i.assessment_group_id ORDER BY i.invoice_date DESC, i.invoice_number DESC `); } async findOne(id: string) { const rows = await this.tenant.query(` SELECT i.*, u.unit_number, u.owner_name FROM invoices i JOIN units u ON u.id = i.unit_id WHERE i.id = $1`, [id]); if (!rows.length) throw new NotFoundException('Invoice not found'); return rows[0]; } /** * Calculate billing period based on frequency and the billing month. */ private calculatePeriod(frequency: string, month: number, year: number): { start: string; end: string; description: string } { switch (frequency) { case 'quarterly': { // Period covers 3 months starting from the billing month const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month + 2, 0); // last day of month+2 const endMonth = month + 2 > 12 ? month + 2 - 12 : month + 2; const quarter = Math.ceil(month / 3); return { start: startDate.toISOString().split('T')[0], end: endDate.toISOString().split('T')[0], description: `Q${quarter} ${year} Assessment (${MONTH_ABBREV[month]}-${MONTH_ABBREV[endMonth]})`, }; } case 'annual': { const startDate = new Date(year, 0, 1); const endDate = new Date(year, 11, 31); return { start: startDate.toISOString().split('T')[0], end: endDate.toISOString().split('T')[0], description: `Annual Assessment ${year}`, }; } default: { // monthly const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0); // last day of month return { start: startDate.toISOString().split('T')[0], end: endDate.toISOString().split('T')[0], description: `Monthly Assessment - ${MONTH_NAMES[month]} ${year}`, }; } } } /** * Preview which groups/units will be billed for a given month/year. */ async generatePreview(dto: { month: number; year: number }) { const allGroups = await this.tenant.query( `SELECT ag.*, (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as active_units FROM assessment_groups ag WHERE ag.is_active = true ORDER BY ag.name`, ); const groups = allGroups.map((g: any) => { const dueMonths: number[] = g.due_months || [1,2,3,4,5,6,7,8,9,10,11,12]; const isBillingMonth = dueMonths.includes(dto.month); const activeUnits = parseInt(g.active_units || '0'); const totalAmount = isBillingMonth ? (parseFloat(g.regular_assessment) + parseFloat(g.special_assessment || '0')) * activeUnits : 0; const period = this.calculatePeriod(g.frequency || 'monthly', dto.month, dto.year); return { id: g.id, name: g.name, frequency: g.frequency || 'monthly', due_months: dueMonths, active_units: activeUnits, regular_assessment: g.regular_assessment, special_assessment: g.special_assessment, is_billing_month: isBillingMonth, total_amount: totalAmount, period_description: period.description, }; }); const billableGroups = groups.filter((g: any) => g.is_billing_month && g.active_units > 0); const totalInvoices = billableGroups.reduce((sum: number, g: any) => sum + g.active_units, 0); const totalAmount = billableGroups.reduce((sum: number, g: any) => sum + g.total_amount, 0); return { month: dto.month, year: dto.year, month_name: MONTH_NAMES[dto.month], groups, summary: { total_groups_billing: billableGroups.length, total_invoices: totalInvoices, total_amount: totalAmount }, }; } /** * Generate invoices for all assessment groups where the given month is a billing month. */ async generateBulk(dto: { month: number; year: number }, userId: string) { // Get assessment groups where this month is a billing month const groups = await this.tenant.query( `SELECT * FROM assessment_groups WHERE is_active = true AND $1 = ANY(due_months)`, [dto.month], ); if (!groups.length) { throw new BadRequestException(`No assessment groups have billing scheduled for ${MONTH_NAMES[dto.month]}`); } // Get or create fiscal period let fp = await this.tenant.query( 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [dto.year, dto.month], ); if (!fp.length) { fp = await this.tenant.query( `INSERT INTO fiscal_periods (year, month, status) VALUES ($1, $2, 'open') RETURNING id`, [dto.year, dto.month], ); } const fiscalPeriodId = fp[0].id; // Look up GL accounts once const arAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '1200'`); const incomeAccount = await this.tenant.query(`SELECT id FROM accounts WHERE account_number = '4000'`); let created = 0; const groupResults: any[] = []; for (const group of groups) { // Get active units in this assessment group const units = await this.tenant.query( `SELECT * FROM units WHERE status = 'active' AND assessment_group_id = $1`, [group.id], ); if (!units.length) continue; const frequency = group.frequency || 'monthly'; const period = this.calculatePeriod(frequency, dto.month, dto.year); const dueDay = Math.min(group.due_day || 1, 28); const invoiceDate = new Date(dto.year, dto.month - 1, 1); const dueDate = new Date(dto.year, dto.month - 1, dueDay); // Use the group's assessment amount (full period amount, not monthly equivalent) const assessmentAmount = parseFloat(group.regular_assessment) + parseFloat(group.special_assessment || '0'); let groupCreated = 0; for (const unit of units) { const invNum = `INV-${dto.year}${String(dto.month).padStart(2, '0')}-${unit.unit_number}`; // Check if already generated const existing = await this.tenant.query( 'SELECT id FROM invoices WHERE invoice_number = $1', [invNum], ); if (existing.length) continue; // Use unit-level override if set, otherwise use group amount const unitAmount = unit.monthly_assessment && parseFloat(unit.monthly_assessment) > 0 ? (frequency === 'monthly' ? parseFloat(unit.monthly_assessment) : frequency === 'quarterly' ? parseFloat(unit.monthly_assessment) * 3 : parseFloat(unit.monthly_assessment) * 12) : assessmentAmount; // Create the invoice with status 'pending' (no email sending capability) const inv = await this.tenant.query( `INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status, period_start, period_end, assessment_group_id) VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'pending', $7, $8, $9) RETURNING id`, [invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0], period.description, unitAmount, period.start, period.end, group.id], ); // Create journal entry: DR Accounts Receivable, CR Assessment Income if (arAccount.length && incomeAccount.length) { const je = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, source_type, source_id, is_posted, posted_at, created_by) VALUES ($1, $2, 'assessment', $3, 'invoice', $4, true, NOW(), $5) RETURNING id`, [invoiceDate.toISOString().split('T')[0], `Assessment - Unit ${unit.unit_number}`, fiscalPeriodId, inv[0].id, userId], ); await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit) VALUES ($1, $2, $3, 0), ($1, $4, 0, $3)`, [je[0].id, arAccount[0].id, unitAmount, incomeAccount[0].id], ); await this.tenant.query( `UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id], ); } created++; groupCreated++; } groupResults.push({ group_name: group.name, frequency, period: period.description, invoices_created: groupCreated, }); } return { created, month: dto.month, year: dto.year, groups: groupResults }; } async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - dto.grace_period_days); const cutoffStr = cutoff.toISOString().split('T')[0]; const overdue = await this.tenant.query(` SELECT i.*, u.unit_number FROM invoices i JOIN units u ON u.id = i.unit_id WHERE i.status IN ('pending', 'partial') AND i.due_date < $1 AND NOT EXISTS ( SELECT 1 FROM invoices lf WHERE lf.unit_id = i.unit_id AND lf.invoice_type = 'late_fee' AND lf.description LIKE '%' || i.invoice_number || '%' ) `, [cutoffStr]); let applied = 0; for (const inv of overdue) { await this.tenant.query(`UPDATE invoices SET status = 'overdue' WHERE id = $1`, [inv.id]); const lfNum = `LF-${inv.invoice_number}`; await this.tenant.query( `INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status) VALUES ($1, $2, CURRENT_DATE, CURRENT_DATE + INTERVAL '15 days', 'late_fee', $3, $4, 'pending')`, [lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount], ); applied++; } return { applied }; } }