feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly, annual) with configurable due months and due day. Invoice generation respects each group's schedule - only generating invoices when the selected month is a billing month for that group. Adds a generation preview showing which groups will be billed, period tracking on invoices, and billing period context in the payments UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,8 @@ export class TenantSchemaService {
|
||||
special_assessment DECIMAL(10,2) DEFAULT 0.00,
|
||||
unit_count INTEGER DEFAULT 0,
|
||||
frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')),
|
||||
due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}',
|
||||
due_day INTEGER DEFAULT 1,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -157,6 +159,9 @@ export class TenantSchemaService {
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN (
|
||||
'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off'
|
||||
)),
|
||||
period_start DATE,
|
||||
period_end DATE,
|
||||
assessment_group_id UUID REFERENCES "${s}".assessment_groups(id),
|
||||
journal_entry_id UUID REFERENCES "${s}".journal_entries(id),
|
||||
sent_at TIMESTAMPTZ,
|
||||
paid_at TIMESTAMPTZ,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { TenantService } from '../../database/tenant.service';
|
||||
|
||||
const DEFAULT_DUE_MONTHS: Record<string, number[]> = {
|
||||
monthly: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
quarterly: [1, 4, 7, 10],
|
||||
annual: [1],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AssessmentGroupsService {
|
||||
constructor(private tenant: TenantService) {}
|
||||
@@ -42,6 +48,33 @@ export class AssessmentGroupsService {
|
||||
return rows.length ? rows[0] : null;
|
||||
}
|
||||
|
||||
private validateDueMonths(frequency: string, dueMonths: number[]) {
|
||||
if (!dueMonths || !dueMonths.length) {
|
||||
throw new BadRequestException('Due months are required');
|
||||
}
|
||||
// Validate all values are 1-12
|
||||
if (dueMonths.some((m) => m < 1 || m > 12 || !Number.isInteger(m))) {
|
||||
throw new BadRequestException('Due months must be integers between 1 and 12');
|
||||
}
|
||||
switch (frequency) {
|
||||
case 'monthly':
|
||||
if (dueMonths.length !== 12) {
|
||||
throw new BadRequestException('Monthly frequency must include all 12 months');
|
||||
}
|
||||
break;
|
||||
case 'quarterly':
|
||||
if (dueMonths.length !== 4) {
|
||||
throw new BadRequestException('Quarterly frequency must have exactly 4 due months');
|
||||
}
|
||||
break;
|
||||
case 'annual':
|
||||
if (dueMonths.length !== 1) {
|
||||
throw new BadRequestException('Annual frequency must have exactly 1 due month');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: any) {
|
||||
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
|
||||
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
|
||||
@@ -51,17 +84,23 @@ export class AssessmentGroupsService {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
}
|
||||
|
||||
const frequency = dto.frequency || 'monthly';
|
||||
const dueMonths = dto.dueMonths || DEFAULT_DUE_MONTHS[frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||
const dueDay = Math.min(Math.max(dto.dueDay || 1, 1), 28);
|
||||
|
||||
this.validateDueMonths(frequency, dueMonths);
|
||||
|
||||
const rows = await this.tenant.query(
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
|
||||
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
|
||||
dto.unitCount || 0, frequency, dueMonths, dueDay, shouldBeDefault],
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(id: string, dto: any) {
|
||||
await this.findOne(id);
|
||||
const existing = await this.findOne(id);
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
|
||||
@@ -80,6 +119,24 @@ export class AssessmentGroupsService {
|
||||
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
|
||||
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
|
||||
|
||||
// Handle due_months: if frequency changed and no explicit dueMonths, auto-populate defaults
|
||||
const effectiveFrequency = dto.frequency || existing.frequency;
|
||||
if (dto.dueMonths !== undefined) {
|
||||
this.validateDueMonths(effectiveFrequency, dto.dueMonths);
|
||||
sets.push(`due_months = $${idx++}`);
|
||||
params.push(dto.dueMonths);
|
||||
} else if (dto.frequency !== undefined && dto.frequency !== existing.frequency) {
|
||||
// Frequency changed, auto-populate due_months
|
||||
const newDueMonths = DEFAULT_DUE_MONTHS[dto.frequency] || DEFAULT_DUE_MONTHS.monthly;
|
||||
sets.push(`due_months = $${idx++}`);
|
||||
params.push(newDueMonths);
|
||||
}
|
||||
|
||||
if (dto.dueDay !== undefined) {
|
||||
sets.push(`due_day = $${idx++}`);
|
||||
params.push(Math.min(Math.max(dto.dueDay, 1), 28));
|
||||
}
|
||||
|
||||
if (!sets.length) return this.findOne(id);
|
||||
|
||||
sets.push('updated_at = NOW()');
|
||||
|
||||
@@ -16,6 +16,11 @@ export class InvoicesController {
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||
|
||||
@Post('generate-preview')
|
||||
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||
return this.invoicesService.generatePreview(dto);
|
||||
}
|
||||
|
||||
@Post('generate-bulk')
|
||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
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,
|
||||
SELECT i.*, u.unit_number, 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
|
||||
`);
|
||||
}
|
||||
@@ -23,11 +34,102 @@ export class InvoicesService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async generateBulk(dto: { month: number; year: number }, userId: string) {
|
||||
const units = await this.tenant.query(
|
||||
`SELECT * FROM units WHERE status = 'active' AND monthly_assessment > 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`,
|
||||
);
|
||||
if (!units.length) throw new BadRequestException('No active units with assessments found');
|
||||
|
||||
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(
|
||||
@@ -41,50 +143,87 @@ export class InvoicesService {
|
||||
}
|
||||
const fiscalPeriodId = fp[0].id;
|
||||
|
||||
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
||||
const dueDate = new Date(dto.year, dto.month - 1, 15);
|
||||
// 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 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;
|
||||
|
||||
// Create the invoice
|
||||
const inv = await this.tenant.query(
|
||||
`INSERT INTO invoices (invoice_number, unit_id, invoice_date, due_date, invoice_type, description, amount, status)
|
||||
VALUES ($1, $2, $3, $4, 'regular_assessment', $5, $6, 'sent') RETURNING id`,
|
||||
[invNum, unit.id, invoiceDate.toISOString().split('T')[0], dueDate.toISOString().split('T')[0],
|
||||
`Monthly assessment - ${new Date(dto.year, dto.month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })}`,
|
||||
unit.monthly_assessment],
|
||||
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],
|
||||
);
|
||||
|
||||
// Create journal entry: DR Accounts Receivable, CR Assessment Income
|
||||
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'`);
|
||||
if (!units.length) continue;
|
||||
|
||||
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],
|
||||
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],
|
||||
);
|
||||
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, unit.monthly_assessment, incomeAccount[0].id],
|
||||
);
|
||||
await this.tenant.query(
|
||||
`UPDATE invoices SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, inv[0].id],
|
||||
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
|
||||
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, 'sent', $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++;
|
||||
}
|
||||
created++;
|
||||
|
||||
groupResults.push({
|
||||
group_name: group.name,
|
||||
frequency,
|
||||
period: period.description,
|
||||
invoices_created: groupCreated,
|
||||
});
|
||||
}
|
||||
|
||||
return { created, month: dto.month, year: dto.year };
|
||||
return { created, month: dto.month, year: dto.year, groups: groupResults };
|
||||
}
|
||||
|
||||
async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) {
|
||||
|
||||
Reference in New Issue
Block a user