- Replace misleading 'sent' status with 'pending' (no email capability) - Show assessment group name instead of raw 'regular_assessment' type - Add owner last name to invoice table - Fix payment creation Internal Server Error (PostgreSQL $2 type cast) - Add edit/delete capability for payment records with invoice recalc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
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 };
|
|
}
|
|
}
|