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>
193 lines
7.5 KiB
TypeScript
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];
|
|
}
|
|
}
|