From 9871eb16d471842971576e1b27f10c323b562304 Mon Sep 17 00:00:00 2001 From: Hadi Date: Tue, 14 Jul 2026 21:35:17 +0300 Subject: [PATCH 1/5] feat: add Invoices read path (issues/012) Invoice/InvoiceLineItem schema, computeInvoiceSummary deep module, read-only InvoicesService/Controller, invoices permission area, and the filtered list page. Write path lands in issues/013. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.module.ts | 2 + .../compute-invoice-summary.spec.ts | 109 +++++++ .../compute-invoice-summary.ts | 39 +++ .../modules/invoices/invoices.controller.ts | 34 +++ .../src/modules/invoices/invoices.module.ts | 10 + .../modules/invoices/invoices.service.spec.ts | 169 +++++++++++ .../src/modules/invoices/invoices.service.ts | 128 ++++++++ .../app/[lang]/dashboard/invoices/page.tsx | 23 ++ apps/web/src/app/api/invoices/[id]/route.ts | 5 + apps/web/src/app/api/invoices/route.ts | 5 + apps/web/src/auth/permissions.ts | 12 +- .../components/dashboard/invoices-page.tsx | 279 ++++++++++++++++++ .../components/layout/dashboard-sidebar.tsx | 8 + apps/web/src/i18n/dictionaries/ar.json | 1 + apps/web/src/i18n/dictionaries/en.json | 1 + .../src/store/api/endpoints/invoices.api.ts | 28 ++ apps/web/src/store/api/tag-types.ts | 1 + packages/contracts/src/index.ts | 54 ++++ .../migration.sql | 47 +++ packages/database/prisma/schema.prisma | 40 +++ 20 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts create mode 100644 apps/api/src/common/invoice-summary/compute-invoice-summary.ts create mode 100644 apps/api/src/modules/invoices/invoices.controller.ts create mode 100644 apps/api/src/modules/invoices/invoices.module.ts create mode 100644 apps/api/src/modules/invoices/invoices.service.spec.ts create mode 100644 apps/api/src/modules/invoices/invoices.service.ts create mode 100644 apps/web/src/app/[lang]/dashboard/invoices/page.tsx create mode 100644 apps/web/src/app/api/invoices/[id]/route.ts create mode 100644 apps/web/src/app/api/invoices/route.ts create mode 100644 apps/web/src/components/dashboard/invoices-page.tsx create mode 100644 apps/web/src/store/api/endpoints/invoices.api.ts create mode 100644 packages/database/prisma/migrations/20260714175807_add_invoice_model/migration.sql diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f5879e8a..af205f90 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -31,6 +31,7 @@ import { VendorsModule } from '@/modules/vendors/vendors.module'; import { MaintenanceRequestsModule } from '@/modules/maintenance-requests/maintenance-requests.module'; import { WorkOrdersModule } from '@/modules/work-orders/work-orders.module'; import { ExpensesModule } from '@/modules/expenses/expenses.module'; +import { InvoicesModule } from '@/modules/invoices/invoices.module'; @Module({ imports: [ @@ -76,6 +77,7 @@ import { ExpensesModule } from '@/modules/expenses/expenses.module'; MaintenanceRequestsModule, WorkOrdersModule, ExpensesModule, + InvoicesModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts b/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts new file mode 100644 index 00000000..97ec426e --- /dev/null +++ b/apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts @@ -0,0 +1,109 @@ +import { computeInvoiceSummary } from './compute-invoice-summary'; + +describe('computeInvoiceSummary', () => { + const dueDate = new Date('2026-02-01T00:00:00.000Z'); + const beforeDue = new Date('2026-01-15T00:00:00.000Z'); + const afterDue = new Date('2026-02-15T00:00:00.000Z'); + + it('is "open" when there are no payments and the due date has not passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ totalAmount: 1000, paidAmount: 0, status: 'open' }); + }); + + it('is "partially_paid" when some but not all of the total has been paid before the due date', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 400 }], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ + totalAmount: 1000, + paidAmount: 400, + status: 'partially_paid', + }); + }); + + it('is "paid" when payments exactly match the total', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1000 }], + dueDate, + beforeDue, + ); + + expect(result.status).toBe('paid'); + }); + + it('is "paid" when payments exceed the total (overpayment)', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1200 }], + dueDate, + beforeDue, + ); + + expect(result).toEqual({ + totalAmount: 1000, + paidAmount: 1200, + status: 'paid', + }); + }); + + it('is "overdue" when unpaid and the due date has passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [], + dueDate, + afterDue, + ); + + expect(result.status).toBe('overdue'); + }); + + it('is "overdue" when partially paid and the due date has passed', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 300 }], + dueDate, + afterDue, + ); + + expect(result.status).toBe('overdue'); + }); + + it('is "paid", not "overdue", when fully paid even past the due date (overdue-vs-paid precedence)', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }], + [{ amount: 1000 }], + dueDate, + afterDue, + ); + + expect(result.status).toBe('paid'); + }); + + it('sums multiple line items into the total', () => { + const result = computeInvoiceSummary( + [{ amount: 1000 }, { amount: 50 }, { amount: 25 }], + [], + dueDate, + beforeDue, + ); + + expect(result.totalAmount).toBe(1075); + }); + + it('treats an empty line-item list as a zero total', () => { + const result = computeInvoiceSummary([], [], dueDate, beforeDue); + + expect(result.totalAmount).toBe(0); + }); +}); diff --git a/apps/api/src/common/invoice-summary/compute-invoice-summary.ts b/apps/api/src/common/invoice-summary/compute-invoice-summary.ts new file mode 100644 index 00000000..63978899 --- /dev/null +++ b/apps/api/src/common/invoice-summary/compute-invoice-summary.ts @@ -0,0 +1,39 @@ +import { InvoiceStatus } from '@repo/contracts'; + +export type InvoiceSummaryLineItem = { amount: number }; +export type InvoiceSummaryPayment = { amount: number }; + +export type InvoiceSummary = { + totalAmount: number; + paidAmount: number; + status: InvoiceStatus; +}; + +/** + * Pure, dependency-free derivation of an Invoice's total/paid amounts and + * status from its line items and payments. No Prisma, no I/O — the single + * source of truth for these derived fields, consumed by both InvoicesService + * and InvoicePaymentsService so they can never disagree. + */ +export function computeInvoiceSummary( + lineItems: InvoiceSummaryLineItem[], + payments: InvoiceSummaryPayment[], + dueDate: Date, + now: Date, +): InvoiceSummary { + const totalAmount = lineItems.reduce((sum, item) => sum + item.amount, 0); + const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0); + + let status: InvoiceStatus; + if (paidAmount >= totalAmount) { + status = 'paid'; + } else if (dueDate < now) { + status = 'overdue'; + } else if (paidAmount > 0) { + status = 'partially_paid'; + } else { + status = 'open'; + } + + return { totalAmount, paidAmount, status }; +} diff --git a/apps/api/src/modules/invoices/invoices.controller.ts b/apps/api/src/modules/invoices/invoices.controller.ts new file mode 100644 index 00000000..a44ed33c --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { InvoicesService } from './invoices.service'; +import { OrgScopeService } from '@/common/org-scope/org-scope.service'; +import { CurrentUser, Roles } from '@/common/decorators'; +import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; +import { Role } from '@/common/enums'; + +@ApiTags('invoices') +@ApiBearerAuth() +@Controller('invoices') +export class InvoicesController { + constructor( + private readonly invoicesService: InvoicesService, + private readonly orgScope: OrgScopeService, + ) {} + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get() + async getInvoices(@CurrentUser() user: AuthenticatedUser) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.findAll(orgId, user.sub, role); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get(':id') + async getInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.findOne(orgId, user.sub, role, id); + } +} diff --git a/apps/api/src/modules/invoices/invoices.module.ts b/apps/api/src/modules/invoices/invoices.module.ts new file mode 100644 index 00000000..058d12f8 --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvoicesController } from './invoices.controller'; +import { InvoicesService } from './invoices.service'; + +@Module({ + controllers: [InvoicesController], + providers: [InvoicesService], + exports: [InvoicesService], +}) +export class InvoicesModule {} diff --git a/apps/api/src/modules/invoices/invoices.service.spec.ts b/apps/api/src/modules/invoices/invoices.service.spec.ts new file mode 100644 index 00000000..f760eb57 --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.service.spec.ts @@ -0,0 +1,169 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { InvoicesService } from './invoices.service'; +import { Role } from '@/common/enums'; + +describe('InvoicesService', () => { + const orgId = 'org-1'; + const callerId = 'caller-1'; + const buildingId = 'building-1'; + + function makeService( + overrides: { + invoice?: Partial>; + buildingAccess?: Partial>; + } = {}, + ) { + const prisma: any = { + invoice: { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + ...overrides.invoice, + }, + }; + const buildingAccess = { + getAllowedBuildingIds: jest.fn().mockResolvedValue(null), + assertBuildingAccess: jest.fn().mockResolvedValue(undefined), + ...overrides.buildingAccess, + }; + const service = new InvoicesService(prisma, buildingAccess as any); + return { service, prisma, buildingAccess }; + } + + const decimal = (value: string) => ({ + toString: () => value, + toNumber: () => Number(value), + }); + + const invoiceRow = (overrides: Partial> = {}) => ({ + id: 'invoice-1', + orgId, + buildingId, + leaseId: 'lease-1', + dueDate: new Date('2099-02-01T00:00:00.000Z'), + notes: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + lineItems: [ + { + id: 'li-1', + invoiceId: 'invoice-1', + category: 'rent', + description: null, + amount: decimal('1000.00'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + lease: { + renter: { fullName: 'Jane Tenant' }, + apartment: { unitNumber: '101' }, + }, + ...overrides, + }); + + describe('findAll', () => { + it('returns invoices with computed totalAmount/paidAmount/status for an org-wide role', async () => { + const { service, prisma, buildingAccess } = makeService({ + invoice: { findMany: jest.fn().mockResolvedValue([invoiceRow()]) }, + }); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + callerId, + Role.ORG_ADMIN, + ); + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId } }), + ); + expect(result.data).toEqual([ + expect.objectContaining({ + id: 'invoice-1', + leaseId: 'lease-1', + totalAmount: '1000.00', + paidAmount: '0.00', + status: 'open', + renterName: 'Jane Tenant', + apartmentUnitNumber: '101', + }), + ]); + }); + + it('sees the full org regardless of building for a finance caller', async () => { + const { service, prisma, buildingAccess } = makeService(); + + await service.findAll(orgId, callerId, Role.FINANCE); + + expect(buildingAccess.getAllowedBuildingIds).toHaveBeenCalledWith( + orgId, + callerId, + Role.FINANCE, + ); + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId } }), + ); + }); + + it('filters to allowed building ids for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR); + + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { orgId, buildingId: { in: [buildingId] } }, + }), + ); + }); + }); + + describe('findOne', () => { + it('returns the invoice when it belongs to the caller org', async () => { + const { service, prisma } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + const result = await service.findOne( + orgId, + callerId, + Role.ORG_ADMIN, + 'invoice-1', + ); + + expect(prisma.invoice.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'invoice-1', orgId } }), + ); + expect(result.data).toEqual( + expect.objectContaining({ id: 'invoice-1' }), + ); + }); + + it('throws NotFoundException for an invoice in a different org', async () => { + const { service } = makeService(); + + await expect( + service.findOne(orgId, callerId, Role.ORG_ADMIN, 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws ForbiddenException for a supervisor not assigned to the building', async () => { + const { service } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + buildingAccess: { + assertBuildingAccess: jest + .fn() + .mockRejectedValue(new ForbiddenException()), + }, + }); + + await expect( + service.findOne(orgId, callerId, Role.SUPERVISOR, 'invoice-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); +}); diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts new file mode 100644 index 00000000..dd60d506 --- /dev/null +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -0,0 +1,128 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@repo/db'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { BuildingAccessService } from '@/common/building-access/building-access.service'; +import { Role } from '@/common/enums'; +import { InvoiceLineItemCategory, InvoiceResponse } from '@repo/contracts'; +import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; + +type InvoiceRow = { + id: string; + orgId: string; + buildingId: string; + leaseId: string; + dueDate: Date; + notes: string | null; + createdAt: Date; + updatedAt: Date; + lineItems: { + id: string; + invoiceId: string; + category: string; + description: string | null; + amount: Prisma.Decimal; + createdAt: Date; + updatedAt: Date; + }[]; + lease: { + renter: { fullName: string }; + apartment: { unitNumber: string }; + }; +}; + +const INVOICE_INCLUDE = { + lineItems: true, + lease: { + include: { + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + }, + }, +} satisfies Prisma.InvoiceInclude; + +@Injectable() +export class InvoicesService { + constructor( + private readonly prisma: PrismaService, + private readonly buildingAccess: BuildingAccessService, + ) {} + + private formatInvoice(invoice: InvoiceRow): InvoiceResponse { + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + [], // no InvoicePayments module yet + invoice.dueDate, + new Date(), + ); + + return { + id: invoice.id, + orgId: invoice.orgId, + buildingId: invoice.buildingId, + leaseId: invoice.leaseId, + dueDate: invoice.dueDate.toISOString(), + notes: invoice.notes, + lineItems: invoice.lineItems.map((li) => ({ + id: li.id, + invoiceId: li.invoiceId, + category: li.category as InvoiceLineItemCategory, + description: li.description, + amount: li.amount.toString(), + createdAt: li.createdAt.toISOString(), + updatedAt: li.updatedAt.toISOString(), + })), + totalAmount: totalAmount.toFixed(2), + paidAmount: paidAmount.toFixed(2), + status, + renterName: invoice.lease.renter.fullName, + apartmentUnitNumber: invoice.lease.apartment.unitNumber, + createdAt: invoice.createdAt.toISOString(), + updatedAt: invoice.updatedAt.toISOString(), + }; + } + + async findAll( + orgId: string, + callerId: string, + callerRole: Role, + ): Promise<{ data: InvoiceResponse[] }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const invoices = await this.prisma.invoice.findMany({ + where: { + orgId, + ...(allowedBuildingIds && { buildingId: { in: allowedBuildingIds } }), + }, + include: INVOICE_INCLUDE, + orderBy: { dueDate: 'desc' }, + }); + + return { data: invoices.map((i) => this.formatInvoice(i)) }; + } + + async findOne( + orgId: string, + callerId: string, + callerRole: Role, + invoiceId: string, + ): Promise<{ data: InvoiceResponse }> { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + include: INVOICE_INCLUDE, + }); + if (!invoice) throw new NotFoundException('Invoice not found.'); + + await this.buildingAccess.assertBuildingAccess( + orgId, + callerId, + callerRole, + invoice.buildingId, + ); + + return { data: this.formatInvoice(invoice) }; + } +} diff --git a/apps/web/src/app/[lang]/dashboard/invoices/page.tsx b/apps/web/src/app/[lang]/dashboard/invoices/page.tsx new file mode 100644 index 00000000..52d60104 --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/invoices/page.tsx @@ -0,0 +1,23 @@ +import { requireSession } from '@/auth/guards'; +import { normalizeRole } from '@/auth/roles'; +import { canAccess, canWrite } from '@/auth/permissions'; +import { redirect } from 'next/navigation'; +import { isLocale } from '@/i18n/config'; +import { InvoicesPage } from '@/components/dashboard/invoices-page'; + +export default async function InvoicesPageRoute({ + params, +}: { + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const locale = isLocale(lang) ? lang : 'en'; + const session = await requireSession({ locale }); + const role = normalizeRole(session.role ?? session.user?.role); + + if (!canAccess(role, 'invoices')) { + redirect(`/${locale}/dashboard`); + } + + return ; +} diff --git a/apps/web/src/app/api/invoices/[id]/route.ts b/apps/web/src/app/api/invoices/[id]/route.ts new file mode 100644 index 00000000..ec4b7b53 --- /dev/null +++ b/apps/web/src/app/api/invoices/[id]/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute((params) => `/invoices/${params.id}`); diff --git a/apps/web/src/app/api/invoices/route.ts b/apps/web/src/app/api/invoices/route.ts new file mode 100644 index 00000000..dbb903c3 --- /dev/null +++ b/apps/web/src/app/api/invoices/route.ts @@ -0,0 +1,5 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +export const GET = forwardRoute('/invoices'); diff --git a/apps/web/src/auth/permissions.ts b/apps/web/src/auth/permissions.ts index 685879d6..dcb2ae1d 100644 --- a/apps/web/src/auth/permissions.ts +++ b/apps/web/src/auth/permissions.ts @@ -19,6 +19,10 @@ * "expenses" → /dashboard/expenses (not building-scoped at the area * level either — a supervisor's building-scoping for * Expense is enforced server-side, not via this matrix) + * "invoices" → /dashboard/invoices (covers both Invoices and Invoice + * Payments together, same as "tasks" covering Maintenance + * Requests + Work Orders; distinct from "payments"/"billing", + * which are the platform's own Stripe subscription billing) */ import type { Role } from '@/auth/roles'; @@ -33,7 +37,8 @@ export type DashboardArea = | 'timeline' | 'tasks' | 'vendors' - | 'expenses'; + | 'expenses' + | 'invoices'; /** * Per-role access level for an area. @@ -57,6 +62,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'full', vendors: 'full', expenses: 'full', + invoices: 'full', }, supervisor: { dashboard: 'readonly', @@ -69,6 +75,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'readonly', vendors: 'readonly', expenses: 'readonly', + invoices: 'readonly', }, finance: { dashboard: 'readonly', @@ -81,6 +88,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'none', vendors: 'readonly', expenses: 'full', + invoices: 'full', }, maintenance: { dashboard: 'readonly', @@ -93,6 +101,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'full', vendors: 'readonly', expenses: 'none', + invoices: 'none', }, tenant: { dashboard: 'readonly', @@ -105,6 +114,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { tasks: 'none', vendors: 'none', expenses: 'none', + invoices: 'none', }, }; diff --git a/apps/web/src/components/dashboard/invoices-page.tsx b/apps/web/src/components/dashboard/invoices-page.tsx new file mode 100644 index 00000000..47131cc3 --- /dev/null +++ b/apps/web/src/components/dashboard/invoices-page.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { FileTextIcon, EyeIcon } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; + +import { useListInvoicesQuery } from '@/store/api/endpoints/invoices.api'; +import { useListBuildingsQuery } from '@/store/api/endpoints/buildings.api'; +import type { InvoiceStatus } from '@/types/api'; + +// ── Status badge ────────────────────────────────────────────────────────────── + +const STATUSES: InvoiceStatus[] = ['open', 'partially_paid', 'paid', 'overdue']; + +const STATUS_LABELS: Record = { + open: 'Open', + partially_paid: 'Partially paid', + paid: 'Paid', + overdue: 'Overdue', +}; + +const STATUS_STYLES: Record = { + open: 'bg-blue-50 text-blue-700 border-blue-200', + partially_paid: 'bg-amber-50 text-amber-700 border-amber-200', + paid: 'bg-emerald-50 text-emerald-700 border-emerald-200', + overdue: 'bg-red-50 text-red-700 border-red-200', +}; + +function StatusBadge({ status }: { status: InvoiceStatus }) { + return ( + + {STATUS_LABELS[status] ?? status} + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +const ALL = '__all__'; + +interface InvoicesPageProps { + /** When false (supervisor), hide all write actions. There are none yet in this pass. */ + canWrite: boolean; +} + +export function InvoicesPage({ canWrite }: InvoicesPageProps) { + const { data: invoices, isLoading, isError } = useListInvoicesQuery(); + const { data: buildings } = useListBuildingsQuery(); + + const [buildingFilter, setBuildingFilter] = useState(ALL); + const [statusFilter, setStatusFilter] = useState(ALL); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + + const buildingNameById = useMemo(() => { + const map = new Map(); + for (const b of buildings ?? []) map.set(b.id, b.name); + return map; + }, [buildings]); + + const filteredInvoices = useMemo(() => { + return (invoices ?? []).filter((invoice) => { + if (buildingFilter !== ALL && invoice.buildingId !== buildingFilter) { + return false; + } + if (statusFilter !== ALL && invoice.status !== statusFilter) { + return false; + } + const dueDate = invoice.dueDate.slice(0, 10); + if (fromDate && dueDate < fromDate) return false; + if (toDate && dueDate > toDate) return false; + return true; + }); + }, [invoices, buildingFilter, statusFilter, fromDate, toDate]); + + const hasAnyInvoices = (invoices?.length ?? 0) > 0; + + return ( +
+ {/* Header */} +
+
+

Invoices

+

+ Bills issued to renters — rent, late fees, utilities, and more. +

+
+ {!canWrite && ( + + + Read-only + + )} +
+ + {/* Filters */} + {hasAnyInvoices && ( +
+
+ + +
+
+ + +
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
+
+ )} + + {/* Invoices table */} +
+ + + + Lease / Renter + Building + Due date + Total + Paid + Status + + + + {isLoading ? ( + <> + {[...Array(3)].map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + + ) : isError ? ( + + + Failed to load invoices. Please try again. + + + ) : !hasAnyInvoices ? ( + + + + No invoices recorded yet. + + + ) : filteredInvoices.length === 0 ? ( + + + No invoices match the selected filters. + + + ) : ( + filteredInvoices.map((invoice) => ( + + + {invoice.renterName} · {invoice.apartmentUnitNumber} + + + {buildingNameById.get(invoice.buildingId) ?? + invoice.buildingId} + + + {new Date(invoice.dueDate).toLocaleDateString()} + + + {invoice.totalAmount} + + + {invoice.paidAmount} + + + + + + )) + )} + +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/dashboard-sidebar.tsx b/apps/web/src/components/layout/dashboard-sidebar.tsx index 4074254f..f35db961 100644 --- a/apps/web/src/components/layout/dashboard-sidebar.tsx +++ b/apps/web/src/components/layout/dashboard-sidebar.tsx @@ -19,6 +19,7 @@ import { Contact, HardHat, Receipt, + FileText, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -124,6 +125,13 @@ function buildNavItems( href: `/${locale}/dashboard/expenses`, areaKey: 'expenses', }, + { + key: 'invoices', + label: dict.nav.invoices, + icon: FileText, + href: `/${locale}/dashboard/invoices`, + areaKey: 'invoices', + }, { key: 'billing', label: dict.nav.billing, diff --git a/apps/web/src/i18n/dictionaries/ar.json b/apps/web/src/i18n/dictionaries/ar.json index 57977789..2c3baeb2 100644 --- a/apps/web/src/i18n/dictionaries/ar.json +++ b/apps/web/src/i18n/dictionaries/ar.json @@ -18,6 +18,7 @@ "reports": "التقارير", "tasks": "المهام", "expenses": "المصاريف", + "invoices": "الفواتير", "language": "اللغة", "role": "الدور", "menu": "فتح القائمة", diff --git a/apps/web/src/i18n/dictionaries/en.json b/apps/web/src/i18n/dictionaries/en.json index 13030ea8..116d27ce 100644 --- a/apps/web/src/i18n/dictionaries/en.json +++ b/apps/web/src/i18n/dictionaries/en.json @@ -18,6 +18,7 @@ "reports": "Reports", "tasks": "Tasks", "expenses": "Expenses", + "invoices": "Invoices", "language": "Language", "role": "Role", "menu": "Open menu", diff --git a/apps/web/src/store/api/endpoints/invoices.api.ts b/apps/web/src/store/api/endpoints/invoices.api.ts new file mode 100644 index 00000000..9db46e29 --- /dev/null +++ b/apps/web/src/store/api/endpoints/invoices.api.ts @@ -0,0 +1,28 @@ +import { baseApi } from '@/store/api/base-api'; +import type { ApiEnvelope, InvoiceResponse } from '@/types/api'; + +function unwrap(response: TData | ApiEnvelope): TData { + return response && typeof response === 'object' && 'data' in response + ? (response as ApiEnvelope).data + : (response as TData); +} + +export const invoicesApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + listInvoices: build.query({ + query: () => ({ url: '/invoices', method: 'GET' }), + transformResponse: ( + response: InvoiceResponse[] | ApiEnvelope, + ) => unwrap(response), + providesTags: (result) => + result + ? [ + ...result.map(({ id }) => ({ type: 'Invoice' as const, id })), + { type: 'Invoice', id: 'LIST' }, + ] + : [{ type: 'Invoice', id: 'LIST' }], + }), + }), +}); + +export const { useListInvoicesQuery } = invoicesApi; diff --git a/apps/web/src/store/api/tag-types.ts b/apps/web/src/store/api/tag-types.ts index 9cdf3506..ff6c33a4 100644 --- a/apps/web/src/store/api/tag-types.ts +++ b/apps/web/src/store/api/tag-types.ts @@ -14,6 +14,7 @@ export const TAG_TYPES = [ 'MaintenanceRequest', 'WorkOrder', 'Expense', + 'Invoice', ] as const; export type TagType = (typeof TAG_TYPES)[number]; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 314d86a6..c1731a8c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -643,3 +643,57 @@ export type PatchExpenseBody = { workOrderId?: string | null; notes?: string | null; }; + +// ── Invoices ───────────────────────────────────────────────────────────────── + +export const invoiceLineItemCategorySchema = z.enum([ + 'rent', + 'late_fee', + 'utilities', + 'damages', + 'deposit', + 'other', +]); +export type InvoiceLineItemCategory = z.infer< + typeof invoiceLineItemCategorySchema +>; + +/** Always derived via computeInvoiceSummary — never stored/accepted as input. */ +export const invoiceStatusSchema = z.enum([ + 'open', + 'partially_paid', + 'paid', + 'overdue', +]); +export type InvoiceStatus = z.infer; + +export type InvoiceLineItemResponse = { + id: string; + invoiceId: string; + category: InvoiceLineItemCategory; + description?: string | null; + amount: string; // Decimal(12,2) serialized as string + createdAt: string; + updatedAt: string; +}; + +export type InvoiceResponse = { + id: string; + orgId: string; + /** Denormalized from Lease.buildingId at creation time. */ + buildingId: string; + leaseId: string; + dueDate: string; + notes?: string | null; + lineItems: InvoiceLineItemResponse[]; + /** Computed via computeInvoiceSummary from lineItems + payments; never stored. */ + totalAmount: string; + paidAmount: string; + status: InvoiceStatus; + /** Joined server-side for list-table display; not a stored column. */ + renterName: string; + /** Joined server-side for list-table display; not a stored column. */ + apartmentUnitNumber: string; + createdAt: string; + updatedAt: string; +}; diff --git a/packages/database/prisma/migrations/20260714175807_add_invoice_model/migration.sql b/packages/database/prisma/migrations/20260714175807_add_invoice_model/migration.sql new file mode 100644 index 00000000..44d74394 --- /dev/null +++ b/packages/database/prisma/migrations/20260714175807_add_invoice_model/migration.sql @@ -0,0 +1,47 @@ +-- CreateEnum +CREATE TYPE "InvoiceLineItemCategory" AS ENUM ('rent', 'late_fee', 'utilities', 'damages', 'deposit', 'other'); + +-- CreateTable +CREATE TABLE "Invoice" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "buildingId" TEXT NOT NULL, + "leaseId" TEXT NOT NULL, + "dueDate" TIMESTAMP(3) NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InvoiceLineItem" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "category" "InvoiceLineItemCategory" NOT NULL, + "description" TEXT, + "amount" DECIMAL(12,2) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InvoiceLineItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Invoice_orgId_idx" ON "Invoice"("orgId"); + +-- CreateIndex +CREATE INDEX "Invoice_buildingId_idx" ON "Invoice"("buildingId"); + +-- CreateIndex +CREATE INDEX "Invoice_leaseId_idx" ON "Invoice"("leaseId"); + +-- CreateIndex +CREATE INDEX "InvoiceLineItem_invoiceId_idx" ON "InvoiceLineItem"("invoiceId"); + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_leaseId_fkey" FOREIGN KEY ("leaseId") REFERENCES "Lease"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InvoiceLineItem" ADD CONSTRAINT "InvoiceLineItem_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 615e2f4d..4f6c385f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -74,6 +74,15 @@ enum ExpenseCategory { other } +enum InvoiceLineItemCategory { + rent + late_fee + utilities + damages + deposit + other +} + model Organization { id String @id @default(cuid()) name String @@ -233,6 +242,7 @@ model Lease { updatedAt DateTime @updatedAt apartment Apartment @relation(fields: [apartmentId], references: [id], onDelete: Restrict) renter Renter @relation(fields: [renterId], references: [id], onDelete: Restrict) + invoices Invoice[] @@index([apartmentId]) @@index([renterId]) @@ -322,3 +332,33 @@ model Expense { @@index([vendorId]) @@index([workOrderId]) } + +model Invoice { + id String @id @default(cuid()) + orgId String + buildingId String + leaseId String + dueDate DateTime + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lease Lease @relation(fields: [leaseId], references: [id], onDelete: Restrict) + lineItems InvoiceLineItem[] + + @@index([orgId]) + @@index([buildingId]) + @@index([leaseId]) +} + +model InvoiceLineItem { + id String @id @default(cuid()) + invoiceId String + category InvoiceLineItemCategory + description String? + amount Decimal @db.Decimal(12, 2) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([invoiceId]) +} From 5eea50ff80ba57d01c6cf93bd46cd6661d51fce0 Mon Sep 17 00:00:00 2001 From: Hadi Date: Tue, 14 Jul 2026 23:04:34 +0300 Subject: [PATCH 2/5] feat: add Invoices write path (issues/013) InvoicesService.create/update/remove with wholesale line-item replacement on update, LeasesService.remove guarded against referenced invoices, and the New/Edit Invoice dialog (cascading lease picker, repeatable line-item rows) plus delete confirmation. Co-Authored-By: Claude Sonnet 5 --- .../invoices/dto/create-invoice.dto.ts | 54 ++ .../invoices/dto/update-invoice.dto.ts | 31 + .../modules/invoices/invoices.controller.ts | 43 +- .../modules/invoices/invoices.service.spec.ts | 199 ++++- .../src/modules/invoices/invoices.service.ts | 166 +++- .../src/modules/leases/leases.service.spec.ts | 24 + apps/api/src/modules/leases/leases.service.ts | 9 + apps/web/src/app/api/invoices/[id]/route.ts | 2 + apps/web/src/app/api/invoices/route.ts | 1 + .../components/dashboard/invoices-page.tsx | 729 +++++++++++++++++- .../src/store/api/endpoints/invoices.api.ts | 56 +- packages/contracts/src/index.ts | 28 + 12 files changed, 1318 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/modules/invoices/dto/create-invoice.dto.ts create mode 100644 apps/api/src/modules/invoices/dto/update-invoice.dto.ts diff --git a/apps/api/src/modules/invoices/dto/create-invoice.dto.ts b/apps/api/src/modules/invoices/dto/create-invoice.dto.ts new file mode 100644 index 00000000..937281c3 --- /dev/null +++ b/apps/api/src/modules/invoices/dto/create-invoice.dto.ts @@ -0,0 +1,54 @@ +import { + ArrayMinSize, + IsArray, + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { InvoiceLineItemCategory } from '@repo/db'; + +export class InvoiceLineItemInputDto { + @ApiProperty({ enum: InvoiceLineItemCategory }) + @IsEnum(InvoiceLineItemCategory) + category: InvoiceLineItemCategory; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiProperty() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + amount: number; +} + +export class CreateInvoiceDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + leaseId: string; + + @ApiProperty() + @IsDateString() + dueDate: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ type: [InvoiceLineItemInputDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceLineItemInputDto) + lineItems: InvoiceLineItemInputDto[]; +} diff --git a/apps/api/src/modules/invoices/dto/update-invoice.dto.ts b/apps/api/src/modules/invoices/dto/update-invoice.dto.ts new file mode 100644 index 00000000..e130af13 --- /dev/null +++ b/apps/api/src/modules/invoices/dto/update-invoice.dto.ts @@ -0,0 +1,31 @@ +import { + ArrayMinSize, + IsArray, + IsDateString, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { InvoiceLineItemInputDto } from './create-invoice.dto'; + +export class UpdateInvoiceDto { + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + dueDate?: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + notes?: string | null; + + @ApiPropertyOptional({ type: [InvoiceLineItemInputDto] }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InvoiceLineItemInputDto) + lineItems?: InvoiceLineItemInputDto[]; +} diff --git a/apps/api/src/modules/invoices/invoices.controller.ts b/apps/api/src/modules/invoices/invoices.controller.ts index a44ed33c..f5ee03f5 100644 --- a/apps/api/src/modules/invoices/invoices.controller.ts +++ b/apps/api/src/modules/invoices/invoices.controller.ts @@ -1,10 +1,20 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { InvoicesService } from './invoices.service'; import { OrgScopeService } from '@/common/org-scope/org-scope.service'; import { CurrentUser, Roles } from '@/common/decorators'; import { AuthenticatedUser } from '@/common/types/authenticated-user.type'; import { Role } from '@/common/enums'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { UpdateInvoiceDto } from './dto/update-invoice.dto'; @ApiTags('invoices') @ApiBearerAuth() @@ -31,4 +41,35 @@ export class InvoicesController { const { orgId, role } = await this.orgScope.resolveForCaller(user); return this.invoicesService.findOne(orgId, user.sub, role, id); } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Post() + async createInvoice( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: CreateInvoiceDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.create(orgId, user.sub, role, dto); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Patch(':id') + async updateInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + @Body() dto: UpdateInvoiceDto, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.update(orgId, user.sub, role, id, dto); + } + + @Roles(Role.ORG_ADMIN, Role.FINANCE) + @Delete(':id') + async deleteInvoice( + @CurrentUser() user: AuthenticatedUser, + @Param('id') id: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicesService.remove(orgId, user.sub, role, id); + } } diff --git a/apps/api/src/modules/invoices/invoices.service.spec.ts b/apps/api/src/modules/invoices/invoices.service.spec.ts index f760eb57..5e17f6d7 100644 --- a/apps/api/src/modules/invoices/invoices.service.spec.ts +++ b/apps/api/src/modules/invoices/invoices.service.spec.ts @@ -1,4 +1,8 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { InvoicesService } from './invoices.service'; import { Role } from '@/common/enums'; @@ -10,6 +14,8 @@ describe('InvoicesService', () => { function makeService( overrides: { invoice?: Partial>; + lease?: Partial>; + invoiceLineItem?: Partial>; buildingAccess?: Partial>; } = {}, ) { @@ -17,16 +23,35 @@ describe('InvoicesService', () => { invoice: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), ...overrides.invoice, }, + lease: { + findFirst: jest.fn().mockResolvedValue(null), + ...overrides.lease, + }, + invoiceLineItem: { + deleteMany: jest.fn().mockResolvedValue(undefined), + ...overrides.invoiceLineItem, + }, }; + prisma.$transaction = jest.fn(async (cb: (tx: unknown) => unknown) => + cb(prisma), + ); const buildingAccess = { getAllowedBuildingIds: jest.fn().mockResolvedValue(null), assertBuildingAccess: jest.fn().mockResolvedValue(undefined), ...overrides.buildingAccess, }; - const service = new InvoicesService(prisma, buildingAccess as any); - return { service, prisma, buildingAccess }; + const timeline = { emit: jest.fn().mockResolvedValue(undefined) }; + const service = new InvoicesService( + prisma, + buildingAccess as any, + timeline as any, + ); + return { service, prisma, buildingAccess, timeline }; } const decimal = (value: string) => ({ @@ -166,4 +191,172 @@ describe('InvoicesService', () => { ).rejects.toBeInstanceOf(ForbiddenException); }); }); + + describe('create', () => { + const dto = { + leaseId: 'lease-1', + dueDate: '2099-02-01', + notes: undefined, + lineItems: [{ category: 'rent' as const, amount: 1000 }], + }; + + it('creates an invoice with the denormalized buildingId from the lease', async () => { + const { service, prisma, timeline } = makeService({ + lease: { + findFirst: jest + .fn() + .mockResolvedValue({ id: 'lease-1', buildingId }), + }, + invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + const result = await service.create(orgId, callerId, Role.ORG_ADMIN, dto); + + expect(prisma.invoice.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + orgId, + buildingId, + leaseId: 'lease-1', + }), + }), + ); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.created' }), + ); + expect(result.data.id).toBe('invoice-1'); + }); + + it('rejects when line items are empty', async () => { + const { service } = makeService({ + lease: { + findFirst: jest + .fn() + .mockResolvedValue({ id: 'lease-1', buildingId }), + }, + }); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, { + ...dto, + lineItems: [], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects when a required field is missing', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, { + ...dto, + leaseId: '', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws NotFoundException when the lease does not exist in the org', async () => { + const { service } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(null) }, + }); + + await expect( + service.create(orgId, callerId, Role.ORG_ADMIN, dto), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService(); + + await expect( + service.create(orgId, callerId, Role.SUPERVISOR, dto), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); + + describe('update', () => { + it('replaces the line-item set wholesale when line items are provided', async () => { + const { service, prisma, timeline } = makeService({ + invoice: { + findFirst: jest.fn().mockResolvedValue(invoiceRow()), + update: jest.fn().mockResolvedValue(invoiceRow()), + }, + }); + + await service.update(orgId, callerId, Role.FINANCE, 'invoice-1', { + lineItems: [{ category: 'utilities', amount: 250 }], + }); + + expect(prisma.invoiceLineItem.deleteMany).toHaveBeenCalledWith({ + where: { invoiceId: 'invoice-1' }, + }); + expect(prisma.invoice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + lineItems: { create: [expect.objectContaining({ amount: 250 })] }, + }), + }), + ); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.updated' }), + ); + }); + + it('patches dueDate/notes independently when line items are not provided', async () => { + const { service, prisma } = makeService({ + invoice: { + findFirst: jest.fn().mockResolvedValue(invoiceRow()), + update: jest.fn().mockResolvedValue(invoiceRow()), + }, + }); + + await service.update(orgId, callerId, Role.ORG_ADMIN, 'invoice-1', { + notes: 'updated notes', + }); + + expect(prisma.invoiceLineItem.deleteMany).not.toHaveBeenCalled(); + expect(prisma.invoice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { notes: 'updated notes' }, + }), + ); + }); + + it('throws NotFoundException for an invoice outside the org', async () => { + const { service } = makeService(); + + await expect( + service.update(orgId, callerId, Role.ORG_ADMIN, 'missing', { + notes: 'x', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('remove', () => { + it('deletes the invoice and its line items', async () => { + const { service, prisma, timeline } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await service.remove(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'); + + expect(prisma.invoice.delete).toHaveBeenCalledWith({ + where: { id: 'invoice-1' }, + }); + expect(timeline.emit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'invoice.deleted' }), + ); + }); + + it('rejects a supervisor caller', async () => { + const { service } = makeService({ + invoice: { findFirst: jest.fn().mockResolvedValue(invoiceRow()) }, + }); + + await expect( + service.remove(orgId, callerId, Role.SUPERVISOR, 'invoice-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + }); }); diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index dd60d506..bd435345 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -1,10 +1,18 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Prisma } from '@repo/db'; import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { BuildingAccessService } from '@/common/building-access/building-access.service'; +import { TimelineService } from '@/modules/timeline/timeline.service'; import { Role } from '@/common/enums'; import { InvoiceLineItemCategory, InvoiceResponse } from '@repo/contracts'; import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { UpdateInvoiceDto } from './dto/update-invoice.dto'; type InvoiceRow = { id: string; @@ -45,8 +53,17 @@ export class InvoicesService { constructor( private readonly prisma: PrismaService, private readonly buildingAccess: BuildingAccessService, + private readonly timeline: TimelineService, ) {} + private assertWriteAccess(callerRole: Role): void { + if (callerRole !== Role.ORG_ADMIN && callerRole !== Role.FINANCE) { + throw new ForbiddenException( + 'Only an org admin or finance user can write to invoices.', + ); + } + } + private formatInvoice(invoice: InvoiceRow): InvoiceResponse { const { totalAmount, paidAmount, status } = computeInvoiceSummary( invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), @@ -125,4 +142,151 @@ export class InvoicesService { return { data: this.formatInvoice(invoice) }; } + + // ── CRUD (write) ────────────────────────────────────────────────────────── + + private validateLineItems( + lineItems: { category?: string; amount?: number }[] | undefined, + ): void { + if (!lineItems || lineItems.length === 0) { + throw new BadRequestException('At least one line item is required.'); + } + for (const li of lineItems) { + if (!li.category || li.amount === undefined || li.amount === null) { + throw new BadRequestException( + 'Each line item requires a category and an amount.', + ); + } + } + } + + async create( + orgId: string, + actorId: string, + callerRole: Role, + dto: CreateInvoiceDto, + ): Promise<{ data: InvoiceResponse }> { + this.assertWriteAccess(callerRole); + + if (!dto.leaseId || !dto.dueDate) { + throw new BadRequestException('leaseId and dueDate are required.'); + } + this.validateLineItems(dto.lineItems); + + const lease = await this.prisma.lease.findFirst({ + where: { id: dto.leaseId, orgId }, + select: { id: true, buildingId: true }, + }); + if (!lease) throw new NotFoundException('Lease not found.'); + + const invoice = await this.prisma.invoice.create({ + data: { + orgId, + buildingId: lease.buildingId, + leaseId: lease.id, + dueDate: new Date(dto.dueDate), + notes: dto.notes, + lineItems: { + create: dto.lineItems.map((li) => ({ + category: li.category, + description: li.description, + amount: li.amount, + })), + }, + }, + include: INVOICE_INCLUDE, + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.created', + targetType: 'Invoice', + targetId: invoice.id, + metadata: { leaseId: invoice.leaseId, lineItemCount: dto.lineItems.length }, + }); + + return { data: this.formatInvoice(invoice) }; + } + + async update( + orgId: string, + actorId: string, + callerRole: Role, + invoiceId: string, + dto: UpdateInvoiceDto, + ): Promise<{ data: InvoiceResponse }> { + this.assertWriteAccess(callerRole); + + const existing = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + }); + if (!existing) throw new NotFoundException('Invoice not found.'); + + if (dto.lineItems !== undefined) { + this.validateLineItems(dto.lineItems); + } + + const invoice = await this.prisma.$transaction(async (tx) => { + if (dto.lineItems !== undefined) { + await tx.invoiceLineItem.deleteMany({ where: { invoiceId } }); + } + + return tx.invoice.update({ + where: { id: invoiceId }, + data: { + ...(dto.dueDate !== undefined && { dueDate: new Date(dto.dueDate) }), + ...(dto.notes !== undefined && { notes: dto.notes }), + ...(dto.lineItems !== undefined && { + lineItems: { + create: dto.lineItems.map((li) => ({ + category: li.category, + description: li.description, + amount: li.amount, + })), + }, + }), + }, + include: INVOICE_INCLUDE, + }); + }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.updated', + targetType: 'Invoice', + targetId: invoiceId, + metadata: { changes: Object.keys(dto) }, + }); + + return { data: this.formatInvoice(invoice) }; + } + + async remove( + orgId: string, + actorId: string, + callerRole: Role, + invoiceId: string, + ): Promise<{ data: { id: string } }> { + this.assertWriteAccess(callerRole); + + const existing = await this.prisma.invoice.findFirst({ + where: { id: invoiceId, orgId }, + }); + if (!existing) throw new NotFoundException('Invoice not found.'); + + await this.prisma.invoice.delete({ where: { id: invoiceId } }); + + await this.timeline.emit({ + orgId, + actorId, + action: 'invoice.deleted', + targetType: 'Invoice', + targetId: invoiceId, + metadata: { leaseId: existing.leaseId }, + }); + + return { data: { id: invoiceId } }; + } } diff --git a/apps/api/src/modules/leases/leases.service.spec.ts b/apps/api/src/modules/leases/leases.service.spec.ts index e8d1e3b5..f50a7ea3 100644 --- a/apps/api/src/modules/leases/leases.service.spec.ts +++ b/apps/api/src/modules/leases/leases.service.spec.ts @@ -24,6 +24,7 @@ describe('LeasesService', () => { lease?: Partial>; apartment?: Partial>; renter?: Partial>; + invoice?: Partial>; buildingAccess?: Partial>; } = {}, ) { @@ -51,6 +52,10 @@ describe('LeasesService', () => { findFirst: jest.fn().mockResolvedValue({ id: renterId, orgId }), ...overrides.renter, }, + invoice: { + count: jest.fn().mockResolvedValue(0), + ...overrides.invoice, + }, }; prisma.$transaction = jest.fn(async (cb: (tx: unknown) => unknown) => cb(prisma), @@ -320,6 +325,25 @@ describe('LeasesService', () => { expect.objectContaining({ action: 'lease.deleted' }), ); }); + + it('rejects deletion when an Invoice references the lease', async () => { + const { service, prisma } = makeService({ + lease: { findFirst: jest.fn().mockResolvedValue(leaseRow()) }, + invoice: { count: jest.fn().mockResolvedValue(1) }, + }); + + await expect( + service.remove( + orgId, + actorId, + buildingId, + floorId, + apartmentId, + 'lease-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.lease.delete).not.toHaveBeenCalled(); + }); }); describe('findAll', () => { diff --git a/apps/api/src/modules/leases/leases.service.ts b/apps/api/src/modules/leases/leases.service.ts index 650b8d0b..0d8ea161 100644 --- a/apps/api/src/modules/leases/leases.service.ts +++ b/apps/api/src/modules/leases/leases.service.ts @@ -330,6 +330,15 @@ export class LeasesService { }); if (!existing) throw new NotFoundException('Lease not found.'); + const invoiceCount = await this.prisma.invoice.count({ + where: { leaseId }, + }); + if (invoiceCount > 0) { + throw new ConflictException( + 'Cannot delete a lease that is referenced by an invoice.', + ); + } + await this.prisma.lease.delete({ where: { id: leaseId } }); await this.timeline.emit({ diff --git a/apps/web/src/app/api/invoices/[id]/route.ts b/apps/web/src/app/api/invoices/[id]/route.ts index ec4b7b53..d1b39fcb 100644 --- a/apps/web/src/app/api/invoices/[id]/route.ts +++ b/apps/web/src/app/api/invoices/[id]/route.ts @@ -3,3 +3,5 @@ import { forwardRoute } from '@/lib/api/forward'; export const runtime = 'nodejs'; export const GET = forwardRoute((params) => `/invoices/${params.id}`); +export const PATCH = forwardRoute((params) => `/invoices/${params.id}`); +export const DELETE = forwardRoute((params) => `/invoices/${params.id}`); diff --git a/apps/web/src/app/api/invoices/route.ts b/apps/web/src/app/api/invoices/route.ts index dbb903c3..a834534d 100644 --- a/apps/web/src/app/api/invoices/route.ts +++ b/apps/web/src/app/api/invoices/route.ts @@ -3,3 +3,4 @@ import { forwardRoute } from '@/lib/api/forward'; export const runtime = 'nodejs'; export const GET = forwardRoute('/invoices'); +export const POST = forwardRoute('/invoices'); diff --git a/apps/web/src/components/dashboard/invoices-page.tsx b/apps/web/src/components/dashboard/invoices-page.tsx index 47131cc3..9e462b93 100644 --- a/apps/web/src/components/dashboard/invoices-page.tsx +++ b/apps/web/src/components/dashboard/invoices-page.tsx @@ -1,11 +1,25 @@ 'use client'; import { useMemo, useState } from 'react'; -import { FileTextIcon, EyeIcon } from 'lucide-react'; +import { useForm, useFieldArray, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { + FileTextIcon, + EyeIcon, + PlusIcon, + MoreHorizontalIcon, + PencilIcon, + TrashIcon, + XIcon, +} from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, @@ -22,10 +36,38 @@ import { TableCell, } from '@/components/ui/table'; import { Skeleton } from '@/components/ui/skeleton'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; -import { useListInvoicesQuery } from '@/store/api/endpoints/invoices.api'; +import { + useListInvoicesQuery, + useCreateInvoiceMutation, + useUpdateInvoiceMutation, + useDeleteInvoiceMutation, +} from '@/store/api/endpoints/invoices.api'; import { useListBuildingsQuery } from '@/store/api/endpoints/buildings.api'; -import type { InvoiceStatus } from '@/types/api'; +import { useListFloorsQuery } from '@/store/api/endpoints/floors.api'; +import { useListApartmentsQuery } from '@/store/api/endpoints/apartments.api'; +import { useListLeasesQuery } from '@/store/api/endpoints/leases.api'; +import { useListRentersQuery } from '@/store/api/endpoints/renters.api'; +import type { + InvoiceLineItemCategory, + InvoiceResponse, + InvoiceStatus, +} from '@/types/api'; // ── Status badge ────────────────────────────────────────────────────────────── @@ -53,12 +95,330 @@ function StatusBadge({ status }: { status: InvoiceStatus }) { ); } +// ── Line item categories ──────────────────────────────────────────────────── + +const LINE_ITEM_CATEGORIES: InvoiceLineItemCategory[] = [ + 'rent', + 'late_fee', + 'utilities', + 'damages', + 'deposit', + 'other', +]; + +const LINE_ITEM_CATEGORY_LABELS: Record = { + rent: 'Rent', + late_fee: 'Late fee', + utilities: 'Utilities', + damages: 'Damages', + deposit: 'Deposit', + other: 'Other', +}; + +// ── Form schema ────────────────────────────────────────────────────────────── + +const NONE = '__none__'; + +const lineItemSchema = z.object({ + category: z.enum([ + 'rent', + 'late_fee', + 'utilities', + 'damages', + 'deposit', + 'other', + ]), + description: z.string().optional(), + amount: z + .string() + .min(1, 'Amount is required') + .refine( + (v) => !Number.isNaN(Number(v)) && Number(v) >= 0, + 'Amount must be a positive number', + ), +}); + +const invoiceSchema = z.object({ + leaseId: z.string().min(1, 'Lease is required'), + dueDate: z.string().min(1, 'Due date is required'), + notes: z.string().optional(), + lineItems: z + .array(lineItemSchema) + .min(1, 'At least one line item is required'), +}); + +type InvoiceFormValues = z.infer; + +const EMPTY_LINE_ITEM = { category: 'rent' as const, description: '', amount: '' }; + +const EMPTY_VALUES: InvoiceFormValues = { + leaseId: '', + dueDate: '', + notes: '', + lineItems: [EMPTY_LINE_ITEM], +}; + +// ── Line items sub-form (create + edit dialogs) ─────────────────────────────── + +function LineItemsFields({ + idPrefix, + control, + register, + errors, +}: { + idPrefix: string; + control: ReturnType>['control']; + register: ReturnType>['register']; + errors: ReturnType>['formState']['errors']; +}) { + const { fields, append, remove } = useFieldArray({ + control, + name: 'lineItems', + }); + + return ( +
+
+ + +
+ {errors.lineItems?.root && ( +

+ {errors.lineItems.root.message} +

+ )} + {errors.lineItems?.message && ( +

{errors.lineItems.message}

+ )} +
+ {fields.map((field, index) => ( +
+
+
+ ( + + )} + /> +
+
+ +
+
+ + {errors.lineItems?.[index]?.amount && ( +

+ {errors.lineItems[index]?.amount?.message} +

+ )} +
+
+ +
+ ))} +
+
+ ); +} + +// ── Lease picker (create dialog only — lease is fixed once created) ────────── + +function LeasePicker({ + value, + onChange, + error, +}: { + value: string; + onChange: (leaseId: string) => void; + error?: string; +}) { + const { data: buildings } = useListBuildingsQuery(); + const [buildingId, setBuildingId] = useState(''); + const [floorId, setFloorId] = useState(''); + const [apartmentId, setApartmentId] = useState(''); + + const { data: floors } = useListFloorsQuery(buildingId, { + skip: !buildingId, + }); + const { data: apartments } = useListApartmentsQuery( + { buildingId, floorId }, + { skip: !floorId }, + ); + const { data: leases } = useListLeasesQuery( + { buildingId, floorId, apartmentId }, + { skip: !apartmentId }, + ); + const { data: renters } = useListRentersQuery(); + + const renterNameById = useMemo(() => { + const map = new Map(); + for (const r of renters ?? []) map.set(r.id, r.fullName); + return map; + }, [renters]); + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + {error &&

