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:
@@ -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()');
|
||||
|
||||
Reference in New Issue
Block a user