Change account_number from INTEGER to VARCHAR(50) to support segmented codes like 30-3001-0000 used by real HOA accounting systems. Budget CSV import now: - Auto-creates income/expense accounts from CSV when they don't exist - Infers account_type and fund_type from account number prefix conventions - Parses currency-formatted values ($48,065.21, $(13,000.00), $-, etc.) - Reports created accounts back to the user Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
121 lines
5.0 KiB
TypeScript
121 lines
5.0 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { TenantService } from '../../database/tenant.service';
|
|
|
|
@Injectable()
|
|
export class InvoicesService {
|
|
constructor(private tenant: TenantService) {}
|
|
|
|
async findAll() {
|
|
return this.tenant.query(`
|
|
SELECT i.*, u.unit_number,
|
|
(i.amount - i.amount_paid) as balance_due
|
|
FROM invoices i
|
|
JOIN units u ON u.id = i.unit_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 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];
|
|
}
|
|
|
|
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`,
|
|
);
|
|
if (!units.length) throw new BadRequestException('No active units with assessments found');
|
|
|
|
// 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;
|
|
|
|
const invoiceDate = new Date(dto.year, dto.month - 1, 1);
|
|
const dueDate = new Date(dto.year, dto.month - 1, 15);
|
|
let created = 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;
|
|
|
|
// 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],
|
|
);
|
|
|
|
// 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 (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, 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],
|
|
);
|
|
}
|
|
created++;
|
|
}
|
|
|
|
return { created, month: dto.month, year: dto.year };
|
|
}
|
|
|
|
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 ('sent', '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, 'sent')`,
|
|
[lfNum, inv.unit_id, `Late fee for invoice ${inv.invoice_number}`, dto.late_fee_amount],
|
|
);
|
|
applied++;
|
|
}
|
|
|
|
return { applied };
|
|
}
|
|
}
|