{error}

} +
+
+ ); +} + // ── Main component ──────────────────────────────────────────────────────────── const ALL = '__all__'; interface InvoicesPageProps { - /** When false (supervisor), hide all write actions. There are none yet in this pass. */ + /** When false (supervisor), hide all write actions. */ canWrite: boolean; } @@ -66,11 +426,43 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { const { data: invoices, isLoading, isError } = useListInvoicesQuery(); const { data: buildings } = useListBuildingsQuery(); + const [createInvoice, { isLoading: creating }] = useCreateInvoiceMutation(); + const [updateInvoice, { isLoading: updating }] = useUpdateInvoiceMutation(); + const [deleteInvoice, { isLoading: deleting }] = useDeleteInvoiceMutation(); + const [buildingFilter, setBuildingFilter] = useState(ALL); const [statusFilter, setStatusFilter] = useState(ALL); const [fromDate, setFromDate] = useState(''); const [toDate, setToDate] = useState(''); + const [createOpen, setCreateOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + + const { + control: createControl, + register: registerCreate, + handleSubmit: handleCreate, + reset: resetCreate, + formState: { errors: createErrors }, + } = useForm({ + resolver: zodResolver(invoiceSchema), + defaultValues: EMPTY_VALUES, + }); + + const { + control: editControl, + register: registerEdit, + handleSubmit: handleEdit, + reset: resetEdit, + formState: { errors: editErrors }, + } = useForm({ + resolver: zodResolver(invoiceSchema), + defaultValues: EMPTY_VALUES, + }); + const buildingNameById = useMemo(() => { const map = new Map(); for (const b of buildings ?? []) map.set(b.id, b.name); @@ -94,6 +486,73 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { const hasAnyInvoices = (invoices?.length ?? 0) > 0; + async function onCreateSubmit(values: InvoiceFormValues) { + try { + await createInvoice({ + leaseId: values.leaseId, + dueDate: values.dueDate, + notes: values.notes || undefined, + lineItems: values.lineItems.map((li) => ({ + category: li.category, + description: li.description || undefined, + amount: Number(li.amount), + })), + }).unwrap(); + toast.success('Invoice created.'); + setCreateOpen(false); + resetCreate(EMPTY_VALUES); + } catch { + toast.error('Failed to create invoice. Please try again.'); + } + } + + function openEdit(invoice: InvoiceResponse) { + setEditTarget(invoice); + resetEdit({ + leaseId: invoice.leaseId, + dueDate: invoice.dueDate.slice(0, 10), + notes: invoice.notes ?? '', + lineItems: invoice.lineItems.map((li) => ({ + category: li.category, + description: li.description ?? '', + amount: li.amount, + })), + }); + } + + async function onEditSubmit(values: InvoiceFormValues) { + if (!editTarget) return; + try { + await updateInvoice({ + id: editTarget.id, + body: { + dueDate: values.dueDate, + notes: values.notes || null, + lineItems: values.lineItems.map((li) => ({ + category: li.category, + description: li.description || undefined, + amount: Number(li.amount), + })), + }, + }).unwrap(); + toast.success('Invoice updated.'); + setEditTarget(null); + } catch { + toast.error('Failed to update invoice.'); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + try { + await deleteInvoice(deleteTarget.id).unwrap(); + toast.success('Invoice deleted.'); + setDeleteTarget(null); + } catch { + toast.error('Failed to delete invoice. Please try again.'); + } + } + return (
{/* Header */} @@ -104,15 +563,28 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { Bills issued to renters — rent, late fees, utilities, and more.

- {!canWrite && ( - - - Read-only - - )} +
+ {!canWrite && ( + + + Read-only + + )} + {canWrite && ( + + )} +
{/* Filters */} @@ -190,6 +662,7 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { Total Paid Status + {canWrite && } @@ -215,13 +688,14 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { + {canWrite && } ))} ) : isError ? ( Failed to load invoices. Please try again. @@ -230,17 +704,19 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { ) : !hasAnyInvoices ? ( - No invoices recorded yet. + {canWrite + ? 'No invoices recorded yet. Create your first invoice.' + : 'No invoices recorded yet.'} ) : filteredInvoices.length === 0 ? ( No invoices match the selected filters. @@ -268,12 +744,231 @@ export function InvoicesPage({ canWrite }: InvoicesPageProps) { + {canWrite && ( + + + + } + > + + + + openEdit(invoice)}> + + Edit + + + setDeleteTarget(invoice)} + > + + Delete + + + + + )} )) )} + + {/* ── Create Invoice Dialog ───────────────────────────────────────────── */} + + + + New invoice + +
+ ( + + )} + /> +
+ + + {createErrors.dueDate && ( +

+ {createErrors.dueDate.message} +

+ )} +
+ +
+ +