From ff7987771b483b1c5a78b9fc67cf3a53d709a3e7 Mon Sep 17 00:00:00 2001 From: Mouhannad Date: Sun, 26 Jul 2026 22:29:48 +0300 Subject: [PATCH 1/6] feat(rent-payments): org-wide tenant rent-payment register + nav entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was nowhere in the admin nav to register a tenant's rent payment. /dashboard/payments is the platform's own Stripe SUBSCRIPTION billing; tenant rent money (InvoicePayment) was only reachable by drilling Invoices -> invoice detail -> "Record payment", with no org-wide list and no nav item. New surface - /[lang]/dashboard/rent-payments + sidebar "Rent Payments" + topbar title. - New permission area `rentPayments`: org_admin/finance full, supervisor readonly, maintenance/tenant none. The route path is kebab-case while the area key is camelCase, so proxy.ts SUBAREA_ALIASES maps it — otherwise the edge treats it as an unknown area and skips gating layer #1.5. Backend (invoice-payments) - findAll -> PaginatedResponse (was {data:[]}), with invoiceId/buildingId/renterId/method/from/to/q/page/limit (limit clamped to 100) and server-side enrichment: renter, unit, building name, and the parent invoice's derived status/totals. - New GET /invoice-payments/summary: collected, MTD, outstanding balance and a per-method breakdown, computed over the SAME where-clause as the list so the tiles always describe the rows shown. Declared before @Get(':id') — Nest matches routes in declaration order. outstandingTotal reuses computeInvoiceSummary so it reconciles with invoices and reports. - A supervisor's building scope INTERSECTS an explicit buildingId filter (out-of-scope -> {in: []}, never widens) and survives the free-text OR. Frontend - listRentPayments + getRentPaymentSummary; listInvoicePayments now peels `.items` so invoice-detail needed no changes. Create/delete invalidate the register, summary, invoice list and reports. - KPI tiles, filters with debounced search, enriched table, pagination, record dialog (payable-invoice picker + live remaining balance) and delete confirm. - Invoices row action "Record payment" deep-links with ?recordFor=. i18n: new rentPayments namespace, parity 1181 -> 1253 == 1253; payment-method and invoice-status labels are reused from the invoices namespace. No migrations. api jest 353 pass/4 skip (this suite 12 -> 29); web vitest 30/30; check-types and next build clean. Verified against real data in-browser: the full record -> invoice flips to Paid -> delete -> baseline cycle, supervisor read-only in UI and 403 on the server. Co-Authored-By: Claude Opus 5 (1M context) --- .../invoice-payments.controller.ts | 54 +- .../invoice-payments.service.spec.ts | 337 ++++++- .../invoice-payments.service.ts | 342 ++++++- .../[lang]/dashboard/rent-payments/page.tsx | 33 + .../app/api/invoice-payments/summary/route.ts | 6 + apps/web/src/auth/permissions.ts | 17 +- .../components/dashboard/invoices-page.tsx | 13 + .../dashboard/rent-payments-page.tsx | 910 ++++++++++++++++++ .../components/layout/dashboard-sidebar.tsx | 8 + .../components/layout/dashboard-topbar.tsx | 1 + apps/web/src/i18n/dictionaries/ar.json | 90 ++ apps/web/src/i18n/dictionaries/en.json | 90 ++ apps/web/src/proxy.ts | 7 +- .../api/endpoints/invoice-payments.api.ts | 80 +- packages/contracts/src/index.ts | 71 ++ 15 files changed, 2030 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx create mode 100644 apps/web/src/app/api/invoice-payments/summary/route.ts create mode 100644 apps/web/src/components/dashboard/rent-payments-page.tsx diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts index 4726fe6b..d268e221 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.controller.ts @@ -24,19 +24,63 @@ export class InvoicePaymentsController { private readonly orgScope: OrgScopeService, ) {} + /** + * Org-wide rent-payment register (paginated). Also serves the invoice-detail + * payment list via `invoiceId`. A supervisor is narrowed to their assigned + * buildings inside the service. + */ @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) @Get() async getInvoicePayments( @CurrentUser() user: AuthenticatedUser, @Query('invoiceId') invoiceId?: string, + @Query('buildingId') buildingId?: string, + @Query('renterId') renterId?: string, + @Query('method') method?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('q') q?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, ) { const { orgId, role } = await this.orgScope.resolveForCaller(user); - return this.invoicePaymentsService.findAll( - orgId, - user.sub, - role, + return this.invoicePaymentsService.findAll(orgId, user.sub, role, { invoiceId, - ); + buildingId, + renterId, + method, + from, + to, + q, + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }); + } + + /** + * Declared BEFORE `:id` — Nest matches routes in declaration order, so the + * dynamic param would otherwise swallow `/summary`. + */ + @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) + @Get('summary') + async getInvoicePaymentSummary( + @CurrentUser() user: AuthenticatedUser, + @Query('buildingId') buildingId?: string, + @Query('renterId') renterId?: string, + @Query('method') method?: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('q') q?: string, + ) { + const { orgId, role } = await this.orgScope.resolveForCaller(user); + return this.invoicePaymentsService.summary(orgId, user.sub, role, { + buildingId, + renterId, + method, + from, + to, + q, + }); } @Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR) diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts b/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts index 761ed460..6b9b6c41 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.spec.ts @@ -15,6 +15,7 @@ describe('InvoicePaymentsService', () => { overrides: { invoicePayment?: Partial>; invoice?: Partial>; + building?: Partial>; buildingAccess?: Partial>; notifications?: Partial>; } = {}, @@ -23,14 +24,24 @@ describe('InvoicePaymentsService', () => { invoicePayment: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + aggregate: jest + .fn() + .mockResolvedValue({ _sum: { amount: null }, _count: { _all: 0 } }), + groupBy: jest.fn().mockResolvedValue([]), create: jest.fn(), delete: jest.fn(), ...overrides.invoicePayment, }, invoice: { findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), ...overrides.invoice, }, + building: { + findMany: jest.fn().mockResolvedValue([]), + ...overrides.building, + }, }; const buildingAccess = { getAllowedBuildingIds: jest.fn().mockResolvedValue(null), @@ -78,11 +89,30 @@ describe('InvoicePaymentsService', () => { ...overrides, }); + /** A payment row as returned with ENRICHED_INCLUDE (the register list shape). */ + const enrichedRow = (overrides: Partial> = {}) => ({ + ...paymentRow(), + invoice: { + buildingId, + leaseId: 'lease-1', + dueDate: new Date('2099-02-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000.00') }], + payments: [{ amount: decimal('500.00') }], + lease: { + renterId: 'renter-1', + renter: { fullName: 'Sara Haddad' }, + apartment: { unitNumber: 'G-01' }, + }, + }, + ...overrides, + }); + describe('findAll', () => { it('sees the full org for a finance caller', async () => { const { service, prisma, buildingAccess } = makeService({ invoicePayment: { - findMany: jest.fn().mockResolvedValue([paymentRow()]), + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), }, }); @@ -120,7 +150,9 @@ describe('InvoicePaymentsService', () => { it('filters by invoiceId when provided', async () => { const { service, prisma } = makeService(); - await service.findAll(orgId, callerId, Role.ORG_ADMIN, 'invoice-1'); + await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + invoiceId: 'invoice-1', + }); expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -128,6 +160,307 @@ describe('InvoicePaymentsService', () => { }), ); }); + + it('returns the paginated { items, total, page, limit } envelope', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(7), + }, + }); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + page: 2, + limit: 5, + }); + + expect(result.total).toBe(7); + expect(result.page).toBe(2); + expect(result.limit).toBe(5); + expect(result.items).toHaveLength(1); + }); + + it('clamps limit to the maximum and page to at least 1', async () => { + const { service, prisma } = makeService(); + + const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + page: 0, + limit: 5000, + }); + + expect(result.page).toBe(1); + expect(result.limit).toBe(100); + expect(prisma.invoicePayment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 100 }), + ); + }); + + it('enriches each row with renter, unit, building and parent-invoice state', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), + }, + building: { + findMany: jest + .fn() + .mockResolvedValue([{ id: buildingId, name: 'Al Manar' }]), + }, + }); + + const [row] = ( + await service.findAll(orgId, callerId, Role.ORG_ADMIN, {}) + ).items; + + expect(row).toEqual( + expect.objectContaining({ + id: 'payment-1', + amount: '500.00', + method: 'cash', + leaseId: 'lease-1', + renterId: 'renter-1', + renterName: 'Sara Haddad', + buildingId, + buildingName: 'Al Manar', + apartmentUnitNumber: 'G-01', + invoiceTotalAmount: '1000.00', + invoicePaidAmount: '500.00', + invoiceStatus: 'partially_paid', + }), + ); + }); + + it('falls back to the building id when the building name cannot be resolved', async () => { + const { service } = makeService({ + invoicePayment: { + findMany: jest.fn().mockResolvedValue([enrichedRow()]), + count: jest.fn().mockResolvedValue(1), + }, + }); + + const [row] = ( + await service.findAll(orgId, callerId, Role.ORG_ADMIN, {}) + ).items; + + expect(row.buildingName).toBe(buildingId); + }); + + it('applies method, renter, date-range and free-text filters', async () => { + const { service, prisma } = makeService(); + + await service.findAll(orgId, callerId, Role.ORG_ADMIN, { + method: 'bank_transfer', + renterId: 'renter-9', + from: '2026-01-01', + to: '2026-01-31', + q: 'sara', + }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.method).toBe('bank_transfer'); + expect(where.invoice).toEqual({ lease: { renterId: 'renter-9' } }); + expect(where.paidAt).toEqual({ + gte: new Date('2026-01-01'), + lte: new Date('2026-01-31'), + }); + expect(where.OR).toHaveLength(3); + }); + + it('narrows an out-of-scope building filter to nothing for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { + buildingId: 'building-not-mine', + }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.invoice).toEqual({ buildingId: { in: [] } }); + }); + + it('honours an in-scope building filter for a supervisor', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest + .fn() + .mockResolvedValue([buildingId, 'building-2']), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { buildingId }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + expect(where.invoice).toEqual({ buildingId }); + }); + + it('keeps the building scope alongside a free-text search (OR must not widen it)', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.findAll(orgId, callerId, Role.SUPERVISOR, { q: 'cash' }); + + const { where } = prisma.invoicePayment.findMany.mock.calls[0][0]; + // top-level `invoice` and `OR` are ANDed by Prisma → scope survives + expect(where.invoice).toEqual({ buildingId: { in: [buildingId] } }); + expect(where.OR).toHaveLength(3); + }); + + it('rejects an unknown payment method', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { method: 'crypto' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an inverted date range', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { + from: '2026-03-01', + to: '2026-01-01', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects an unparseable date', async () => { + const { service } = makeService(); + + await expect( + service.findAll(orgId, callerId, Role.ORG_ADMIN, { from: 'not-a-date' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('summary', () => { + const now = new Date('2026-02-15T00:00:00.000Z'); + + it('totals collected, MTD and the per-method breakdown', async () => { + const { service } = makeService({ + invoicePayment: { + aggregate: jest + .fn() + .mockResolvedValueOnce({ + _sum: { amount: decimal('1500.00') }, + _count: { _all: 3 }, + }) + .mockResolvedValueOnce({ + _sum: { amount: decimal('500.00') }, + _count: { _all: 1 }, + }), + groupBy: jest.fn().mockResolvedValue([ + { + method: 'cash', + _sum: { amount: decimal('400.00') }, + _count: { _all: 1 }, + }, + { + method: 'bank_transfer', + _sum: { amount: decimal('1100.00') }, + _count: { _all: 2 }, + }, + ]), + }, + }); + + const { data } = await service.summary( + orgId, + callerId, + Role.FINANCE, + {}, + now, + ); + + expect(data.totalCollected).toBe('1500.00'); + expect(data.count).toBe(3); + expect(data.mtdCollected).toBe('500.00'); + expect(data.mtdCount).toBe(1); + // sorted by amount desc + expect(data.byMethod.map((m) => m.method)).toEqual([ + 'bank_transfer', + 'cash', + ]); + expect(data.byMethod[0]).toEqual({ + method: 'bank_transfer', + amount: '1100.00', + count: 2, + }); + }); + + it('sums only unpaid balances into outstandingTotal', async () => { + const { service } = makeService({ + invoice: { + findMany: jest.fn().mockResolvedValue([ + // 1000 billed, 400 paid → 600 outstanding + { + dueDate: new Date('2026-03-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('1000.00') }], + payments: [{ amount: decimal('400.00') }], + }, + // fully settled → contributes nothing + { + dueDate: new Date('2026-03-01T00:00:00.000Z'), + lineItems: [{ amount: decimal('800.00') }], + payments: [{ amount: decimal('800.00') }], + }, + ]), + }, + }); + + const { data } = await service.summary( + orgId, + callerId, + Role.ORG_ADMIN, + {}, + now, + ); + + expect(data.outstandingTotal).toBe('600.00'); + expect(data.outstandingInvoices).toBe(1); + }); + + it('computes MTD from the current UTC month regardless of the picked range', async () => { + const { service, prisma } = makeService(); + + await service.summary( + orgId, + callerId, + Role.ORG_ADMIN, + { from: '2025-01-01', to: '2025-12-31' }, + now, + ); + + const mtdWhere = prisma.invoicePayment.aggregate.mock.calls[1][0].where; + expect(mtdWhere.paidAt).toEqual({ + gte: new Date('2026-02-01T00:00:00.000Z'), + }); + }); + + it('scopes the outstanding balance to a supervisor allowed buildings', async () => { + const { service, prisma } = makeService({ + buildingAccess: { + getAllowedBuildingIds: jest.fn().mockResolvedValue([buildingId]), + }, + }); + + await service.summary(orgId, callerId, Role.SUPERVISOR, {}, now); + + expect(prisma.invoice.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + orgId, + buildingId: { in: [buildingId] }, + }), + }), + ); + }); }); describe('findOne', () => { diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts index 0d965698..478101b6 100644 --- a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts +++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts @@ -11,9 +11,13 @@ import { TimelineService } from '@/modules/timeline/timeline.service'; import { NotificationsService } from '@/modules/notifications/notifications.service'; import { Role } from '@/common/enums'; import { + InvoicePaymentListItem, InvoicePaymentMethod, InvoicePaymentResponse, InvoiceSummarySnapshot, + PaginatedResponse, + RentPaymentMethodBreakdown, + RentPaymentSummaryResponse, } from '@repo/contracts'; import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary'; import { CreateInvoicePaymentDto } from './dto/create-invoice-payment.dto'; @@ -30,6 +34,65 @@ type InvoicePaymentRow = { updatedAt: Date; }; +/** A payment row joined with everything the org-wide register displays. */ +type InvoicePaymentEnrichedRow = InvoicePaymentRow & { + invoice: { + buildingId: string; + leaseId: string; + dueDate: Date; + lineItems: { amount: Prisma.Decimal }[]; + payments: { amount: Prisma.Decimal }[]; + lease: { + renterId: string; + renter: { fullName: string }; + apartment: { unitNumber: string }; + }; + }; +}; + +const ENRICHED_INCLUDE = { + invoice: { + select: { + buildingId: true, + leaseId: true, + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + lease: { + select: { + renterId: true, + renter: { select: { fullName: true } }, + apartment: { select: { unitNumber: true } }, + }, + }, + }, + }, +} satisfies Prisma.InvoicePaymentInclude; + +/** Filters accepted by the org-wide rent-payments register. */ +export type InvoicePaymentListFilters = { + invoiceId?: string; + buildingId?: string; + renterId?: string; + method?: string; + from?: string; + to?: string; + q?: string; + page?: number; + limit?: number; +}; + +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 100; + +const PAYMENT_METHODS: InvoicePaymentMethod[] = [ + 'cash', + 'check', + 'bank_transfer', + 'card', + 'other', +]; + @Injectable() export class InvoicePaymentsService { constructor( @@ -61,6 +124,40 @@ export class InvoicePaymentsService { }; } + /** + * Enriches a payment row with its renter / unit / building / parent-invoice + * state for the org-wide register. `buildingNameById` is resolved by the + * caller in one query — Invoice.buildingId is a bare column (no Building + * relation in the schema), so it cannot be joined in Prisma. + */ + private formatListItem( + payment: InvoicePaymentEnrichedRow, + buildingNameById: Map, + ): InvoicePaymentListItem { + const { invoice } = payment; + const { totalAmount, paidAmount, status } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + new Date(), + ); + + return { + ...this.formatPayment(payment), + leaseId: invoice.leaseId, + renterId: invoice.lease.renterId, + renterName: invoice.lease.renter.fullName, + buildingId: invoice.buildingId, + buildingName: + buildingNameById.get(invoice.buildingId) ?? invoice.buildingId, + apartmentUnitNumber: invoice.lease.apartment.unitNumber, + invoiceDueDate: invoice.dueDate.toISOString(), + invoiceTotalAmount: totalAmount.toFixed(2), + invoicePaidAmount: paidAmount.toFixed(2), + invoiceStatus: status, + }; + } + /** Recomputes totalAmount/paidAmount/status for the parent Invoice. */ private async summarizeInvoice( orgId: string, @@ -90,30 +187,251 @@ export class InvoicePaymentsService { }; } + /** UTC start-of-month — the MTD window lower bound (mirrors ReportsService). */ + private startOfMonthUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + } + + private parseDate(value: string | undefined, field: string): Date | undefined { + if (!value) return undefined; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new BadRequestException(`Invalid "${field}" date.`); + } + return parsed; + } + + /** + * Builds the shared WHERE for the register: org scope, the caller's building + * scope (null = whole org), plus the optional user filters. The list and the + * summary both use this so the tiles always describe exactly the rows shown. + * + * `opts.ignoreDateRange` drops from/to — used for the MTD tile, which keeps a + * stable "this month" meaning regardless of the range the user picked. + */ + private buildListWhere( + orgId: string, + allowedBuildingIds: string[] | null, + filters: InvoicePaymentListFilters, + opts: { ignoreDateRange?: boolean; paidAtFrom?: Date } = {}, + ): Prisma.InvoicePaymentWhereInput { + const { invoiceId, buildingId, renterId, method, q } = filters; + + if (method && !PAYMENT_METHODS.includes(method as InvoicePaymentMethod)) { + throw new BadRequestException(`Unknown payment method "${method}".`); + } + + const from = opts.ignoreDateRange + ? opts.paidAtFrom + : this.parseDate(filters.from, 'from'); + const to = opts.ignoreDateRange + ? undefined + : this.parseDate(filters.to, 'to'); + if (from && to && from > to) { + throw new BadRequestException('"from" must not be after "to".'); + } + + // A supervisor's allowed set intersected with an explicit building filter: + // an out-of-scope buildingId must narrow to nothing, never widen. + const buildingIdFilter: Prisma.StringFilter | string | undefined = + allowedBuildingIds + ? buildingId + ? allowedBuildingIds.includes(buildingId) + ? buildingId + : { in: [] } + : { in: allowedBuildingIds } + : buildingId; + + const invoiceWhere: Prisma.InvoiceWhereInput = { + ...(buildingIdFilter !== undefined && { buildingId: buildingIdFilter }), + ...(renterId && { lease: { renterId } }), + }; + + return { + orgId, + ...(invoiceId && { invoiceId }), + ...(method && { method: method as InvoicePaymentMethod }), + ...((from || to) && { + paidAt: { ...(from && { gte: from }), ...(to && { lte: to }) }, + }), + ...(Object.keys(invoiceWhere).length > 0 && { invoice: invoiceWhere }), + ...(q && { + OR: [ + { notes: { contains: q, mode: 'insensitive' } }, + { + invoice: { + lease: { + renter: { fullName: { contains: q, mode: 'insensitive' } }, + }, + }, + }, + { + invoice: { + lease: { + apartment: { unitNumber: { contains: q, mode: 'insensitive' } }, + }, + }, + }, + ], + }), + }; + } + + private async buildingNamesFor( + orgId: string, + buildingIds: string[], + ): Promise> { + if (buildingIds.length === 0) return new Map(); + const buildings = await this.prisma.building.findMany({ + where: { orgId, id: { in: buildingIds } }, + select: { id: true, name: true }, + }); + return new Map(buildings.map((b) => [b.id, b.name])); + } + + /** + * Org-wide rent-payment register, paginated and enriched. + * + * Returns the `{ items, total, page, limit }` envelope (PaginatedResponse) — + * NOT the `{ data }` envelope this method used before the register existed. + * The invoice-detail view consumes the same endpoint with `invoiceId` set. + */ async findAll( orgId: string, callerId: string, callerRole: Role, - invoiceId?: string, - ): Promise<{ data: InvoicePaymentResponse[] }> { + filters: InvoicePaymentListFilters = {}, + ): Promise> { const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( orgId, callerId, callerRole, ); - const payments = await this.prisma.invoicePayment.findMany({ - where: { - orgId, - ...(invoiceId && { invoiceId }), - ...(allowedBuildingIds && { - invoice: { buildingId: { in: allowedBuildingIds } }, - }), - }, - orderBy: { paidAt: 'desc' }, + const page = Math.max(1, Math.trunc(filters.page ?? 1)); + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Math.trunc(filters.limit ?? DEFAULT_LIMIT)), + ); + const where = this.buildListWhere(orgId, allowedBuildingIds, filters); + + const [rows, total] = await Promise.all([ + this.prisma.invoicePayment.findMany({ + where, + include: ENRICHED_INCLUDE, + orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.invoicePayment.count({ where }), + ]); + + const buildingNameById = await this.buildingNamesFor( + orgId, + [...new Set(rows.map((r) => r.invoice.buildingId))], + ); + + return { + items: rows.map((r) => this.formatListItem(r, buildingNameById)), + total, + page, + limit, + }; + } + + /** + * Headline numbers for the register, over the same filters as the list. + * `outstandingTotal` is the counterpart to what was collected: Σ of the unpaid + * balance across invoices in scope, derived via computeInvoiceSummary so it + * reconciles exactly with the invoice list and the reports page. + */ + async summary( + orgId: string, + callerId: string, + callerRole: Role, + filters: InvoicePaymentListFilters = {}, + now: Date = new Date(), + ): Promise<{ data: RentPaymentSummaryResponse }> { + const allowedBuildingIds = await this.buildingAccess.getAllowedBuildingIds( + orgId, + callerId, + callerRole, + ); + + const where = this.buildListWhere(orgId, allowedBuildingIds, filters); + const mtdWhere = this.buildListWhere(orgId, allowedBuildingIds, filters, { + ignoreDateRange: true, + paidAtFrom: this.startOfMonthUtc(now), }); - return { data: payments.map((p) => this.formatPayment(p)) }; + // Invoices in the same scope, for the outstanding balance. + const invoiceWhere: Prisma.InvoiceWhereInput = { + orgId, + ...(where.invoice as Prisma.InvoiceWhereInput | undefined), + }; + + const [totals, mtdTotals, byMethodRows, invoices] = await Promise.all([ + this.prisma.invoicePayment.aggregate({ + where, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoicePayment.aggregate({ + where: mtdWhere, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoicePayment.groupBy({ + by: ['method'], + where, + _sum: { amount: true }, + _count: { _all: true }, + }), + this.prisma.invoice.findMany({ + where: invoiceWhere, + select: { + dueDate: true, + lineItems: { select: { amount: true } }, + payments: { select: { amount: true } }, + }, + }), + ]); + + let outstandingTotal = 0; + let outstandingInvoices = 0; + for (const invoice of invoices) { + const { totalAmount, paidAmount } = computeInvoiceSummary( + invoice.lineItems.map((li) => ({ amount: li.amount.toNumber() })), + invoice.payments.map((p) => ({ amount: p.amount.toNumber() })), + invoice.dueDate, + now, + ); + const balance = totalAmount - paidAmount; + if (balance > 0) { + outstandingTotal += balance; + outstandingInvoices += 1; + } + } + + const byMethod: RentPaymentMethodBreakdown[] = byMethodRows + .map((row) => ({ + method: row.method as InvoicePaymentMethod, + amount: (row._sum.amount?.toNumber() ?? 0).toFixed(2), + count: row._count._all, + })) + .sort((a, b) => Number(b.amount) - Number(a.amount)); + + return { + data: { + totalCollected: (totals._sum.amount?.toNumber() ?? 0).toFixed(2), + count: totals._count._all, + mtdCollected: (mtdTotals._sum.amount?.toNumber() ?? 0).toFixed(2), + mtdCount: mtdTotals._count._all, + outstandingTotal: outstandingTotal.toFixed(2), + outstandingInvoices, + byMethod, + }, + }; } async findOne( diff --git a/apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx b/apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx new file mode 100644 index 00000000..ab5e150d --- /dev/null +++ b/apps/web/src/app/[lang]/dashboard/rent-payments/page.tsx @@ -0,0 +1,33 @@ +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 { getDictionary } from '@/i18n/get-dictionary'; +import { RentPaymentsPage } from '@/components/dashboard/rent-payments-page'; + +export default async function RentPaymentsPageRoute({ + 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); + + // maintenance + tenant have no access; supervisor is read-only. + if (!canAccess(role, 'rentPayments')) { + redirect(`/${locale}/dashboard`); + } + + const dict = await getDictionary(locale); + + return ( + + ); +} diff --git a/apps/web/src/app/api/invoice-payments/summary/route.ts b/apps/web/src/app/api/invoice-payments/summary/route.ts new file mode 100644 index 00000000..9d0aab0c --- /dev/null +++ b/apps/web/src/app/api/invoice-payments/summary/route.ts @@ -0,0 +1,6 @@ +import { forwardRoute } from '@/lib/api/forward'; + +export const runtime = 'nodejs'; + +// Static segment — Next resolves it ahead of the sibling [id] route. +export const GET = forwardRoute('/invoice-payments/summary'); diff --git a/apps/web/src/auth/permissions.ts b/apps/web/src/auth/permissions.ts index aedbc03c..3953822d 100644 --- a/apps/web/src/auth/permissions.ts +++ b/apps/web/src/auth/permissions.ts @@ -19,10 +19,17 @@ * "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 + * "invoices" → /dashboard/invoices (invoices + the per-invoice payment + * list on invoice detail, same as "tasks" covering Maintenance * Requests + Work Orders; distinct from "payments"/"billing", * which are the platform's own Stripe subscription billing) + * "rentPayments" → /dashboard/rent-payments — the org-wide register of rent + * money RECEIVED from tenants (InvoicePayment). Deliberately + * its own area, not folded into "invoices": recording money is + * a finance action, and a supervisor may read the register + * (building-scoped, server-side) without any write rights. + * Note the path is kebab-case while the area key is camelCase — + * proxy.ts maps the two via SUBAREA_ALIASES. * "leases" → /dashboard/leases (org-wide, top-level lease list; mirrors * "buildings" for access — full lease CRUD still lives under * /dashboard/buildings/:id/floors/:id/apartments/:id) @@ -48,6 +55,7 @@ export type DashboardArea = | 'vendors' | 'expenses' | 'invoices' + | 'rentPayments' | 'support' | 'leases' | 'notifications'; @@ -75,6 +83,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'full', expenses: 'full', invoices: 'full', + rentPayments: 'full', support: 'full', leases: 'full', notifications: 'full', @@ -91,6 +100,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'readonly', invoices: 'readonly', + rentPayments: 'readonly', support: 'full', leases: 'readonly', notifications: 'full', @@ -107,6 +117,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'full', invoices: 'full', + rentPayments: 'full', support: 'readonly', leases: 'readonly', notifications: 'full', @@ -123,6 +134,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'readonly', expenses: 'none', invoices: 'none', + rentPayments: 'none', support: 'readonly', leases: 'readonly', notifications: 'full', @@ -139,6 +151,7 @@ export const PERMISSION_MATRIX: PermissionMatrix = { vendors: 'none', expenses: 'none', invoices: 'none', + rentPayments: 'none', // readonly = the tenant sees the Support area and can open tickets (the // "New ticket" button is unconditional), but NOT the staff-only status // transition actions (acknowledge/resolve/close), which the API 403s anyway. diff --git a/apps/web/src/components/dashboard/invoices-page.tsx b/apps/web/src/components/dashboard/invoices-page.tsx index de538fa9..276c02de 100644 --- a/apps/web/src/components/dashboard/invoices-page.tsx +++ b/apps/web/src/components/dashboard/invoices-page.tsx @@ -15,6 +15,7 @@ import { TrashIcon, XIcon, RefreshCwIcon, + WalletIcon, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -897,6 +898,18 @@ export function InvoicesPage({ canWrite, locale, dict }: InvoicesPageProps) { {dict.common.edit} + {invoice.status !== 'paid' && ( + + router.push( + `/${locale}/dashboard/rent-payments?recordFor=${encodeURIComponent(invoice.id)}`, + ) + } + > + + {t.list.recordPayment} + + )} = { + 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', +}; + +// ── Record-payment form ────────────────────────────────────────────────────── + +type RecordErrors = + Dictionary['rentPayments']['dialog']['record']['errors']; + +function buildPaymentSchema(errors: RecordErrors) { + return z.object({ + invoiceId: z.string().min(1, errors.invoiceRequired), + amount: z + .string() + .min(1, errors.amountRequired) + .refine( + (v) => !Number.isNaN(Number(v)) && Number(v) > 0, + errors.amountPositive, + ), + method: z.enum(['cash', 'check', 'bank_transfer', 'card', 'other']), + paidAt: z.string().min(1, errors.paidAtRequired), + notes: z.string().optional(), + }); +} + +type PaymentFormValues = z.infer>; + +/** Today in `yyyy-MM-dd` — the natural default for "when was this paid". */ +function todayInputValue(): string { + return new Date().toISOString().slice(0, 10); +} + +function emptyPayment(): PaymentFormValues { + return { + invoiceId: '', + amount: '', + method: 'cash', + paidAt: todayInputValue(), + notes: '', + }; +} + +function invoiceBalance(invoice: InvoiceResponse): number { + return Number(invoice.totalAmount) - Number(invoice.paidAmount); +} + +// ── Main component ─────────────────────────────────────────────────────────── + +interface RentPaymentsPageProps { + locale: string; + /** org_admin + finance may record/delete; supervisor is read-only. */ + canWrite: boolean; + dict: Dictionary; +} + +export function RentPaymentsPage({ + locale, + canWrite, + dict, +}: RentPaymentsPageProps) { + const t = dict.rentPayments; + const money = useMoney(locale); + const dateFormatter = useMemo( + () => new Intl.DateTimeFormat(locale === 'ar' ? 'ar' : 'en', { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + [locale], + ); + const formatDate = (iso: string) => dateFormatter.format(new Date(iso)); + + /** Picks the singular copy for a count of exactly 1 (no ICU in these dicts). */ + const countHint = (count: number, plural: string, one: string) => + (count === 1 ? one : plural).replace('{count}', String(count)); + + // ── Filters ─────────────────────────────────────────────────────────────── + const [buildingFilter, setBuildingFilter] = useState(ALL); + const [methodFilter, setMethodFilter] = useState(ALL); + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + + // Debounce the free-text box so typing doesn't fire a request per keystroke. + useEffect(() => { + const id = setTimeout(() => setSearch(searchInput.trim()), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(id); + }, [searchInput]); + + const filters = useMemo( + () => ({ + ...(buildingFilter !== ALL && { buildingId: buildingFilter }), + ...(methodFilter !== ALL && { + method: methodFilter as InvoicePaymentMethod, + }), + ...(fromDate && { from: fromDate }), + ...(toDate && { to: toDate }), + ...(search && { q: search }), + }), + [buildingFilter, methodFilter, fromDate, toDate, search], + ); + + const hasActiveFilters = Object.keys(filters).length > 0; + + // A narrower result set can leave the current page out of range. + useEffect(() => { + setPage(1); + }, [filters]); + + // ── Data ────────────────────────────────────────────────────────────────── + const { data, isLoading, isFetching, isError } = useListRentPaymentsQuery({ + ...filters, + page, + limit: PAGE_SIZE, + }); + const { data: summary, isLoading: summaryLoading } = + useGetRentPaymentSummaryQuery(filters); + const { data: buildings } = useListBuildingsQuery(); + // Only needed for the invoice picker in the record dialog. + const { data: invoices } = useListInvoicesQuery(undefined, { + skip: !canWrite, + }); + + const [createPayment, { isLoading: creating }] = + useCreateInvoicePaymentMutation(); + const [deletePayment, { isLoading: deleting }] = + useDeleteInvoicePaymentMutation(); + + const [recordOpen, setRecordOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = + useState(null); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const rangeFrom = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1; + const rangeTo = Math.min(page * PAGE_SIZE, total); + + /** Invoices with money still owed — the only ones worth paying against. */ + const payableInvoices = useMemo( + () => + (invoices ?? []) + .filter((invoice) => invoiceBalance(invoice) > 0) + .sort((a, b) => a.dueDate.localeCompare(b.dueDate)), + [invoices], + ); + + // ── Record form ─────────────────────────────────────────────────────────── + const schema = useMemo( + () => buildPaymentSchema(t.dialog.record.errors), + [t.dialog.record.errors], + ); + + const { + control, + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: emptyPayment(), + }); + + const selectedInvoiceId = watch('invoiceId'); + const selectedInvoice = payableInvoices.find( + (invoice) => invoice.id === selectedInvoiceId, + ); + const selectedBalance = selectedInvoice + ? invoiceBalance(selectedInvoice) + : null; + + function invoiceOptionLabel(invoice: InvoiceResponse): string { + return t.dialog.record.invoiceOption + .replace('{renter}', invoice.renterName) + .replace('{unit}', invoice.apartmentUnitNumber) + .replace('{due}', formatDate(invoice.dueDate)); + } + + async function onRecordSubmit(values: PaymentFormValues) { + try { + await createPayment({ + invoiceId: values.invoiceId, + amount: Number(values.amount), + method: values.method, + paidAt: values.paidAt, + notes: values.notes || undefined, + }).unwrap(); + toast.success(t.dialog.record.success); + setRecordOpen(false); + reset(emptyPayment()); + } catch { + toast.error(t.dialog.record.error); + } + } + + async function handleDelete() { + if (!deleteTarget) return; + try { + await deletePayment({ + id: deleteTarget.id, + invoiceId: deleteTarget.invoiceId, + }).unwrap(); + toast.success(t.dialog.delete.success); + setDeleteTarget(null); + } catch { + toast.error(t.dialog.delete.error); + } + } + + // ── Deep link: /rent-payments?recordFor= ────────────────────── + // Sent by the "Record payment" row action on the invoices list. Opens the + // dialog with that invoice preselected. Runs once per invoice id (a ref, not + // state, so closing the dialog doesn't immediately re-open it). + const handledDeepLink = useRef(null); + const searchParams = useSearchParams(); + const recordFor = searchParams.get('recordFor'); + + useEffect(() => { + if (!canWrite || !recordFor) return; + if (handledDeepLink.current === recordFor) return; + // Wait until the invoice list has loaded so the picker can resolve the id. + if (!payableInvoices.some((invoice) => invoice.id === recordFor)) return; + + handledDeepLink.current = recordFor; + reset({ ...emptyPayment(), invoiceId: recordFor }); + setRecordOpen(true); + }, [canWrite, recordFor, payableInvoices, reset]); + + function clearFilters() { + setBuildingFilter(ALL); + setMethodFilter(ALL); + setFromDate(''); + setToDate(''); + setSearchInput(''); + } + + const buildingNameById = useMemo(() => { + const map = new Map(); + for (const b of buildings ?? []) map.set(b.id, b.name); + return map; + }, [buildings]); + + const columnCount = canWrite ? 9 : 8; + + return ( +
+ {/* Header */} +
+
+

