Files
HOA_Financial_Platform/backend/src/modules/assessment-groups/assessment-groups.service.ts
olsch01 efa5aca35f 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>
2026-03-07 12:01:57 -05:00

193 lines
7.5 KiB
TypeScript

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) {}
async findAll() {
return this.tenant.query(`
SELECT ag.*,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
CASE ag.frequency
WHEN 'quarterly' THEN ag.regular_assessment / 3
WHEN 'annual' THEN ag.regular_assessment / 12
ELSE ag.regular_assessment
END * ag.unit_count as monthly_operating_income,
CASE ag.frequency
WHEN 'quarterly' THEN ag.special_assessment / 3
WHEN 'annual' THEN ag.special_assessment / 12
ELSE ag.special_assessment
END * ag.unit_count as monthly_reserve_income,
(CASE ag.frequency
WHEN 'quarterly' THEN (ag.regular_assessment + ag.special_assessment) / 3
WHEN 'annual' THEN (ag.regular_assessment + ag.special_assessment) / 12
ELSE ag.regular_assessment + ag.special_assessment
END) * ag.unit_count as total_monthly_income
FROM assessment_groups ag
ORDER BY ag.name
`);
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM assessment_groups WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Assessment group not found');
return rows[0];
}
async getDefault() {
const rows = await this.tenant.query(
'SELECT * FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
);
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;
const shouldBeDefault = dto.isDefault || isFirstGroup;
if (shouldBeDefault) {
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, 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, frequency, dueMonths, dueDay, shouldBeDefault],
);
return rows[0];
}
async update(id: string, dto: any) {
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');
}
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); }
if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); }
if (dto.regularAssessment !== undefined) { sets.push(`regular_assessment = $${idx++}`); params.push(dto.regularAssessment); }
if (dto.specialAssessment !== undefined) { sets.push(`special_assessment = $${idx++}`); params.push(dto.specialAssessment); }
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
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()');
params.push(id);
const rows = await this.tenant.query(
`UPDATE assessment_groups SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
return rows[0];
}
async setDefault(id: string) {
await this.findOne(id);
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
await this.tenant.query(
'UPDATE assessment_groups SET is_default = true, updated_at = NOW() WHERE id = $1',
[id],
);
return this.findOne(id);
}
async getSummary() {
const rows = await this.tenant.query(`
SELECT
COUNT(*) as group_count,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN regular_assessment / 3
WHEN 'annual' THEN regular_assessment / 12
ELSE regular_assessment
END * unit_count
), 0) as total_monthly_operating,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN special_assessment / 3
WHEN 'annual' THEN special_assessment / 12
ELSE special_assessment
END * unit_count
), 0) as total_monthly_reserve,
COALESCE(SUM(
CASE frequency
WHEN 'quarterly' THEN (regular_assessment + special_assessment) / 3
WHEN 'annual' THEN (regular_assessment + special_assessment) / 12
ELSE regular_assessment + special_assessment
END * unit_count
), 0) as total_monthly_income,
COALESCE(SUM(unit_count), 0) as total_units
FROM assessment_groups WHERE is_active = true
`);
return rows[0];
}
}