From 1e31595d7fa7de007275770c34d371556e6fd7e5 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Fri, 6 Mar 2026 19:08:56 -0500 Subject: [PATCH] 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 --- backend/src/database/tenant-schema.service.ts | 5 + .../assessment-groups.service.ts | 67 +++++- .../modules/invoices/invoices.controller.ts | 5 + .../src/modules/invoices/invoices.service.ts | 217 ++++++++++++++---- .../011-invoice-billing-frequency.sql | 57 +++++ db/seed/seed.sql | 14 +- .../AssessmentGroupsPage.tsx | 96 +++++++- frontend/src/pages/invoices/InvoicesPage.tsx | 152 ++++++++++-- frontend/src/pages/payments/PaymentsPage.tsx | 21 +- 9 files changed, 560 insertions(+), 74 deletions(-) create mode 100644 db/migrations/011-invoice-billing-frequency.sql diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index d5e8d41..3f2a7d9 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -112,6 +112,8 @@ export class TenantSchemaService { special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, frequency VARCHAR(20) DEFAULT 'monthly' CHECK (frequency IN ('monthly', 'quarterly', 'annual')), + due_months INTEGER[] DEFAULT '{1,2,3,4,5,6,7,8,9,10,11,12}', + due_day INTEGER DEFAULT 1, is_default BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), @@ -157,6 +159,9 @@ export class TenantSchemaService { status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ( 'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' )), + period_start DATE, + period_end DATE, + assessment_group_id UUID REFERENCES "${s}".assessment_groups(id), journal_entry_id UUID REFERENCES "${s}".journal_entries(id), sent_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, diff --git a/backend/src/modules/assessment-groups/assessment-groups.service.ts b/backend/src/modules/assessment-groups/assessment-groups.service.ts index 6d2cb35..af97525 100644 --- a/backend/src/modules/assessment-groups/assessment-groups.service.ts +++ b/backend/src/modules/assessment-groups/assessment-groups.service.ts @@ -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 = { + 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()'); diff --git a/backend/src/modules/invoices/invoices.controller.ts b/backend/src/modules/invoices/invoices.controller.ts index 672dc6f..b728330 100644 --- a/backend/src/modules/invoices/invoices.controller.ts +++ b/backend/src/modules/invoices/invoices.controller.ts @@ -16,6 +16,11 @@ export class InvoicesController { @Get(':id') findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); } + @Post('generate-preview') + generatePreview(@Body() dto: { month: number; year: number }) { + return this.invoicesService.generatePreview(dto); + } + @Post('generate-bulk') generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) { return this.invoicesService.generateBulk(dto, req.user.sub); diff --git a/backend/src/modules/invoices/invoices.service.ts b/backend/src/modules/invoices/invoices.service.ts index 1d703af..980f848 100644 --- a/backend/src/modules/invoices/invoices.service.ts +++ b/backend/src/modules/invoices/invoices.service.ts @@ -1,16 +1,27 @@ 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, + SELECT i.*, u.unit_number, 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 `); } @@ -23,11 +34,102 @@ export class InvoicesService { 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`, + /** + * 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`, ); - if (!units.length) throw new BadRequestException('No active units with assessments found'); + + 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( @@ -41,50 +143,87 @@ export class InvoicesService { } 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); + // 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 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], + 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], ); - // 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 (!units.length) continue; - 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], + 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], ); - 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], + 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 + 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, 'sent', $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++; } - created++; + + groupResults.push({ + group_name: group.name, + frequency, + period: period.description, + invoices_created: groupCreated, + }); } - return { created, month: dto.month, year: dto.year }; + return { created, month: dto.month, year: dto.year, groups: groupResults }; } async applyLateFees(dto: { grace_period_days: number; late_fee_amount: number }, userId: string) { diff --git a/db/migrations/011-invoice-billing-frequency.sql b/db/migrations/011-invoice-billing-frequency.sql new file mode 100644 index 0000000..8c3cc21 --- /dev/null +++ b/db/migrations/011-invoice-billing-frequency.sql @@ -0,0 +1,57 @@ +-- Migration 011: Add billing frequency support to invoices +-- Adds due_months and due_day to assessment_groups +-- Adds period_start, period_end, assessment_group_id to invoices + +DO $$ +DECLARE + v_schema TEXT; +BEGIN + FOR v_schema IN + SELECT schema_name FROM information_schema.schemata + WHERE schema_name LIKE 'tenant_%' + LOOP + -- Add due_months and due_day to assessment_groups + EXECUTE format(' + ALTER TABLE %I.assessment_groups + ADD COLUMN IF NOT EXISTS due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'', + ADD COLUMN IF NOT EXISTS due_day INTEGER DEFAULT 1 + ', v_schema); + + -- Add period tracking and assessment group link to invoices + EXECUTE format(' + ALTER TABLE %I.invoices + ADD COLUMN IF NOT EXISTS period_start DATE, + ADD COLUMN IF NOT EXISTS period_end DATE, + ADD COLUMN IF NOT EXISTS assessment_group_id UUID + ', v_schema); + + -- Backfill due_months based on existing frequency values + EXECUTE format(' + UPDATE %I.assessment_groups + SET due_months = CASE frequency + WHEN ''quarterly'' THEN ''{1,4,7,10}''::INTEGER[] + WHEN ''annual'' THEN ''{1}''::INTEGER[] + ELSE ''{1,2,3,4,5,6,7,8,9,10,11,12}''::INTEGER[] + END + WHERE due_months IS NULL OR due_months = ''{1,2,3,4,5,6,7,8,9,10,11,12}'' + AND frequency != ''monthly'' + ', v_schema); + + -- Backfill period_start/period_end for existing invoices (all monthly) + EXECUTE format(' + UPDATE %I.invoices + SET period_start = invoice_date, + period_end = (invoice_date + INTERVAL ''1 month'' - INTERVAL ''1 day'')::DATE + WHERE period_start IS NULL AND invoice_type = ''regular_assessment'' + ', v_schema); + + -- Backfill assessment_group_id on existing invoices from units + EXECUTE format(' + UPDATE %I.invoices i + SET assessment_group_id = u.assessment_group_id + FROM %I.units u + WHERE i.unit_id = u.id AND i.assessment_group_id IS NULL + ', v_schema, v_schema); + + END LOOP; +END $$; diff --git a/db/seed/seed.sql b/db/seed/seed.sql index cdc4be8..c6ef1f1 100644 --- a/db/seed/seed.sql +++ b/db/seed/seed.sql @@ -204,7 +204,10 @@ CREATE TABLE IF NOT EXISTS %I.assessment_groups ( special_assessment DECIMAL(10,2) DEFAULT 0.00, unit_count INTEGER DEFAULT 0, frequency VARCHAR(20) DEFAULT ''monthly'', + due_months INTEGER[] DEFAULT ''{1,2,3,4,5,6,7,8,9,10,11,12}'', + due_day INTEGER DEFAULT 1, is_active BOOLEAN DEFAULT TRUE, + is_default BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )', v_schema); @@ -244,6 +247,9 @@ CREATE TABLE IF NOT EXISTS %I.invoices ( amount DECIMAL(10,2) NOT NULL, amount_paid DECIMAL(10,2) DEFAULT 0.00, status VARCHAR(20) DEFAULT ''draft'', + period_start DATE, + period_end DATE, + assessment_group_id UUID, journal_entry_id UUID, sent_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, @@ -443,10 +449,10 @@ END LOOP; -- ============================================================ -- 4b. Seed Assessment Groups -- ============================================================ -EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count) VALUES - (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20), - (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 425.00, 0.00, 15), - (''Estate Lots'', ''Large estate lots (Units 36-50)'', 500.00, 75.00, 15) +EXECUTE format('INSERT INTO %I.assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, due_months, due_day) VALUES + (''Single Family Homes'', ''Standard single family detached homes (Units 1-20)'', 350.00, 0.00, 20, ''monthly'', ''{1,2,3,4,5,6,7,8,9,10,11,12}'', 15), + (''Patio Homes'', ''Medium-sized patio homes (Units 21-35)'', 1275.00, 0.00, 15, ''quarterly'', ''{1,4,7,10}'', 1), + (''Estate Lots'', ''Large estate lots (Units 36-50)'', 6000.00, 900.00, 15, ''annual'', ''{3}'', 1) ', v_schema); -- ============================================================ diff --git a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx index 6e90dbe..f8a44ba 100644 --- a/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx +++ b/frontend/src/pages/assessment-groups/AssessmentGroupsPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center, ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip, + MultiSelect, } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; @@ -21,6 +22,8 @@ interface AssessmentGroup { special_assessment: string; unit_count: number; frequency: string; + due_months: number[]; + due_day: number; actual_unit_count: string; monthly_operating_income: string; monthly_reserve_income: string; @@ -49,6 +52,29 @@ const frequencyColors: Record = { annual: 'violet', }; +const MONTH_OPTIONS = [ + { value: '1', label: 'January' }, + { value: '2', label: 'February' }, + { value: '3', label: 'March' }, + { value: '4', label: 'April' }, + { value: '5', label: 'May' }, + { value: '6', label: 'June' }, + { value: '7', label: 'July' }, + { value: '8', label: 'August' }, + { value: '9', label: 'September' }, + { value: '10', label: 'October' }, + { value: '11', label: 'November' }, + { value: '12', label: 'December' }, +]; + +const MONTH_ABBREV = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const DEFAULT_DUE_MONTHS: Record = { + monthly: ['1','2','3','4','5','6','7','8','9','10','11','12'], + quarterly: ['1','4','7','10'], + annual: ['1'], +}; + export function AssessmentGroupsPage() { const [opened, { open, close }] = useDisclosure(false); const [editing, setEditing] = useState(null); @@ -73,18 +99,31 @@ export function AssessmentGroupsPage() { specialAssessment: 0, unitCount: 0, frequency: 'monthly', + dueMonths: DEFAULT_DUE_MONTHS.monthly, + dueDay: 1, }, validate: { name: (v) => (v.length > 0 ? null : 'Required'), regularAssessment: (v) => (v >= 0 ? null : 'Must be >= 0'), + dueMonths: (v, values) => { + if (values.frequency === 'quarterly' && v.length !== 4) return 'Quarterly requires exactly 4 months'; + if (values.frequency === 'annual' && v.length !== 1) return 'Annual requires exactly 1 month'; + return null; + }, + dueDay: (v) => (v >= 1 && v <= 28 ? null : 'Must be 1-28'), }, }); const saveMutation = useMutation({ - mutationFn: (values: any) => - editing - ? api.put(`/assessment-groups/${editing.id}`, values) - : api.post('/assessment-groups', values), + mutationFn: (values: any) => { + const payload = { + ...values, + dueMonths: values.dueMonths.map(Number), + }; + return editing + ? api.put(`/assessment-groups/${editing.id}`, payload) + : api.post('/assessment-groups', payload); + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['assessment-groups'] }); queryClient.invalidateQueries({ queryKey: ['assessment-groups-summary'] }); @@ -121,6 +160,9 @@ export function AssessmentGroupsPage() { const handleEdit = (group: AssessmentGroup) => { setEditing(group); + const dueMonths = group.due_months + ? group.due_months.map(String) + : DEFAULT_DUE_MONTHS[group.frequency] || DEFAULT_DUE_MONTHS.monthly; form.setValues({ name: group.name, description: group.description || '', @@ -128,6 +170,8 @@ export function AssessmentGroupsPage() { specialAssessment: parseFloat(group.special_assessment || '0'), unitCount: group.unit_count || 0, frequency: group.frequency || 'monthly', + dueMonths, + dueDay: group.due_day || 1, }); open(); }; @@ -138,6 +182,12 @@ export function AssessmentGroupsPage() { open(); }; + const handleFrequencyChange = (value: string | null) => { + if (!value) return; + form.setFieldValue('frequency', value); + form.setFieldValue('dueMonths', DEFAULT_DUE_MONTHS[value] || DEFAULT_DUE_MONTHS.monthly); + }; + const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); @@ -149,6 +199,11 @@ export function AssessmentGroupsPage() { } }; + const formatDueMonths = (months: number[], frequency: string) => { + if (!months || frequency === 'monthly') return 'Every month'; + return months.map((m) => MONTH_ABBREV[m]).join(', '); + }; + if (isLoading) return
; return ( @@ -219,6 +274,7 @@ export function AssessmentGroupsPage() { Group Name Units Frequency + Due Months Regular Assessment Special Assessment Monthly Equiv. @@ -229,7 +285,7 @@ export function AssessmentGroupsPage() { {groups.length === 0 && ( - + No assessment groups yet. Create groups like "Single Family Homes", "Condos", etc. @@ -263,6 +319,9 @@ export function AssessmentGroupsPage() { {frequencyLabels[g.frequency] || 'Monthly'} + + {formatDueMonths(g.due_months, g.frequency)} + {fmt(g.regular_assessment)}{freqSuffix(g.frequency)} @@ -322,8 +381,22 @@ export function AssessmentGroupsPage() { { value: 'quarterly', label: 'Quarterly' }, { value: 'annual', label: 'Annual' }, ]} - {...form.getInputProps('frequency')} + value={form.values.frequency} + onChange={handleFrequencyChange} /> + {form.values.frequency !== 'monthly' && ( + form.setFieldValue('dueMonths', v)} + error={form.errors.dueMonths} + maxValues={form.values.frequency === 'annual' ? 1 : 4} + /> + )} - + + + + diff --git a/frontend/src/pages/invoices/InvoicesPage.tsx b/frontend/src/pages/invoices/InvoicesPage.tsx index eaa9bf7..577543f 100644 --- a/frontend/src/pages/invoices/InvoicesPage.tsx +++ b/frontend/src/pages/invoices/InvoicesPage.tsx @@ -1,13 +1,12 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Title, Table, Group, Button, Stack, Text, Badge, Modal, - NumberInput, Select, Loader, Center, Card, + NumberInput, Select, Loader, Center, Card, Alert, } from '@mantine/core'; -import { DateInput } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconFileInvoice, IconSend } from '@tabler/icons-react'; +import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; @@ -15,15 +14,48 @@ interface Invoice { id: string; invoice_number: string; unit_number: string; unit_id: string; invoice_date: string; due_date: string; invoice_type: string; description: string; amount: string; amount_paid: string; balance_due: string; - status: string; + status: string; period_start: string; period_end: string; + assessment_group_name: string; frequency: string; +} + +interface PreviewGroup { + id: string; + name: string; + frequency: string; + active_units: number; + regular_assessment: string; + special_assessment: string; + is_billing_month: boolean; + total_amount: number; + period_description: string; +} + +interface Preview { + month: number; + year: number; + month_name: string; + groups: PreviewGroup[]; + summary: { + total_groups_billing: number; + total_invoices: number; + total_amount: number; + }; } const statusColors: Record = { draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark', }; +const frequencyColors: Record = { + monthly: 'blue', quarterly: 'teal', annual: 'violet', +}; + +const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + export function InvoicesPage() { const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); const queryClient = useQueryClient(); const { data: invoices = [], isLoading } = useQuery({ @@ -35,13 +67,36 @@ export function InvoicesPage() { initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() }, }); + // Fetch preview when month/year changes + const fetchPreview = async (month: number, year: number) => { + setPreviewLoading(true); + try { + const { data } = await api.post('/invoices/generate-preview', { month, year }); + setPreview(data); + } catch { + setPreview(null); + } + setPreviewLoading(false); + }; + + useEffect(() => { + if (bulkOpened) { + fetchPreview(bulkForm.values.month, bulkForm.values.year); + } + }, [bulkOpened, bulkForm.values.month, bulkForm.values.year]); + const bulkMutation = useMutation({ mutationFn: (values: any) => api.post('/invoices/generate-bulk', values), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['journal-entries'] }); - notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' }); + const groupInfo = res.data.groups?.map((g: any) => `${g.group_name}: ${g.invoices_created}`).join(', ') || ''; + notifications.show({ + message: `Generated ${res.data.created} invoices${groupInfo ? ` (${groupInfo})` : ''}`, + color: 'green', + }); closeBulk(); + setPreview(null); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, }); @@ -54,8 +109,6 @@ export function InvoicesPage() { }, }); - const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' }); - if (isLoading) return
; const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0); @@ -66,18 +119,19 @@ export function InvoicesPage() { Invoices - + Total Invoices{invoices.length} - Outstanding{fmt(String(totalOutstanding))} + Outstanding{fmt(totalOutstanding)} Invoice #UnitDate - DueTypeAmount + DueTypePeriod + Amount PaidBalanceStatus @@ -89,24 +143,92 @@ export function InvoicesPage() { {new Date(i.invoice_date).toLocaleDateString()}{new Date(i.due_date).toLocaleDateString()}{i.invoice_type} + + {i.period_start && i.period_end ? ( + + {new Date(i.period_start).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })} + {i.period_start !== i.period_end && ( + <> - {new Date(i.period_end).toLocaleDateString(undefined, { month: 'short', year: 'numeric' })} + )} + + ) : ( + - + )} + {fmt(i.amount)}{fmt(i.amount_paid)}{fmt(i.balance_due)}{i.status} ))} - {invoices.length === 0 && No invoices yet} + {invoices.length === 0 && No invoices yet}
- + + { closeBulk(); setPreview(null); }} title="Generate Assessments" size="lg">
bulkMutation.mutate(v))}>