{t.title}

+

{t.subtitle}

+
+
+ {!canWrite && ( + + + {t.readOnly} + + )} + {canWrite && ( + + )} +
+
+ + {/* KPIs */} +
+ } + loading={summaryLoading} + /> + } + tone="positive" + loading={summaryLoading} + /> + } + tone={ + Number(summary?.outstandingTotal ?? 0) > 0 ? 'negative' : 'neutral' + } + href={`/${locale}/dashboard/invoices`} + loading={summaryLoading} + /> + + {summary?.byMethod.length + ? dict.invoices.paymentMethod[summary.byMethod[0].method] + : '—'} + + } + icon={} + loading={summaryLoading} + > +
+ {(summary?.byMethod ?? []).slice(0, 3).map((row) => ( +
+ {dict.invoices.paymentMethod[row.method]} + + {money.format(Number(row.amount))} + +
+ ))} +
+
+
+ + {/* Filters */} +
+
+ + setSearchInput(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
+ {hasActiveFilters && ( + + )} +
+ + {/* Table */} +
+ + + + {t.table.renter} + {t.table.unit} + {t.table.building} + {t.table.amount} + {t.table.method} + {t.table.paidAt} + {t.table.invoice} + {t.table.notes} + {canWrite && } + + + + {isLoading ? ( + [...Array(4)].map((_, i) => ( + + {[...Array(columnCount)].map((__, j) => ( + + + + ))} + + )) + ) : isError ? ( + + + {t.loadError} + + + ) : rows.length === 0 ? ( + + + + {hasActiveFilters + ? t.noMatch + : canWrite + ? t.emptyWrite + : t.empty} + + + ) : ( + rows.map((row) => ( + + + + {row.renterName} + + + + {row.apartmentUnitNumber} + + + + {row.buildingName} + + + + {money.format(Number(row.amount))} + + + {dict.invoices.paymentMethod[row.method] ?? row.method} + + + {formatDate(row.paidAt)} + + + + + {dict.invoices.status[row.invoiceStatus] ?? + row.invoiceStatus} + + + {money.format(Number(row.invoicePaidAmount))} /{' '} + {money.format(Number(row.invoiceTotalAmount))} + + + + + {row.notes ?? '—'} + + {canWrite && ( + + + + )} + + )) + )} + +
+
+ + {/* Pagination */} + {total > 0 && ( +
+

+ {t.pagination.showing + .replace('{from}', String(rangeFrom)) + .replace('{to}', String(rangeTo)) + .replace('{total}', String(total))} +

+
+ + +
+
+ )} + + {/* ── Record Payment Dialog ─────────────────────────────────────────── */} + + + + {t.dialog.record.title} + +
+

+ {t.dialog.record.description} +

+ +
+ + {payableInvoices.length === 0 ? ( +

+ {t.dialog.record.noOpenInvoices} +

+ ) : ( + ( + + )} + /> + )} + {errors.invoiceId && ( +

+ {errors.invoiceId.message} +

+ )} + {selectedBalance !== null && ( +

+ {t.dialog.record.invoiceBalance.replace( + '{balance}', + money.format(selectedBalance), + )} +

+ )} +
+ +
+ + + {selectedBalance !== null && selectedBalance > 0 && ( + + )} + {errors.amount && ( +

+ {errors.amount.message} +

+ )} +
+ +
+ + ( + + )} + /> +
+ +
+ + + {errors.paidAt && ( +

+ {errors.paidAt.message} +

+ )} +
+ +
+ +