From 2b83defbc30ea5d72841616d8c6332abed6de84a Mon Sep 17 00:00:00 2001 From: olsch01 Date: Sat, 7 Mar 2026 11:53:54 -0500 Subject: [PATCH] fix: resolve 5 invoice/payment issues from user feedback - 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 --- backend/src/database/tenant-schema.service.ts | 2 +- .../src/modules/invoices/invoices.service.ts | 12 +- .../modules/payments/payments.controller.ts | 10 +- .../src/modules/payments/payments.service.ts | 84 ++++++++- db/migrations/012-invoice-status-pending.sql | 33 ++++ frontend/src/pages/invoices/InvoicesPage.tsx | 29 +++- frontend/src/pages/payments/PaymentsPage.tsx | 162 ++++++++++++++++-- 7 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 db/migrations/012-invoice-status-pending.sql diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 3f2a7d9..a4bb485 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -157,7 +157,7 @@ export class TenantSchemaService { amount DECIMAL(10,2) NOT NULL, amount_paid DECIMAL(10,2) DEFAULT 0.00, status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ( - 'draft', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' + 'draft', 'pending', 'sent', 'paid', 'partial', 'overdue', 'void', 'written_off' )), period_start DATE, period_end DATE, diff --git a/backend/src/modules/invoices/invoices.service.ts b/backend/src/modules/invoices/invoices.service.ts index 980f848..24d5147 100644 --- a/backend/src/modules/invoices/invoices.service.ts +++ b/backend/src/modules/invoices/invoices.service.ts @@ -17,7 +17,7 @@ export class InvoicesService { async findAll() { return this.tenant.query(` - SELECT i.*, u.unit_number, ag.name as assessment_group_name, ag.frequency, + 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 @@ -28,7 +28,7 @@ export class InvoicesService { async findOne(id: string) { const rows = await this.tenant.query(` - SELECT i.*, u.unit_number FROM invoices i + 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]; @@ -188,10 +188,10 @@ export class InvoicesService { : parseFloat(unit.monthly_assessment) * 12) : assessmentAmount; - // Create the invoice + // 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, 'sent', $7, $8, $9) RETURNING 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], ); @@ -234,7 +234,7 @@ export class InvoicesService { 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 + 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 || '%' @@ -248,7 +248,7 @@ export class InvoicesService { 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')`, + 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++; diff --git a/backend/src/modules/payments/payments.controller.ts b/backend/src/modules/payments/payments.controller.ts index 9f8b9ed..6240b7c 100644 --- a/backend/src/modules/payments/payments.controller.ts +++ b/backend/src/modules/payments/payments.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { PaymentsService } from './payments.service'; @@ -18,4 +18,12 @@ export class PaymentsController { @Post() create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); } + + @Put(':id') + update(@Param('id') id: string, @Body() dto: any, @Request() req: any) { + return this.paymentsService.update(id, dto, req.user.sub); + } + + @Delete(':id') + delete(@Param('id') id: string) { return this.paymentsService.delete(id); } } diff --git a/backend/src/modules/payments/payments.service.ts b/backend/src/modules/payments/payments.service.ts index 2a8715c..d078139 100644 --- a/backend/src/modules/payments/payments.service.ts +++ b/backend/src/modules/payments/payments.service.ts @@ -74,17 +74,95 @@ export class PaymentsService { await this.tenant.query(`UPDATE payments SET journal_entry_id = $1 WHERE id = $2`, [je[0].id, payment[0].id]); } - // Update invoice if linked + // Update invoice if linked — use explicit cast to avoid PostgreSQL type inference error if (invoice) { const newPaid = parseFloat(invoice.amount_paid) + parseFloat(dto.amount); const invoiceAmt = parseFloat(invoice.amount); const newStatus = newPaid >= invoiceAmt ? 'paid' : 'partial'; await this.tenant.query( - `UPDATE invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $3`, - [newPaid, newStatus, invoice.id], + `UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE paid_at END, updated_at = NOW() WHERE id = $4`, + [newPaid, newStatus, newStatus, invoice.id], ); } return payment[0]; } + + async update(id: string, dto: any, userId: string) { + const existing = await this.findOne(id); + + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + + if (dto.payment_date !== undefined) { sets.push(`payment_date = $${idx++}`); params.push(dto.payment_date); } + if (dto.amount !== undefined) { sets.push(`amount = $${idx++}`); params.push(dto.amount); } + if (dto.payment_method !== undefined) { sets.push(`payment_method = $${idx++}`); params.push(dto.payment_method); } + if (dto.reference_number !== undefined) { sets.push(`reference_number = $${idx++}`); params.push(dto.reference_number); } + if (dto.notes !== undefined) { sets.push(`notes = $${idx++}`); params.push(dto.notes); } + + if (!sets.length) return this.findOne(id); + + params.push(id); + await this.tenant.query( + `UPDATE payments SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, + params, + ); + + // If amount changed and payment is linked to an invoice, recalculate invoice totals + if (dto.amount !== undefined && existing.invoice_id) { + await this.recalculateInvoice(existing.invoice_id); + } + + return this.findOne(id); + } + + async delete(id: string) { + const payment = await this.findOne(id); + const invoiceId = payment.invoice_id; + + // Delete associated journal entry lines and journal entry + if (payment.journal_entry_id) { + await this.tenant.query('DELETE FROM journal_entry_lines WHERE journal_entry_id = $1', [payment.journal_entry_id]); + await this.tenant.query('DELETE FROM journal_entries WHERE id = $1', [payment.journal_entry_id]); + } + + // Delete the payment + await this.tenant.query('DELETE FROM payments WHERE id = $1', [id]); + + // Recalculate invoice totals if payment was linked + if (invoiceId) { + await this.recalculateInvoice(invoiceId); + } + + return { success: true }; + } + + private async recalculateInvoice(invoiceId: string) { + // Sum all remaining payments for this invoice + const result = await this.tenant.query( + 'SELECT COALESCE(SUM(amount), 0) as total_paid FROM payments WHERE invoice_id = $1', + [invoiceId], + ); + const totalPaid = parseFloat(result[0].total_paid); + + // Get the invoice amount + const inv = await this.tenant.query('SELECT amount FROM invoices WHERE id = $1', [invoiceId]); + if (!inv.length) return; + + const invoiceAmt = parseFloat(inv[0].amount); + let newStatus: string; + if (totalPaid >= invoiceAmt) { + newStatus = 'paid'; + } else if (totalPaid > 0) { + newStatus = 'partial'; + } else { + newStatus = 'pending'; + } + + await this.tenant.query( + `UPDATE invoices SET amount_paid = $1, status = $2::VARCHAR, paid_at = CASE WHEN $3::VARCHAR = 'paid' THEN NOW() ELSE NULL END, updated_at = NOW() WHERE id = $4`, + [totalPaid, newStatus, newStatus, invoiceId], + ); + } } diff --git a/db/migrations/012-invoice-status-pending.sql b/db/migrations/012-invoice-status-pending.sql new file mode 100644 index 0000000..85fc100 --- /dev/null +++ b/db/migrations/012-invoice-status-pending.sql @@ -0,0 +1,33 @@ +-- Migration 012: Replace 'sent' status with 'pending' for invoices +-- 'sent' implied email delivery which doesn't exist; 'pending' is more accurate + +DO $$ +DECLARE + v_schema TEXT; + v_constraint TEXT; +BEGIN + FOR v_schema IN + SELECT schema_name FROM information_schema.schemata + WHERE schema_name LIKE 'tenant_%' + LOOP + -- Find and drop the existing status check constraint + SELECT constraint_name INTO v_constraint + FROM information_schema.table_constraints + WHERE table_schema = v_schema + AND table_name = 'invoices' + AND constraint_type = 'CHECK' + AND constraint_name LIKE '%status%'; + + IF v_constraint IS NOT NULL THEN + EXECUTE format('ALTER TABLE %I.invoices DROP CONSTRAINT %I', v_schema, v_constraint); + END IF; + + -- Add new constraint that includes 'pending' + EXECUTE format('ALTER TABLE %I.invoices ADD CONSTRAINT invoices_status_check CHECK (status IN ( + ''draft'', ''pending'', ''sent'', ''paid'', ''partial'', ''overdue'', ''void'', ''written_off'' + ))', v_schema); + + -- Convert existing 'sent' invoices to 'pending' + EXECUTE format('UPDATE %I.invoices SET status = ''pending'' WHERE status = ''sent''', v_schema); + END LOOP; +END $$; diff --git a/frontend/src/pages/invoices/InvoicesPage.tsx b/frontend/src/pages/invoices/InvoicesPage.tsx index 577543f..cab1e40 100644 --- a/frontend/src/pages/invoices/InvoicesPage.tsx +++ b/frontend/src/pages/invoices/InvoicesPage.tsx @@ -15,7 +15,7 @@ interface Invoice { invoice_date: string; due_date: string; invoice_type: string; description: string; amount: string; amount_paid: string; balance_due: string; status: string; period_start: string; period_end: string; - assessment_group_name: string; frequency: string; + assessment_group_name: string; frequency: string; owner_name: string; } interface PreviewGroup { @@ -43,7 +43,7 @@ interface Preview { } const statusColors: Record = { - draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark', + draft: 'gray', pending: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark', }; const frequencyColors: Record = { @@ -52,6 +52,13 @@ const frequencyColors: Record = { const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); +/** Extract last name from "First Last" format */ +const getLastName = (ownerName: string | null) => { + if (!ownerName) return '-'; + const parts = ownerName.trim().split(/\s+/); + return parts.length > 1 ? parts[parts.length - 1] : ownerName; +}; + export function InvoicesPage() { const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false); const [preview, setPreview] = useState(null); @@ -129,8 +136,9 @@ export function InvoicesPage() { - Invoice #UnitDate - DueTypePeriod + Invoice #UnitOwner + GroupDate + DuePeriod Amount PaidBalanceStatus @@ -140,9 +148,18 @@ export function InvoicesPage() { {i.invoice_number} {i.unit_number} + {getLastName(i.owner_name)} + + {i.assessment_group_name ? ( + + {i.assessment_group_name} + + ) : ( + {i.invoice_type} + )} + {new Date(i.invoice_date).toLocaleDateString()} {new Date(i.due_date).toLocaleDateString()} - {i.invoice_type} {i.period_start && i.period_end ? ( @@ -161,7 +178,7 @@ export function InvoicesPage() { {i.status} ))} - {invoices.length === 0 && No invoices yet} + {invoices.length === 0 && No invoices yet}
diff --git a/frontend/src/pages/payments/PaymentsPage.tsx b/frontend/src/pages/payments/PaymentsPage.tsx index 2e81a32..a94d8d2 100644 --- a/frontend/src/pages/payments/PaymentsPage.tsx +++ b/frontend/src/pages/payments/PaymentsPage.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; import { Title, Table, Group, Button, Stack, Text, Badge, Modal, - NumberInput, Select, TextInput, Loader, Center, + NumberInput, Select, TextInput, Loader, Center, ActionIcon, Tooltip, } from '@mantine/core'; import { DateInput } from '@mantine/dates'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { IconPlus } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../../services/api'; import { useIsReadOnly } from '../../stores/authStore'; @@ -15,11 +15,13 @@ import { useIsReadOnly } from '../../stores/authStore'; interface Payment { id: string; unit_id: string; unit_number: string; invoice_id: string; invoice_number: string; payment_date: string; amount: string; - payment_method: string; reference_number: string; status: string; + payment_method: string; reference_number: string; status: string; notes: string; } export function PaymentsPage() { const [opened, { open, close }] = useDisclosure(false); + const [editing, setEditing] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); const isReadOnly = useIsReadOnly(); @@ -39,10 +41,18 @@ export function PaymentsPage() { const form = useForm({ initialValues: { invoice_id: '', amount: 0, payment_method: 'check', - reference_number: '', payment_date: new Date(), + reference_number: '', payment_date: new Date(), notes: '', }, }); + const invalidateAll = () => { + queryClient.invalidateQueries({ queryKey: ['payments'] }); + queryClient.invalidateQueries({ queryKey: ['invoices'] }); + queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] }); + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries({ queryKey: ['journal-entries'] }); + }; + const createMutation = useMutation({ mutationFn: (values: any) => { const inv = invoices.find((i: any) => i.id === values.invoice_id); @@ -53,16 +63,69 @@ export function PaymentsPage() { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['payments'] }); - queryClient.invalidateQueries({ queryKey: ['invoices'] }); - queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] }); - queryClient.invalidateQueries({ queryKey: ['accounts'] }); + invalidateAll(); notifications.show({ message: 'Payment recorded', color: 'green' }); - close(); form.reset(); + close(); setEditing(null); form.reset(); }, onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, }); + const updateMutation = useMutation({ + mutationFn: (values: any) => { + return api.put(`/payments/${editing!.id}`, { + payment_date: values.payment_date.toISOString().split('T')[0], + amount: values.amount, + payment_method: values.payment_method, + reference_number: values.reference_number, + notes: values.notes, + }); + }, + onSuccess: () => { + invalidateAll(); + notifications.show({ message: 'Payment updated', color: 'green' }); + close(); setEditing(null); form.reset(); + }, + onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/payments/${id}`), + onSuccess: () => { + invalidateAll(); + notifications.show({ message: 'Payment deleted', color: 'orange' }); + setDeleteConfirm(null); + close(); setEditing(null); form.reset(); + }, + onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); }, + }); + + const handleEdit = (payment: Payment) => { + setEditing(payment); + form.setValues({ + invoice_id: payment.invoice_id || '', + amount: parseFloat(payment.amount || '0'), + payment_method: payment.payment_method || 'check', + reference_number: payment.reference_number || '', + payment_date: new Date(payment.payment_date), + notes: payment.notes || '', + }); + open(); + }; + + const handleNew = () => { + setEditing(null); + form.reset(); + open(); + }; + + const handleSubmit = (values: any) => { + if (editing) { + updateMutation.mutate(values); + } else { + createMutation.mutate(values); + } + }; + const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' }); const formatPeriod = (inv: any) => { @@ -89,7 +152,7 @@ export function PaymentsPage() { Payments - {!isReadOnly && } + {!isReadOnly && } @@ -97,6 +160,7 @@ export function PaymentsPage() { DateUnitInvoice AmountMethod ReferenceStatus + {!isReadOnly && } @@ -109,18 +173,34 @@ export function PaymentsPage() { {p.payment_method} {p.reference_number} {p.status} + {!isReadOnly && ( + + + handleEdit(p)}> + + + + + )} ))} {payments.length === 0 && ( - No payments recorded yet + No payments recorded yet )}
- -
createMutation.mutate(v))}> + + {/* Create / Edit Payment Modal */} + { close(); setEditing(null); form.reset(); }} title={editing ? 'Edit Payment' : 'Record Payment'}> + - + )} + {editing && ( + + )} @@ -131,10 +211,60 @@ export function PaymentsPage() { ]} {...form.getInputProps('payment_method')} /> - + + + + {editing ? ( + <> + + + + ) : ( + + )} +
+ + {/* Delete Confirmation Modal */} + setDeleteConfirm(null)} + title="Delete Payment" + size="sm" + > + + + Are you sure you want to delete this payment of{' '} + {deleteConfirm ? fmt(deleteConfirm.amount) : ''}{' '} + for unit {deleteConfirm?.unit_number}? + + + This will also remove the associated journal entry and recalculate the invoice balance. + + + + + + +
); }