diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index f9d9411e..d00c8c7b 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -44,6 +44,9 @@ import { NotificationsModule } from '@/modules/notifications/notifications.modul
import { SupportTicketsModule } from '@/modules/support-tickets/support-tickets.module';
import { ReportsModule } from '@/modules/reports/reports.module';
import { TenantModule } from '@/modules/tenant/tenant.module';
+import { RecurringInvoicesModule } from '@/modules/recurring-invoices/recurring-invoices.module';
+import { ApartmentStatusSweepModule } from '@/modules/apartment-status-sweep/apartment-status-sweep.module';
+import { AvailableUnitsModule } from '@/modules/available-units/available-units.module';
@Module({
imports: [
@@ -110,6 +113,9 @@ import { TenantModule } from '@/modules/tenant/tenant.module';
SupportTicketsModule,
ReportsModule,
TenantModule,
+ RecurringInvoicesModule,
+ ApartmentStatusSweepModule,
+ AvailableUnitsModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts
new file mode 100644
index 00000000..03488e71
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep-scheduler.service.ts
@@ -0,0 +1,44 @@
+import { InjectQueue } from '@nestjs/bullmq';
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { Queue } from 'bullmq';
+import {
+ APARTMENT_STATUS_SWEEP_CRON,
+ APARTMENT_STATUS_SWEEP_DAILY_JOB_ID,
+ APARTMENT_STATUS_SWEEP_QUEUE,
+ APARTMENT_STATUS_SWEEP_RUN_JOB,
+} from './apartment-status-sweep.constants';
+
+/**
+ * Registers the daily repeatable job on module init (there is no
+ * `@nestjs/schedule` in this app — BullMQ's repeatable jobs are the
+ * scheduling primitive here, same as the rest of the queue infra). A fixed
+ * jobId means BullMQ upserts the same repeatable job definition on every app
+ * restart instead of accumulating duplicate schedules.
+ */
+@Injectable()
+export class ApartmentStatusSweepSchedulerService implements OnModuleInit {
+ private readonly logger = new Logger(
+ ApartmentStatusSweepSchedulerService.name,
+ );
+
+ constructor(
+ @InjectQueue(APARTMENT_STATUS_SWEEP_QUEUE) private readonly queue: Queue,
+ ) {}
+
+ async onModuleInit(): Promise {
+ try {
+ await this.queue.add(
+ APARTMENT_STATUS_SWEEP_RUN_JOB,
+ {},
+ {
+ repeat: { pattern: APARTMENT_STATUS_SWEEP_CRON },
+ jobId: APARTMENT_STATUS_SWEEP_DAILY_JOB_ID,
+ },
+ );
+ } catch (error) {
+ this.logger.error(
+ `Failed to schedule the daily apartment-status sweep job: ${String(error)}`,
+ );
+ }
+ }
+}
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts
new file mode 100644
index 00000000..c75e3b0f
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.constants.ts
@@ -0,0 +1,16 @@
+/** Name of the BullMQ queue that drives the daily apartment-status sweep. */
+export const APARTMENT_STATUS_SWEEP_QUEUE = 'apartment-status-sweep';
+
+/** Job name used for the daily sweep run. */
+export const APARTMENT_STATUS_SWEEP_RUN_JOB = 'run-daily';
+
+/**
+ * Fixed jobId for the repeatable job. BullMQ upserts a repeatable job by its
+ * (name, jobId, repeat options) — reusing this id on every app restart avoids
+ * accumulating duplicate schedules.
+ */
+export const APARTMENT_STATUS_SWEEP_DAILY_JOB_ID =
+ 'apartment-status-sweep-daily';
+
+/** Daily at 07:00 UTC — an hour after the recurring-invoices run. */
+export const APARTMENT_STATUS_SWEEP_CRON = '0 7 * * *';
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts
new file mode 100644
index 00000000..b1792843
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.controller.ts
@@ -0,0 +1,33 @@
+import { Controller, Post } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { ApartmentStatusSweepService } from './apartment-status-sweep.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';
+
+/**
+ * Manual "sweep now" trigger for the apartment-status expiry sweep (F4.2).
+ * Runs the same idempotent core the daily scheduler runs, scoped to the
+ * caller's org. Mounted at `/apartments` (not a dedicated top-level path)
+ * since a status sweep reads most naturally as an apartments action; it
+ * does not collide with the nested
+ * buildings/:buildingId/floors/:floorId/apartments controller since Nest
+ * matches on the full path.
+ */
+@ApiTags('apartments')
+@ApiBearerAuth()
+@Controller('apartments')
+@Roles(Role.ORG_ADMIN)
+export class ApartmentStatusSweepController {
+ constructor(
+ private readonly apartmentStatusSweep: ApartmentStatusSweepService,
+ private readonly orgScope: OrgScopeService,
+ ) {}
+
+ @Post('status-sweep')
+ async runSweep(@CurrentUser() user: AuthenticatedUser) {
+ const { orgId } = await this.orgScope.resolveForCaller(user);
+ return this.apartmentStatusSweep.sweepForOrg(orgId);
+ }
+}
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts
new file mode 100644
index 00000000..c2ae9c3d
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.module.ts
@@ -0,0 +1,19 @@
+import { Module } from '@nestjs/common';
+import { BullModule } from '@nestjs/bullmq';
+import { ApartmentStatusSweepController } from './apartment-status-sweep.controller';
+import { ApartmentStatusSweepService } from './apartment-status-sweep.service';
+import { ApartmentStatusSweepProcessor } from './apartment-status-sweep.processor';
+import { ApartmentStatusSweepSchedulerService } from './apartment-status-sweep-scheduler.service';
+import { APARTMENT_STATUS_SWEEP_QUEUE } from './apartment-status-sweep.constants';
+
+@Module({
+ imports: [BullModule.registerQueue({ name: APARTMENT_STATUS_SWEEP_QUEUE })],
+ controllers: [ApartmentStatusSweepController],
+ providers: [
+ ApartmentStatusSweepService,
+ ApartmentStatusSweepProcessor,
+ ApartmentStatusSweepSchedulerService,
+ ],
+ exports: [ApartmentStatusSweepService],
+})
+export class ApartmentStatusSweepModule {}
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts
new file mode 100644
index 00000000..27bcdb4f
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.processor.ts
@@ -0,0 +1,27 @@
+import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Logger } from '@nestjs/common';
+import { Job } from 'bullmq';
+import { APARTMENT_STATUS_SWEEP_QUEUE } from './apartment-status-sweep.constants';
+import { ApartmentStatusSweepService } from './apartment-status-sweep.service';
+
+/**
+ * Consumes the daily repeatable job and sweeps every org. The manual
+ * `POST /apartments/status-sweep` endpoint calls
+ * {@link ApartmentStatusSweepService.sweepForOrg} directly (single-org,
+ * synchronous response) — this processor is only reached by the scheduled job.
+ */
+@Processor(APARTMENT_STATUS_SWEEP_QUEUE)
+export class ApartmentStatusSweepProcessor extends WorkerHost {
+ private readonly logger = new Logger(ApartmentStatusSweepProcessor.name);
+
+ constructor(
+ private readonly apartmentStatusSweep: ApartmentStatusSweepService,
+ ) {
+ super();
+ }
+
+ async process(job: Job): Promise {
+ this.logger.debug(`Starting apartment-status sweep (job ${job.id})`);
+ await this.apartmentStatusSweep.sweepAll();
+ }
+}
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts
new file mode 100644
index 00000000..c771101f
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.spec.ts
@@ -0,0 +1,145 @@
+import { ApartmentStatusSweepService } from './apartment-status-sweep.service';
+import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
+
+describe('ApartmentStatusSweepService', () => {
+ const orgId = 'org-1';
+ const FAR_FUTURE = new Date('2099-01-01T00:00:00.000Z');
+ const FAR_PAST = new Date('2000-01-01T00:00:00.000Z');
+
+ function makeService(
+ overrides: {
+ apartment?: Partial>;
+ } = {},
+ ) {
+ const prisma: any = {
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([]),
+ update: jest.fn().mockResolvedValue({}),
+ ...overrides.apartment,
+ },
+ };
+ const leaseStatus = new LeaseStatusService();
+ const service = new ApartmentStatusSweepService(prisma, leaseStatus);
+ return { service, prisma };
+ }
+
+ describe('sweepForOrg', () => {
+ it('queries only occupied apartments, scoped to the org', async () => {
+ const { service, prisma } = makeService();
+
+ await service.sweepForOrg(orgId);
+
+ expect(prisma.apartment.findMany).toHaveBeenCalledWith({
+ where: { orgId, status: 'occupied' },
+ select: {
+ id: true,
+ leases: { select: { status: true, endDate: true } },
+ },
+ });
+ });
+
+ it('reverts an occupied apartment with no effectively-active lease to vacant', async () => {
+ const { service, prisma } = makeService({
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([
+ {
+ id: 'apt-1',
+ leases: [{ status: 'terminated', endDate: FAR_PAST }],
+ },
+ ]),
+ },
+ });
+
+ const result = await service.sweepForOrg(orgId);
+
+ expect(prisma.apartment.update).toHaveBeenCalledWith({
+ where: { id: 'apt-1' },
+ data: { status: 'vacant' },
+ });
+ expect(result).toEqual({ reverted: 1, considered: 1 });
+ });
+
+ it('reverts an occupied apartment with no leases at all', async () => {
+ const { service, prisma } = makeService({
+ apartment: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([{ id: 'apt-1', leases: [] }]),
+ },
+ });
+
+ const result = await service.sweepForOrg(orgId);
+
+ expect(prisma.apartment.update).toHaveBeenCalledWith({
+ where: { id: 'apt-1' },
+ data: { status: 'vacant' },
+ });
+ expect(result).toEqual({ reverted: 1, considered: 1 });
+ });
+
+ it('leaves an occupied apartment alone when it still has an effectively-active lease', async () => {
+ const { service, prisma } = makeService({
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([
+ {
+ id: 'apt-1',
+ leases: [{ status: 'active', endDate: FAR_FUTURE }],
+ },
+ ]),
+ },
+ });
+
+ const result = await service.sweepForOrg(orgId);
+
+ expect(prisma.apartment.update).not.toHaveBeenCalled();
+ expect(result).toEqual({ reverted: 0, considered: 1 });
+ });
+
+ it('reports considered/reverted counts across a mix of apartments', async () => {
+ const { service, prisma } = makeService({
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([
+ { id: 'apt-1', leases: [] },
+ {
+ id: 'apt-2',
+ leases: [{ status: 'active', endDate: FAR_FUTURE }],
+ },
+ {
+ id: 'apt-3',
+ leases: [{ status: 'active', endDate: FAR_PAST }], // expired
+ },
+ ]),
+ },
+ });
+
+ const result = await service.sweepForOrg(orgId);
+
+ expect(prisma.apartment.update).toHaveBeenCalledTimes(2);
+ expect(prisma.apartment.update).toHaveBeenCalledWith({
+ where: { id: 'apt-1' },
+ data: { status: 'vacant' },
+ });
+ expect(prisma.apartment.update).toHaveBeenCalledWith({
+ where: { id: 'apt-3' },
+ data: { status: 'vacant' },
+ });
+ expect(result).toEqual({ reverted: 2, considered: 3 });
+ });
+ });
+
+ describe('sweepAll', () => {
+ it('sweeps across every org (no orgId filter)', async () => {
+ const { service, prisma } = makeService();
+
+ await service.sweepAll();
+
+ expect(prisma.apartment.findMany).toHaveBeenCalledWith({
+ where: { status: 'occupied' },
+ select: {
+ id: true,
+ leases: { select: { status: true, endDate: true } },
+ },
+ });
+ });
+ });
+});
diff --git a/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts
new file mode 100644
index 00000000..f583a18c
--- /dev/null
+++ b/apps/api/src/modules/apartment-status-sweep/apartment-status-sweep.service.ts
@@ -0,0 +1,80 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '@/infrastructure/prisma/prisma.service';
+import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
+import { ApartmentStatusSweepResponse } from '@repo/contracts';
+
+/**
+ * F4.2: leases only sync the apartment status at create/update/renew time —
+ * nothing continuously re-checks it, so an apartment left `occupied` by a
+ * lease that has since expired (or was terminated without a replacement)
+ * stays stuck `occupied` forever. This sweep finds `occupied` apartments with
+ * no effectively-active lease and reverts them to `vacant`.
+ *
+ * Only `occupied` apartments are ever touched: `maintenance` and
+ * `unavailable` apartments are excluded by the query itself (status ===
+ * 'occupied'), so an apartment with an open work order — which
+ * WorkOrderApartmentStatusService keeps at `maintenance` — is never
+ * considered here, let alone reverted.
+ */
+@Injectable()
+export class ApartmentStatusSweepService {
+ private readonly logger = new Logger(ApartmentStatusSweepService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly leaseStatus: LeaseStatusService,
+ ) {}
+
+ /**
+ * Pure, idempotent core: revert every `occupied` apartment (optionally
+ * scoped to `orgId`) that has no effectively-active lease as of `asOf`.
+ */
+ private async sweep(
+ orgId: string | undefined,
+ asOf: Date,
+ ): Promise {
+ const apartments = await this.prisma.apartment.findMany({
+ where: { ...(orgId && { orgId }), status: 'occupied' },
+ select: {
+ id: true,
+ leases: { select: { status: true, endDate: true } },
+ },
+ });
+
+ let reverted = 0;
+ for (const apartment of apartments) {
+ const hasActiveLease = apartment.leases.some((lease) =>
+ this.leaseStatus.isEffectivelyActive(
+ { status: lease.status, endDate: lease.endDate },
+ asOf,
+ ),
+ );
+ if (!hasActiveLease) {
+ await this.prisma.apartment.update({
+ where: { id: apartment.id },
+ data: { status: 'vacant' },
+ });
+ reverted++;
+ }
+ }
+
+ return { reverted, considered: apartments.length };
+ }
+
+ /** Sweep a single org — the manual `POST /apartments/status-sweep` trigger. */
+ async sweepForOrg(
+ orgId: string,
+ asOf: Date = new Date(),
+ ): Promise {
+ return this.sweep(orgId, asOf);
+ }
+
+ /** Sweep every org — the daily scheduler's entry point. */
+ async sweepAll(asOf: Date = new Date()): Promise {
+ const result = await this.sweep(undefined, asOf);
+ this.logger.log(
+ `Apartment status sweep: reverted=${result.reverted} considered=${result.considered}`,
+ );
+ return result;
+ }
+}
diff --git a/apps/api/src/modules/available-units/available-units.controller.ts b/apps/api/src/modules/available-units/available-units.controller.ts
new file mode 100644
index 00000000..021964fa
--- /dev/null
+++ b/apps/api/src/modules/available-units/available-units.controller.ts
@@ -0,0 +1,38 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { AvailableUnitsService } from './available-units.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';
+
+/**
+ * F6.2 — org-wide, read-only "Available units" showcase for prospective
+ * renters. A separate top-level controller (mirrors LeasesOverviewController
+ * / WorkOrdersOverviewController), not nested under buildings, since it lists
+ * vacant apartments across the whole org rather than a single building.
+ * Every member role — including TENANT — can view it; prospective tenants
+ * express interest via a support ticket rather than an action here.
+ */
+@ApiTags('available-units')
+@ApiBearerAuth()
+@Controller('available-units')
+export class AvailableUnitsController {
+ constructor(
+ private readonly availableUnitsService: AvailableUnitsService,
+ private readonly orgScope: OrgScopeService,
+ ) {}
+
+ @Roles(
+ Role.ORG_ADMIN,
+ Role.SUPERVISOR,
+ Role.FINANCE,
+ Role.MAINTENANCE,
+ Role.TENANT,
+ )
+ @Get()
+ async getAvailableUnits(@CurrentUser() user: AuthenticatedUser) {
+ const { orgId } = await this.orgScope.resolveForCaller(user);
+ return this.availableUnitsService.findAvailable(orgId);
+ }
+}
diff --git a/apps/api/src/modules/available-units/available-units.module.ts b/apps/api/src/modules/available-units/available-units.module.ts
new file mode 100644
index 00000000..65ba87cc
--- /dev/null
+++ b/apps/api/src/modules/available-units/available-units.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { AvailableUnitsController } from './available-units.controller';
+import { AvailableUnitsService } from './available-units.service';
+
+@Module({
+ controllers: [AvailableUnitsController],
+ providers: [AvailableUnitsService],
+})
+export class AvailableUnitsModule {}
diff --git a/apps/api/src/modules/available-units/available-units.service.spec.ts b/apps/api/src/modules/available-units/available-units.service.spec.ts
new file mode 100644
index 00000000..ab1db722
--- /dev/null
+++ b/apps/api/src/modules/available-units/available-units.service.spec.ts
@@ -0,0 +1,83 @@
+import { AvailableUnitsService } from './available-units.service';
+
+describe('AvailableUnitsService', () => {
+ const orgId = 'org-1';
+
+ function makeService(
+ overrides: {
+ apartment?: Partial>;
+ } = {},
+ ) {
+ const prisma: any = {
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([]),
+ ...overrides.apartment,
+ },
+ };
+ const service = new AvailableUnitsService(prisma);
+ return { service, prisma };
+ }
+
+ describe('findAvailable', () => {
+ it('scopes the query to the org and vacant apartments only, ordered by building then unit number', async () => {
+ const { service, prisma } = makeService();
+
+ await service.findAvailable(orgId);
+
+ expect(prisma.apartment.findMany).toHaveBeenCalledWith({
+ where: { orgId, status: 'vacant' },
+ include: {
+ building: { select: { name: true } },
+ floor: { select: { name: true } },
+ },
+ orderBy: [{ building: { name: 'asc' } }, { unitNumber: 'asc' }],
+ });
+ });
+
+ it('maps each vacant apartment to an AvailableUnit enriched with building/floor names', async () => {
+ const { service } = makeService({
+ apartment: {
+ findMany: jest.fn().mockResolvedValue([
+ {
+ id: 'apt-1',
+ buildingId: 'building-1',
+ floorId: 'floor-1',
+ unitNumber: '101',
+ bedrooms: 2,
+ bathrooms: { toString: () => '1.5' },
+ sqft: 850,
+ building: { name: 'Sunrise Tower' },
+ floor: { name: 'Ground Floor' },
+ },
+ ]),
+ },
+ });
+
+ const result = await service.findAvailable(orgId);
+
+ expect(result).toEqual({
+ data: [
+ {
+ id: 'apt-1',
+ buildingId: 'building-1',
+ buildingName: 'Sunrise Tower',
+ floorId: 'floor-1',
+ floorName: 'Ground Floor',
+ unitNumber: '101',
+ bedrooms: 2,
+ bathrooms: '1.5',
+ sqft: 850,
+ },
+ ],
+ });
+ });
+
+ it('returns an empty list when the org has no vacant apartments', async () => {
+ const { service } = makeService();
+
+ const result = await service.findAvailable(orgId);
+
+ expect(result).toEqual({ data: [] });
+ });
+ });
+});
diff --git a/apps/api/src/modules/available-units/available-units.service.ts b/apps/api/src/modules/available-units/available-units.service.ts
new file mode 100644
index 00000000..2578d713
--- /dev/null
+++ b/apps/api/src/modules/available-units/available-units.service.ts
@@ -0,0 +1,57 @@
+import { Injectable } from '@nestjs/common';
+import { Prisma } from '@repo/db';
+import { PrismaService } from '@/infrastructure/prisma/prisma.service';
+import { AvailableUnit, AvailableUnitListResponse } from '@repo/contracts';
+
+@Injectable()
+export class AvailableUnitsService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ // ── Format helpers ────────────────────────────────────────────────────────
+
+ private formatAvailableUnit(apartment: {
+ id: string;
+ buildingId: string;
+ floorId: string;
+ unitNumber: string;
+ bedrooms: number;
+ bathrooms: Prisma.Decimal;
+ sqft: number | null;
+ building: { name: string };
+ floor: { name: string };
+ }): AvailableUnit {
+ return {
+ id: apartment.id,
+ buildingId: apartment.buildingId,
+ buildingName: apartment.building.name,
+ floorId: apartment.floorId,
+ floorName: apartment.floor.name,
+ unitNumber: apartment.unitNumber,
+ bedrooms: apartment.bedrooms,
+ bathrooms: apartment.bathrooms.toString(),
+ sqft: apartment.sqft,
+ };
+ }
+
+ // ── Reads ─────────────────────────────────────────────────────────────────
+
+ /**
+ * F6.2 — every vacant apartment in the org, enriched with building/floor
+ * names for the renter-facing "Available units" showcase. Deliberately NOT
+ * building-access scoped like the nested apartments/leases reads: a tenant
+ * isn't building-assigned, and this showcase is meant to surface every
+ * vacant unit org-wide, not just the caller's assigned buildings.
+ */
+ async findAvailable(orgId: string): Promise {
+ const apartments = await this.prisma.apartment.findMany({
+ where: { orgId, status: 'vacant' },
+ include: {
+ building: { select: { name: true } },
+ floor: { select: { name: true } },
+ },
+ orderBy: [{ building: { name: 'asc' } }, { unitNumber: 'asc' }],
+ });
+
+ return { data: apartments.map((a) => this.formatAvailableUnit(a)) };
+ }
+}
diff --git a/apps/api/src/modules/billing/billing.controller.ts b/apps/api/src/modules/billing/billing.controller.ts
index 611e58e0..1c9da691 100644
--- a/apps/api/src/modules/billing/billing.controller.ts
+++ b/apps/api/src/modules/billing/billing.controller.ts
@@ -17,7 +17,7 @@ export class BillingController {
private readonly orgScope: OrgScopeService,
) {}
- @Roles(Role.ORG_ADMIN, Role.FINANCE)
+ @Roles(Role.ORG_ADMIN)
@Get('subscription')
async getSubscription(@CurrentUser() user: AuthenticatedUser) {
const { orgId } = await this.orgScope.resolveForCaller(user);
diff --git a/apps/api/src/modules/expenses/expenses.service.spec.ts b/apps/api/src/modules/expenses/expenses.service.spec.ts
index 9c76d98e..6575aa02 100644
--- a/apps/api/src/modules/expenses/expenses.service.spec.ts
+++ b/apps/api/src/modules/expenses/expenses.service.spec.ts
@@ -53,6 +53,7 @@ describe('ExpensesService', () => {
buildingId,
vendorId: null,
workOrderId: null,
+ workOrder: null,
category: 'repairs',
amount: { toString: () => '150.00' },
incurredAt: new Date('2026-01-05T00:00:00.000Z'),
@@ -62,6 +63,10 @@ describe('ExpensesService', () => {
...overrides,
});
+ const WORK_ORDER_NUMBER_INCLUDE = {
+ workOrder: { select: { number: true } },
+ };
+
describe('findAll', () => {
it('returns all org expenses for an org-wide role (org_admin)', async () => {
const { service, prisma, buildingAccess } = makeService({
@@ -77,6 +82,7 @@ describe('ExpensesService', () => {
);
expect(prisma.expense.findMany).toHaveBeenCalledWith({
where: { orgId },
+ include: WORK_ORDER_NUMBER_INCLUDE,
orderBy: { incurredAt: 'desc' },
});
expect(result.data).toEqual([
@@ -100,6 +106,7 @@ describe('ExpensesService', () => {
);
expect(prisma.expense.findMany).toHaveBeenCalledWith({
where: { orgId },
+ include: WORK_ORDER_NUMBER_INCLUDE,
orderBy: { incurredAt: 'desc' },
});
});
@@ -115,9 +122,34 @@ describe('ExpensesService', () => {
expect(prisma.expense.findMany).toHaveBeenCalledWith({
where: { orgId, buildingId: { in: [buildingId] } },
+ include: WORK_ORDER_NUMBER_INCLUDE,
orderBy: { incurredAt: 'desc' },
});
});
+
+ it('populates workOrderNumberLabel for a row with a linked work order', async () => {
+ const { service, prisma } = makeService({
+ expense: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([
+ expenseRow({ workOrderId: 'wo-1', workOrder: { number: 123 } }),
+ ]),
+ },
+ });
+
+ const result = await service.findAll(orgId, callerId, Role.ORG_ADMIN);
+
+ expect(prisma.expense.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ include: WORK_ORDER_NUMBER_INCLUDE }),
+ );
+ expect(result.data).toEqual([
+ expect.objectContaining({
+ workOrderId: 'wo-1',
+ workOrderNumberLabel: 'WO-000123',
+ }),
+ ]);
+ });
});
describe('findOne', () => {
@@ -135,6 +167,7 @@ describe('ExpensesService', () => {
expect(prisma.expense.findFirst).toHaveBeenCalledWith({
where: { id: 'expense-1', orgId },
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
expect(result.data).toEqual(expect.objectContaining({ id: 'expense-1' }));
});
@@ -201,6 +234,7 @@ describe('ExpensesService', () => {
incurredAt: new Date('2026-01-05T00:00:00.000Z'),
notes: undefined,
},
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
expect(timeline.emit).toHaveBeenCalledWith(
expect.objectContaining({
@@ -242,12 +276,14 @@ describe('ExpensesService', () => {
where: { id: 'wo-1', orgId },
select: { vendorId: true },
});
- expect(prisma.expense.create).toHaveBeenCalledWith({
- data: expect.objectContaining({
- vendorId: 'vendor-1',
- workOrderId: 'wo-1',
+ expect(prisma.expense.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ vendorId: 'vendor-1',
+ workOrderId: 'wo-1',
+ }),
}),
- });
+ );
});
it('rejects when vendorId and workOrderId are both provided but do not match', async () => {
@@ -280,12 +316,14 @@ describe('ExpensesService', () => {
vendorId: 'vendor-1',
});
- expect(prisma.expense.create).toHaveBeenCalledWith({
- data: expect.objectContaining({
- vendorId: 'vendor-1',
- workOrderId: 'wo-1',
+ expect(prisma.expense.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ vendorId: 'vendor-1',
+ workOrderId: 'wo-1',
+ }),
}),
- });
+ );
});
it('rejects with ForbiddenException for a supervisor caller', async () => {
@@ -322,6 +360,7 @@ describe('ExpensesService', () => {
expect(prisma.expense.update).toHaveBeenCalledWith({
where: { id: 'expense-1' },
data: { notes: 'Paid in full' },
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
expect(timeline.emit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'expense.updated' }),
diff --git a/apps/api/src/modules/expenses/expenses.service.ts b/apps/api/src/modules/expenses/expenses.service.ts
index df8b2003..3718ae6f 100644
--- a/apps/api/src/modules/expenses/expenses.service.ts
+++ b/apps/api/src/modules/expenses/expenses.service.ts
@@ -12,7 +12,11 @@ import {
} from '@/common/building-access/building-access.service';
import { TimelineService } from '@/modules/timeline/timeline.service';
import { Role } from '@/common/enums';
-import { ExpenseCategory, ExpenseResponse } from '@repo/contracts';
+import {
+ ExpenseCategory,
+ ExpenseResponse,
+ formatWorkOrderNumber,
+} from '@repo/contracts';
import { CreateExpenseDto } from './dto/create-expense.dto';
import { UpdateExpenseDto } from './dto/update-expense.dto';
@@ -28,8 +32,15 @@ type ExpenseRow = {
notes: string | null;
createdAt: Date;
updatedAt: Date;
+ workOrder?: { number: number } | null;
};
+/** Shared across findAll/findOne/create/update so the linked Work Order's
+ * display number is always available to formatExpense. */
+const WORK_ORDER_NUMBER_INCLUDE = {
+ workOrder: { select: { number: true } },
+} as const;
+
@Injectable()
export class ExpensesService {
constructor(
@@ -82,6 +93,9 @@ export class ExpensesService {
buildingId: expense.buildingId,
vendorId: expense.vendorId,
workOrderId: expense.workOrderId,
+ workOrderNumberLabel: expense.workOrder
+ ? formatWorkOrderNumber(expense.workOrder.number)
+ : null,
category: expense.category as ExpenseCategory,
amount: expense.amount.toString(),
incurredAt: expense.incurredAt.toISOString(),
@@ -109,6 +123,7 @@ export class ExpensesService {
orgId,
...(allowedBuildingIds && { buildingId: { in: allowedBuildingIds } }),
},
+ include: WORK_ORDER_NUMBER_INCLUDE,
orderBy: { incurredAt: 'desc' },
});
@@ -123,6 +138,7 @@ export class ExpensesService {
): Promise<{ data: ExpenseResponse }> {
const expense = await this.prisma.expense.findFirst({
where: { id: expenseId, orgId },
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
if (!expense) throw new NotFoundException('Expense not found.');
@@ -176,6 +192,7 @@ export class ExpensesService {
incurredAt: new Date(dto.incurredAt),
notes: dto.notes,
},
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
await this.timeline.emit({
@@ -226,6 +243,7 @@ export class ExpensesService {
}),
...(dto.notes !== undefined && { notes: dto.notes }),
},
+ include: WORK_ORDER_NUMBER_INCLUDE,
});
await this.timeline.emit({
diff --git a/apps/api/src/modules/invoice-payments/invoice-payments.module.ts b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts
index 19644921..2d1a89ec 100644
--- a/apps/api/src/modules/invoice-payments/invoice-payments.module.ts
+++ b/apps/api/src/modules/invoice-payments/invoice-payments.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { InvoicePaymentsController } from './invoice-payments.controller';
import { InvoicePaymentsService } from './invoice-payments.service';
+import { NotificationsModule } from '@/modules/notifications/notifications.module';
@Module({
+ imports: [NotificationsModule],
controllers: [InvoicePaymentsController],
providers: [InvoicePaymentsService],
exports: [InvoicePaymentsService],
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 71956c24..761ed460 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
@@ -16,6 +16,7 @@ describe('InvoicePaymentsService', () => {
invoicePayment?: Partial>;
invoice?: Partial>;
buildingAccess?: Partial>;
+ notifications?: Partial>;
} = {},
) {
const prisma: any = {
@@ -37,12 +38,17 @@ describe('InvoicePaymentsService', () => {
...overrides.buildingAccess,
};
const timeline = { emit: jest.fn().mockResolvedValue(undefined) };
+ const notifications = {
+ enqueue: jest.fn().mockResolvedValue(undefined),
+ ...overrides.notifications,
+ };
const service = new InvoicePaymentsService(
prisma,
buildingAccess as any,
timeline as any,
+ notifications as any,
);
- return { service, prisma, buildingAccess, timeline };
+ return { service, prisma, buildingAccess, timeline, notifications };
}
const decimal = (value: string) => ({
@@ -168,7 +174,10 @@ describe('InvoicePaymentsService', () => {
invoice: {
findFirst: jest
.fn()
- .mockResolvedValueOnce({ id: 'invoice-1' })
+ .mockResolvedValueOnce({
+ id: 'invoice-1',
+ lease: { renter: { renterUserId: null } },
+ })
.mockResolvedValueOnce(invoiceForSummary()),
},
invoicePayment: {
@@ -225,6 +234,57 @@ describe('InvoicePaymentsService', () => {
service.create(orgId, callerId, Role.SUPERVISOR, dto),
).rejects.toBeInstanceOf(ForbiddenException);
});
+
+ describe('tenant notification (F5.1)', () => {
+ it('notifies the tenant when the invoice lease renter has a linked portal user', async () => {
+ const { service, notifications } = makeService({
+ invoice: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValueOnce({
+ id: 'invoice-1',
+ lease: { renter: { renterUserId: 'tenant-user-1' } },
+ })
+ .mockResolvedValueOnce(invoiceForSummary()),
+ },
+ invoicePayment: {
+ create: jest.fn().mockResolvedValue(paymentRow()),
+ },
+ });
+
+ await service.create(orgId, callerId, Role.ORG_ADMIN, dto);
+
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'tenant-user-1',
+ type: 'invoice.payment_recorded',
+ }),
+ );
+ });
+
+ it('does not notify when the renter has no linked portal user', async () => {
+ const { service, notifications } = makeService({
+ invoice: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValueOnce({
+ id: 'invoice-1',
+ lease: { renter: { renterUserId: null } },
+ })
+ .mockResolvedValueOnce(invoiceForSummary()),
+ },
+ invoicePayment: {
+ create: jest.fn().mockResolvedValue(paymentRow()),
+ },
+ });
+
+ await service.create(orgId, callerId, Role.ORG_ADMIN, dto);
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
});
describe('remove', () => {
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 679d89fe..0d965698 100644
--- a/apps/api/src/modules/invoice-payments/invoice-payments.service.ts
+++ b/apps/api/src/modules/invoice-payments/invoice-payments.service.ts
@@ -8,6 +8,7 @@ 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 { NotificationsService } from '@/modules/notifications/notifications.service';
import { Role } from '@/common/enums';
import {
InvoicePaymentMethod,
@@ -35,6 +36,7 @@ export class InvoicePaymentsService {
private readonly prisma: PrismaService,
private readonly buildingAccess: BuildingAccessService,
private readonly timeline: TimelineService,
+ private readonly notifications: NotificationsService,
) {}
private assertWriteAccess(callerRole: Role): void {
@@ -159,7 +161,10 @@ export class InvoicePaymentsService {
const invoice = await this.prisma.invoice.findFirst({
where: { id: dto.invoiceId, orgId },
- select: { id: true },
+ select: {
+ id: true,
+ lease: { select: { renter: { select: { renterUserId: true } } } },
+ },
});
if (!invoice) throw new NotFoundException('Invoice not found.');
@@ -183,6 +188,19 @@ export class InvoicePaymentsService {
metadata: { invoiceId: invoice.id, amount: payment.amount.toString() },
});
+ // F5.1: notify the tenant a payment was recorded — skip silently if the
+ // renter has no linked portal user.
+ if (invoice.lease.renter.renterUserId) {
+ await this.notifications.enqueue({
+ orgId,
+ userId: invoice.lease.renter.renterUserId,
+ type: 'invoice.payment_recorded',
+ title: 'Payment recorded',
+ body: `A payment of ${payment.amount.toString()} was recorded on your invoice.`,
+ data: { invoiceId: invoice.id, paymentId: payment.id },
+ });
+ }
+
const summary = await this.summarizeInvoice(orgId, invoice.id);
return { data: { payment: this.formatPayment(payment), invoice: summary } };
diff --git a/apps/api/src/modules/invoices/invoices.module.ts b/apps/api/src/modules/invoices/invoices.module.ts
index 058d12f8..25ec475d 100644
--- a/apps/api/src/modules/invoices/invoices.module.ts
+++ b/apps/api/src/modules/invoices/invoices.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
+import { NotificationsModule } from '@/modules/notifications/notifications.module';
@Module({
+ imports: [NotificationsModule],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
diff --git a/apps/api/src/modules/invoices/invoices.service.spec.ts b/apps/api/src/modules/invoices/invoices.service.spec.ts
index b02a2d2c..ecd5ed7c 100644
--- a/apps/api/src/modules/invoices/invoices.service.spec.ts
+++ b/apps/api/src/modules/invoices/invoices.service.spec.ts
@@ -19,6 +19,7 @@ describe('InvoicesService', () => {
invoiceLineItem?: Partial>;
invoicePayment?: Partial>;
buildingAccess?: Partial>;
+ notifications?: Partial>;
} = {},
) {
const prisma: any = {
@@ -52,12 +53,17 @@ describe('InvoicesService', () => {
...overrides.buildingAccess,
};
const timeline = { emit: jest.fn().mockResolvedValue(undefined) };
+ const notifications = {
+ enqueue: jest.fn().mockResolvedValue(undefined),
+ ...overrides.notifications,
+ };
const service = new InvoicesService(
prisma,
buildingAccess as any,
timeline as any,
+ notifications as any,
);
- return { service, prisma, buildingAccess, timeline };
+ return { service, prisma, buildingAccess, timeline, notifications };
}
const decimal = (value: string) => ({
@@ -210,7 +216,11 @@ describe('InvoicesService', () => {
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 }),
+ findFirst: jest.fn().mockResolvedValue({
+ id: 'lease-1',
+ buildingId,
+ renter: { renterUserId: null },
+ }),
},
invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) },
});
@@ -275,6 +285,49 @@ describe('InvoicesService', () => {
service.create(orgId, callerId, Role.SUPERVISOR, dto),
).rejects.toBeInstanceOf(ForbiddenException);
});
+
+ describe('tenant notification (F5.1)', () => {
+ it('notifies the tenant when the lease renter has a linked portal user', async () => {
+ const { service, notifications } = makeService({
+ lease: {
+ findFirst: jest.fn().mockResolvedValue({
+ id: 'lease-1',
+ buildingId,
+ renter: { renterUserId: 'tenant-user-1' },
+ }),
+ },
+ invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) },
+ });
+
+ await service.create(orgId, callerId, Role.ORG_ADMIN, dto);
+
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'tenant-user-1',
+ type: 'invoice.issued',
+ }),
+ );
+ });
+
+ it('does not notify when the renter has no linked portal user', async () => {
+ const { service, notifications } = makeService({
+ lease: {
+ findFirst: jest.fn().mockResolvedValue({
+ id: 'lease-1',
+ buildingId,
+ renter: { renterUserId: null },
+ }),
+ },
+ invoice: { create: jest.fn().mockResolvedValue(invoiceRow()) },
+ });
+
+ await service.create(orgId, callerId, Role.ORG_ADMIN, dto);
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
});
describe('update', () => {
diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts
index 1f514fad..4cc2d3f7 100644
--- a/apps/api/src/modules/invoices/invoices.service.ts
+++ b/apps/api/src/modules/invoices/invoices.service.ts
@@ -9,6 +9,7 @@ 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 { NotificationsService } from '@/modules/notifications/notifications.service';
import { Role } from '@/common/enums';
import { InvoiceLineItemCategory, InvoiceResponse } from '@repo/contracts';
import { computeInvoiceSummary } from '@/common/invoice-summary/compute-invoice-summary';
@@ -58,6 +59,7 @@ export class InvoicesService {
private readonly prisma: PrismaService,
private readonly buildingAccess: BuildingAccessService,
private readonly timeline: TimelineService,
+ private readonly notifications: NotificationsService,
) {}
private assertWriteAccess(callerRole: Role): void {
@@ -180,7 +182,11 @@ export class InvoicesService {
const lease = await this.prisma.lease.findFirst({
where: { id: dto.leaseId, orgId },
- select: { id: true, buildingId: true },
+ select: {
+ id: true,
+ buildingId: true,
+ renter: { select: { renterUserId: true } },
+ },
});
if (!lease) throw new NotFoundException('Lease not found.');
@@ -214,6 +220,19 @@ export class InvoicesService {
},
});
+ // F5.1: notify the tenant an invoice was issued — skip silently if the
+ // renter has no linked portal user (renterUserId is nullable).
+ if (lease.renter.renterUserId) {
+ await this.notifications.enqueue({
+ orgId,
+ userId: lease.renter.renterUserId,
+ type: 'invoice.issued',
+ title: 'New invoice issued',
+ body: `An invoice due ${invoice.dueDate.toISOString().slice(0, 10)} has been issued to your lease.`,
+ data: { invoiceId: invoice.id, leaseId: invoice.leaseId },
+ });
+ }
+
return { data: this.formatInvoice(invoice) };
}
diff --git a/apps/api/src/modules/leases/dto/create-lease.dto.ts b/apps/api/src/modules/leases/dto/create-lease.dto.ts
index cfe9ee24..e3775f2b 100644
--- a/apps/api/src/modules/leases/dto/create-lease.dto.ts
+++ b/apps/api/src/modules/leases/dto/create-lease.dto.ts
@@ -1,4 +1,5 @@
import {
+ IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
@@ -51,4 +52,14 @@ export class CreateLeaseDto {
@IsOptional()
@IsString()
notes?: string;
+
+ @ApiPropertyOptional({
+ description:
+ "F4.3 (decision D3): a NEW active lease may not silently start in the past. " +
+ 'Set this to explicitly record an already-existing lease with a back-dated ' +
+ 'start; without it, a past start on an active lease is rejected (400).',
+ })
+ @IsOptional()
+ @IsBoolean()
+ recordExisting?: boolean;
}
diff --git a/apps/api/src/modules/leases/leases.module.ts b/apps/api/src/modules/leases/leases.module.ts
index 588310b0..e8b3b7f9 100644
--- a/apps/api/src/modules/leases/leases.module.ts
+++ b/apps/api/src/modules/leases/leases.module.ts
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { LeasesController } from './leases.controller';
import { LeasesOverviewController } from './leases-overview.controller';
import { LeasesService } from './leases.service';
+import { NotificationsModule } from '@/modules/notifications/notifications.module';
@Module({
+ imports: [NotificationsModule],
controllers: [LeasesController, LeasesOverviewController],
providers: [LeasesService],
exports: [LeasesService],
diff --git a/apps/api/src/modules/leases/leases.service.spec.ts b/apps/api/src/modules/leases/leases.service.spec.ts
index 9bae2a03..45d5cd60 100644
--- a/apps/api/src/modules/leases/leases.service.spec.ts
+++ b/apps/api/src/modules/leases/leases.service.spec.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,
@@ -28,6 +29,7 @@ describe('LeasesService', () => {
building?: Partial>;
floor?: Partial>;
buildingAccess?: Partial>;
+ notifications?: Partial>;
} = {},
) {
const prisma: any = {
@@ -74,6 +76,10 @@ describe('LeasesService', () => {
);
const timeline = { emit: jest.fn().mockResolvedValue(undefined) };
+ const notifications = {
+ enqueue: jest.fn().mockResolvedValue(undefined),
+ ...overrides.notifications,
+ };
const buildingAccess = {
assertBuildingAccess: jest.fn().mockResolvedValue(undefined),
getAllowedBuildingIds: jest.fn(),
@@ -83,10 +89,18 @@ describe('LeasesService', () => {
const service = new LeasesService(
prisma,
timeline as any,
+ notifications as any,
buildingAccess as any,
leaseStatus,
);
- return { service, prisma, timeline, buildingAccess, leaseStatus };
+ return {
+ service,
+ prisma,
+ timeline,
+ notifications,
+ buildingAccess,
+ leaseStatus,
+ };
}
const leaseRow = (overrides: Partial> = {}) => ({
@@ -108,9 +122,13 @@ describe('LeasesService', () => {
...overrides,
});
+ // Comfortably in the future (well past "today" for the foreseeable life of
+ // this suite) so the F4.3 past-start guard never trips on the happy path.
+ const FAR_ENOUGH_FUTURE_START = '2050-01-01T00:00:00.000Z';
+
const dto = {
renterId,
- startDate: '2026-01-01T00:00:00.000Z',
+ startDate: FAR_ENOUGH_FUTURE_START,
endDate: FAR_FUTURE,
rentAmount: 1500,
depositAmount: 1500,
@@ -146,6 +164,20 @@ describe('LeasesService', () => {
});
});
+ it('rejects creating a lease whose end date is not after its start date', async () => {
+ const { service, prisma } = makeService();
+
+ await expect(
+ service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2026-06-01T00:00:00.000Z',
+ endDate: '2026-06-01T00:00:00.000Z',
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+
+ expect(prisma.lease.create).not.toHaveBeenCalled();
+ });
+
it('does not sync the apartment when created with an explicit non-active status', async () => {
const { service, prisma } = makeService();
prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' }));
@@ -158,7 +190,7 @@ describe('LeasesService', () => {
expect(prisma.apartment.update).not.toHaveBeenCalled();
});
- it('rejects creating a second active lease on an apartment that already has one', async () => {
+ it('rejects creating a second active lease whose dates fully overlap an existing one', async () => {
const { service, prisma } = makeService({
lease: {
findMany: jest.fn().mockResolvedValue([leaseRow()]),
@@ -171,22 +203,6 @@ describe('LeasesService', () => {
expect(prisma.lease.create).not.toHaveBeenCalled();
});
- it('allows creating a draft lease alongside an existing active lease', async () => {
- const { service, prisma } = makeService({
- lease: {
- findMany: jest.fn().mockResolvedValue([leaseRow()]),
- },
- });
- prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' }));
-
- await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
- ...dto,
- status: 'draft',
- });
-
- expect(prisma.lease.create).toHaveBeenCalled();
- });
-
it('allows a new active lease when the existing one has already expired', async () => {
const { service, prisma } = makeService({
lease: {
@@ -209,6 +225,198 @@ describe('LeasesService', () => {
expect(prisma.lease.create).toHaveBeenCalled();
});
+ describe('date-overlap check (F4.1)', () => {
+ // A bounded existing lease (not FAR_FUTURE-ended) so there's real room
+ // to construct disjoint/adjacent/partial ranges around it.
+ const boundedExisting = () =>
+ leaseRow({
+ id: 'lease-existing',
+ startDate: new Date('2030-01-01T00:00:00.000Z'),
+ endDate: new Date('2031-01-01T00:00:00.000Z'),
+ });
+
+ it('rejects a partial overlap at the head (new lease starts before, ends inside)', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+
+ await expect(
+ service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2029-06-01T00:00:00.000Z',
+ endDate: '2030-06-01T00:00:00.000Z',
+ }),
+ ).rejects.toBeInstanceOf(ConflictException);
+ expect(prisma.lease.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects a partial overlap at the tail (new lease starts inside, ends after)', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+
+ await expect(
+ service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2030-06-01T00:00:00.000Z',
+ endDate: '2031-06-01T00:00:00.000Z',
+ }),
+ ).rejects.toBeInstanceOf(ConflictException);
+ expect(prisma.lease.create).not.toHaveBeenCalled();
+ });
+
+ it('allows a back-to-back lease starting exactly when the existing one ends', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2031-01-01T00:00:00.000Z', // === existing.endDate
+ endDate: '2032-01-01T00:00:00.000Z',
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('allows a disjoint future lease (starts well after the existing one ends)', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2035-01-01T00:00:00.000Z',
+ endDate: '2036-01-01T00:00:00.000Z',
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('allows a disjoint past lease (ends before the existing one starts)', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow({ status: 'draft' }));
+
+ // Use a draft here (start-date policy F4.3 only guards 'active').
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ status: 'draft',
+ startDate: '2020-01-01T00:00:00.000Z',
+ endDate: '2021-01-01T00:00:00.000Z',
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('ignores a terminated existing lease even if its dates fully overlap', async () => {
+ const { service, prisma } = makeService({
+ lease: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([
+ { ...boundedExisting(), status: 'terminated' },
+ ]),
+ },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2030-01-01T00:00:00.000Z',
+ endDate: '2031-01-01T00:00:00.000Z',
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('applies the overlap check to draft leases too (not exempt)', async () => {
+ const { service, prisma } = makeService({
+ lease: { findMany: jest.fn().mockResolvedValue([boundedExisting()]) },
+ });
+
+ await expect(
+ service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ status: 'draft',
+ startDate: '2030-06-01T00:00:00.000Z',
+ endDate: '2030-09-01T00:00:00.000Z',
+ }),
+ ).rejects.toBeInstanceOf(ConflictException);
+ expect(prisma.lease.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('start-date policy (F4.3)', () => {
+ it('rejects a new active lease starting in the past without recordExisting', async () => {
+ const { service, prisma } = makeService();
+
+ await expect(
+ service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2020-01-01T00:00:00.000Z',
+ }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ expect(prisma.lease.create).not.toHaveBeenCalled();
+ });
+
+ it('allows a past-start active lease when recordExisting is true', async () => {
+ const { service, prisma } = makeService();
+ prisma.lease.create.mockResolvedValue(
+ leaseRow({ startDate: new Date('2020-01-01T00:00:00.000Z') }),
+ );
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: '2020-01-01T00:00:00.000Z',
+ recordExisting: true,
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('allows an active lease starting today', async () => {
+ const { service, prisma } = makeService();
+ const today = new Date();
+ const todayUtcMidnight = new Date(
+ Date.UTC(
+ today.getUTCFullYear(),
+ today.getUTCMonth(),
+ today.getUTCDate(),
+ ),
+ ).toISOString();
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ startDate: todayUtcMidnight,
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+
+ it('allows a past-start draft lease without recordExisting (guard only applies to active)', async () => {
+ const { service, prisma } = makeService();
+ prisma.lease.create.mockResolvedValue(
+ leaseRow({
+ status: 'draft',
+ startDate: new Date('2020-01-01T00:00:00.000Z'),
+ }),
+ );
+
+ await service.create(orgId, actorId, buildingId, floorId, apartmentId, {
+ ...dto,
+ status: 'draft',
+ startDate: '2020-01-01T00:00:00.000Z',
+ });
+
+ expect(prisma.lease.create).toHaveBeenCalled();
+ });
+ });
+
it('throws NotFoundException when the apartment does not belong to the floor/building/org', async () => {
const { service } = makeService({
apartment: { findFirst: jest.fn().mockResolvedValue(null) },
@@ -228,6 +436,59 @@ describe('LeasesService', () => {
service.create(orgId, actorId, buildingId, floorId, apartmentId, dto),
).rejects.toBeInstanceOf(NotFoundException);
});
+
+ describe('tenant notification (F5.1)', () => {
+ it('notifies the tenant when the renter has a linked portal user', async () => {
+ const { service, prisma, notifications } = makeService({
+ renter: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue({ id: renterId, renterUserId: 'tenant-user-1' }),
+ },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(
+ orgId,
+ actorId,
+ buildingId,
+ floorId,
+ apartmentId,
+ dto,
+ );
+
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'tenant-user-1',
+ type: 'lease.created',
+ }),
+ );
+ });
+
+ it('does not notify when the renter has no linked portal user', async () => {
+ const { service, prisma, notifications } = makeService({
+ renter: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue({ id: renterId, renterUserId: null }),
+ },
+ });
+ prisma.lease.create.mockResolvedValue(leaseRow());
+
+ await service.create(
+ orgId,
+ actorId,
+ buildingId,
+ floorId,
+ apartmentId,
+ dto,
+ );
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
});
describe('update', () => {
@@ -313,6 +574,91 @@ describe('LeasesService', () => {
),
).rejects.toBeInstanceOf(NotFoundException);
});
+
+ describe('date-overlap check (F4.1)', () => {
+ it('excludes the lease being updated from the overlap check (self-exclusion)', async () => {
+ const { service, prisma } = makeService({
+ lease: {
+ findFirst: jest.fn().mockResolvedValue(leaseRow()),
+ findMany: jest.fn().mockResolvedValue([]),
+ },
+ });
+ prisma.lease.update.mockResolvedValue(
+ leaseRow({ endDate: new Date('2099-06-01T00:00:00.000Z') }),
+ );
+
+ await service.update(
+ orgId,
+ actorId,
+ buildingId,
+ floorId,
+ apartmentId,
+ 'lease-1',
+ { endDate: '2099-06-01T00:00:00.000Z' },
+ );
+
+ expect(prisma.lease.findMany).toHaveBeenCalledWith({
+ where: { apartmentId, id: { not: 'lease-1' } },
+ select: { status: true, startDate: true, endDate: true },
+ });
+ expect(prisma.lease.update).toHaveBeenCalled();
+ });
+
+ it('rejects updating a lease when the new dates overlap another non-terminated lease on the same apartment', async () => {
+ const { service, prisma } = makeService({
+ lease: {
+ findFirst: jest.fn().mockResolvedValue(leaseRow()),
+ findMany: jest.fn().mockResolvedValue([
+ leaseRow({
+ id: 'lease-2',
+ startDate: new Date('2027-06-01T00:00:00.000Z'),
+ endDate: new Date('2028-06-01T00:00:00.000Z'),
+ }),
+ ]),
+ },
+ });
+
+ await expect(
+ service.update(
+ orgId,
+ actorId,
+ buildingId,
+ floorId,
+ apartmentId,
+ 'lease-1',
+ {
+ startDate: '2027-01-01T00:00:00.000Z',
+ endDate: '2027-12-01T00:00:00.000Z',
+ },
+ ),
+ ).rejects.toBeInstanceOf(ConflictException);
+ expect(prisma.lease.update).not.toHaveBeenCalled();
+ });
+
+ it('does not re-run the overlap check when neither dates nor status change', async () => {
+ const { service, prisma } = makeService({
+ lease: {
+ findFirst: jest.fn().mockResolvedValue(leaseRow()),
+ },
+ });
+ prisma.lease.update.mockResolvedValue(
+ leaseRow({ rentAmount: new Prisma.Decimal('1600.00') }),
+ );
+
+ await service.update(
+ orgId,
+ actorId,
+ buildingId,
+ floorId,
+ apartmentId,
+ 'lease-1',
+ { rentAmount: 1600 },
+ );
+
+ expect(prisma.lease.findMany).not.toHaveBeenCalled();
+ expect(prisma.lease.update).toHaveBeenCalled();
+ });
+ });
});
describe('remove', () => {
diff --git a/apps/api/src/modules/leases/leases.service.ts b/apps/api/src/modules/leases/leases.service.ts
index 3df5a02b..8539f3f4 100644
--- a/apps/api/src/modules/leases/leases.service.ts
+++ b/apps/api/src/modules/leases/leases.service.ts
@@ -1,10 +1,12 @@
import {
+ BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { TimelineService } from '@/modules/timeline/timeline.service';
+import { NotificationsService } from '@/modules/notifications/notifications.service';
import { BuildingAccessService } from '@/common/building-access/building-access.service';
import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
import { Role } from '@/common/enums';
@@ -21,6 +23,7 @@ export class LeasesService {
constructor(
private readonly prisma: PrismaService,
private readonly timeline: TimelineService,
+ private readonly notifications: NotificationsService,
private readonly buildingAccess: BuildingAccessService,
private readonly leaseStatus: LeaseStatusService,
) {}
@@ -48,12 +51,81 @@ export class LeasesService {
private async assertRenterInOrg(
orgId: string,
renterId: string,
- ): Promise {
+ ): Promise<{ id: string; renterUserId: string | null }> {
const renter = await this.prisma.renter.findFirst({
where: { id: renterId, orgId },
- select: { id: true },
+ select: { id: true, renterUserId: true },
});
if (!renter) throw new NotFoundException('Renter not found.');
+ return renter;
+ }
+
+ /**
+ * Server-side invariant: a lease must end strictly after it starts. The FE
+ * enforces this too, but a direct API call previously bypassed it (the DTO
+ * only validated each date in isolation), so a lease with endDate <= startDate
+ * could be persisted.
+ */
+ private assertValidDateRange(
+ startDate: string | Date,
+ endDate: string | Date,
+ ): void {
+ const startMs = new Date(startDate).getTime();
+ const endMs = new Date(endDate).getTime();
+ if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
+ throw new BadRequestException('Invalid lease start or end date.');
+ }
+ if (endMs <= startMs) {
+ throw new BadRequestException(
+ 'Lease end date must be after the start date.',
+ );
+ }
+ }
+
+ /** UTC midnight for `date` — the F4.3 start-date policy compares at day granularity. */
+ private startOfUtcDay(date: Date): Date {
+ return new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+ );
+ }
+
+ /**
+ * F4.1: true date-range overlap check, replacing the old "any effectively-
+ * active lease on the apartment" rule (which ignored the new lease's own
+ * dates and made back-to-back/future leases impossible). Rejects only if
+ * `[startDate, endDate)` overlaps an existing NON-terminated lease's own
+ * `[startDate, endDate)` on the same apartment — end-exclusive, so
+ * back-to-back leases (newStart === existingEnd) are allowed. Terminated
+ * leases are filtered out in application code (matching the sibling
+ * WorkOrderApartmentStatusService convention of querying broadly and
+ * filtering status in JS) so a stale mock/row can't silently bypass this in
+ * tests. `excludeLeaseId` lets update() exclude its own row.
+ */
+ private async assertNoOverlappingLease(
+ apartmentId: string,
+ startDate: Date,
+ endDate: Date,
+ excludeLeaseId?: string,
+ ): Promise {
+ const existingLeases = await this.prisma.lease.findMany({
+ where: {
+ apartmentId,
+ ...(excludeLeaseId && { id: { not: excludeLeaseId } }),
+ },
+ select: { status: true, startDate: true, endDate: true },
+ });
+
+ const overlaps = existingLeases.some(
+ (l) =>
+ l.status !== 'terminated' &&
+ startDate < l.endDate &&
+ l.startDate < endDate,
+ );
+ if (overlaps) {
+ throw new ConflictException(
+ 'This apartment already has a lease that overlaps these dates.',
+ );
+ }
}
// ── CRUD ──────────────────────────────────────────────────────────────────
@@ -185,26 +257,33 @@ export class LeasesService {
dto: CreateLeaseDto,
): Promise<{ data: LeaseResponse }> {
await this.assertApartmentInScope(orgId, buildingId, floorId, apartmentId);
- await this.assertRenterInOrg(orgId, dto.renterId);
+ const renter = await this.assertRenterInOrg(orgId, dto.renterId);
+
+ const startDate = new Date(dto.startDate);
+ const endDate = new Date(dto.endDate);
+ this.assertValidDateRange(startDate, endDate);
const resolvedStatus = dto.status ?? 'active';
const now = new Date();
- if (resolvedStatus === 'active') {
- const existingLeases = await this.prisma.lease.findMany({
- where: { apartmentId },
- });
- const hasActiveLease = existingLeases.some((l) =>
- this.leaseStatus.isEffectivelyActive(
- { status: l.status, endDate: l.endDate },
- now,
- ),
+ // F4.3 (decision D3): a new ACTIVE lease can't silently start in the
+ // past — that almost always means the caller meant to back-date an
+ // already-existing lease, which must be explicit via recordExisting.
+ if (
+ resolvedStatus === 'active' &&
+ this.startOfUtcDay(startDate) < this.startOfUtcDay(now) &&
+ !dto.recordExisting
+ ) {
+ throw new BadRequestException(
+ "A new active lease can't start in the past; use 'record an existing lease' to back-date.",
);
- if (hasActiveLease) {
- throw new ConflictException(
- 'This apartment already has an active lease.',
- );
- }
+ }
+
+ // F4.1: reject only on a genuine date-range overlap with another
+ // non-terminated lease on this apartment — not simply "an active lease
+ // exists" (that made future/back-to-back leases impossible).
+ if (resolvedStatus !== 'terminated') {
+ await this.assertNoOverlappingLease(apartmentId, startDate, endDate);
}
const lease = await this.prisma.$transaction(async (tx) => {
@@ -215,8 +294,8 @@ export class LeasesService {
floorId,
apartmentId,
renterId: dto.renterId,
- startDate: new Date(dto.startDate),
- endDate: new Date(dto.endDate),
+ startDate,
+ endDate,
rentAmount: dto.rentAmount,
depositAmount: dto.depositAmount,
status: resolvedStatus,
@@ -244,6 +323,19 @@ export class LeasesService {
metadata: { apartmentId, renterId: dto.renterId },
});
+ // F5.1: notify the tenant — skip silently if the renter has no linked
+ // portal user.
+ if (renter.renterUserId) {
+ await this.notifications.enqueue({
+ orgId,
+ userId: renter.renterUserId,
+ type: 'lease.created',
+ title: 'New lease created',
+ body: 'A new lease has been created for you.',
+ data: { leaseId: lease.id, apartmentId },
+ });
+ }
+
return { data: this.formatLease(lease) };
}
@@ -261,6 +353,31 @@ export class LeasesService {
});
if (!existing) throw new NotFoundException('Lease not found.');
+ // Validate the resulting date range against whichever dates are being
+ // changed, falling back to the stored values for the untouched one.
+ const resolvedStartDate =
+ dto.startDate !== undefined ? new Date(dto.startDate) : existing.startDate;
+ const resolvedEndDate =
+ dto.endDate !== undefined ? new Date(dto.endDate) : existing.endDate;
+ this.assertValidDateRange(resolvedStartDate, resolvedEndDate);
+
+ // F4.1: re-run the overlap check when the update touches the dates
+ // and/or (re)activates the lease — excluding the lease's own row so it
+ // doesn't conflict with itself.
+ const resolvedStatus = dto.status ?? existing.status;
+ const datesOrStatusChanging =
+ dto.startDate !== undefined ||
+ dto.endDate !== undefined ||
+ dto.status !== undefined;
+ if (datesOrStatusChanging && resolvedStatus !== 'terminated') {
+ await this.assertNoOverlappingLease(
+ apartmentId,
+ resolvedStartDate,
+ resolvedEndDate,
+ leaseId,
+ );
+ }
+
const isTerminating =
dto.status === 'terminated' && existing.status !== 'terminated';
diff --git a/apps/api/src/modules/payments/payments.controller.ts b/apps/api/src/modules/payments/payments.controller.ts
index 99061fe6..d1731a2b 100644
--- a/apps/api/src/modules/payments/payments.controller.ts
+++ b/apps/api/src/modules/payments/payments.controller.ts
@@ -8,9 +8,8 @@ import { Role } from '@/common/enums';
@ApiTags('payments')
@ApiBearerAuth()
-// Supervisor is read-only — no mutating payment endpoints are exposed to supervisor.
// TODO: scope by buildingId once Payment is building-linked.
-@Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR)
+@Roles(Role.ORG_ADMIN, Role.FINANCE)
@Controller('payments')
export class PaymentsController {
constructor(
diff --git a/apps/api/src/modules/payments/payments.service.spec.ts b/apps/api/src/modules/payments/payments.service.spec.ts
new file mode 100644
index 00000000..407de712
--- /dev/null
+++ b/apps/api/src/modules/payments/payments.service.spec.ts
@@ -0,0 +1,45 @@
+import { PaymentsService } from './payments.service';
+
+/**
+ * Regression guard for the paginated-list contract drift bug: the backend list
+ * envelope MUST expose its rows under `items` (matching PaginatedResponse in
+ * @repo/contracts and the FE reads), never `data`. A `data` array here silently
+ * renders empty lists on the client (the FE reads `.items`), and would also be
+ * mis-peeled by the client's `unwrap` ApiEnvelope transform.
+ */
+describe('PaymentsService', () => {
+ const orgId = 'org-1';
+
+ function makeService(rows: unknown[] = [], total = 0) {
+ const prisma: any = {
+ payment: {
+ findMany: jest.fn().mockResolvedValue(rows),
+ count: jest.fn().mockResolvedValue(total),
+ },
+ };
+ return { service: new PaymentsService(prisma), prisma };
+ }
+
+ it('returns the paginated envelope keyed by `items`, not `data`', async () => {
+ const rows = [{ id: 'pay-1' }, { id: 'pay-2' }];
+ const { service } = makeService(rows, 2);
+
+ const result = await service.findAll(orgId, 1, 20);
+
+ expect(result).toEqual({ items: rows, total: 2, page: 1, limit: 20 });
+ expect(result.items).toEqual(rows);
+ expect(result).not.toHaveProperty('data');
+ });
+
+ it('applies page/limit as skip/take and echoes them back', async () => {
+ const { service, prisma } = makeService([], 0);
+
+ const result = await service.findAll(orgId, 3, 10);
+
+ expect(prisma.payment.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { orgId }, skip: 20, take: 10 }),
+ );
+ expect(result.page).toBe(3);
+ expect(result.limit).toBe(10);
+ });
+});
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts
new file mode 100644
index 00000000..c61e9d0c
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices-scheduler.service.ts
@@ -0,0 +1,42 @@
+import { InjectQueue } from '@nestjs/bullmq';
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { Queue } from 'bullmq';
+import {
+ RECURRING_INVOICES_CRON,
+ RECURRING_INVOICES_DAILY_JOB_ID,
+ RECURRING_INVOICES_QUEUE,
+ RECURRING_INVOICES_RUN_JOB,
+} from './recurring-invoices.constants';
+
+/**
+ * Registers the daily repeatable job on module init (there is no
+ * `@nestjs/schedule` in this app — BullMQ's repeatable jobs are the
+ * scheduling primitive here, same as the rest of the queue infra). A fixed
+ * jobId means BullMQ upserts the same repeatable job definition on every app
+ * restart instead of accumulating duplicate schedules.
+ */
+@Injectable()
+export class RecurringInvoicesSchedulerService implements OnModuleInit {
+ private readonly logger = new Logger(RecurringInvoicesSchedulerService.name);
+
+ constructor(
+ @InjectQueue(RECURRING_INVOICES_QUEUE) private readonly queue: Queue,
+ ) {}
+
+ async onModuleInit(): Promise {
+ try {
+ await this.queue.add(
+ RECURRING_INVOICES_RUN_JOB,
+ {},
+ {
+ repeat: { pattern: RECURRING_INVOICES_CRON },
+ jobId: RECURRING_INVOICES_DAILY_JOB_ID,
+ },
+ );
+ } catch (error) {
+ this.logger.error(
+ `Failed to schedule the daily recurring-invoices job: ${String(error)}`,
+ );
+ }
+ }
+}
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts
new file mode 100644
index 00000000..7d0e9228
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.constants.ts
@@ -0,0 +1,15 @@
+/** Name of the BullMQ queue that drives the daily recurring-invoice run. */
+export const RECURRING_INVOICES_QUEUE = 'recurring-invoices';
+
+/** Job name used for the daily generation run. */
+export const RECURRING_INVOICES_RUN_JOB = 'run-daily';
+
+/**
+ * Fixed jobId for the repeatable job. BullMQ upserts a repeatable job by its
+ * (name, jobId, repeat options) — reusing this id on every app restart avoids
+ * accumulating duplicate schedules.
+ */
+export const RECURRING_INVOICES_DAILY_JOB_ID = 'recurring-invoices-daily';
+
+/** Daily at 06:00 UTC — well ahead of the 7-day generation lead window. */
+export const RECURRING_INVOICES_CRON = '0 6 * * *';
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts
new file mode 100644
index 00000000..9f5f8087
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.controller.ts
@@ -0,0 +1,30 @@
+import { Controller, Post } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { RecurringInvoicesService } from './recurring-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';
+
+/**
+ * Manual "generate now" trigger for recurring rent invoices (F3.1). Runs the
+ * same idempotent core the daily scheduler runs, scoped to the caller's org —
+ * both a real feature (an org admin doesn't have to wait for the 06:00 UTC
+ * job) and the primary way to live-verify generation.
+ */
+@ApiTags('recurring-invoices')
+@ApiBearerAuth()
+@Controller('recurring-invoices')
+@Roles(Role.ORG_ADMIN)
+export class RecurringInvoicesController {
+ constructor(
+ private readonly recurringInvoices: RecurringInvoicesService,
+ private readonly orgScope: OrgScopeService,
+ ) {}
+
+ @Post('run')
+ async run(@CurrentUser() user: AuthenticatedUser) {
+ const { orgId } = await this.orgScope.resolveForCaller(user);
+ return this.recurringInvoices.runForOrg(orgId);
+ }
+}
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts
new file mode 100644
index 00000000..bb7c9d57
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.module.ts
@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+import { BullModule } from '@nestjs/bullmq';
+import { RecurringInvoicesController } from './recurring-invoices.controller';
+import { RecurringInvoicesService } from './recurring-invoices.service';
+import { RecurringInvoicesProcessor } from './recurring-invoices.processor';
+import { RecurringInvoicesSchedulerService } from './recurring-invoices-scheduler.service';
+import { RECURRING_INVOICES_QUEUE } from './recurring-invoices.constants';
+import { NotificationsModule } from '@/modules/notifications/notifications.module';
+
+@Module({
+ imports: [
+ BullModule.registerQueue({ name: RECURRING_INVOICES_QUEUE }),
+ NotificationsModule,
+ ],
+ controllers: [RecurringInvoicesController],
+ providers: [
+ RecurringInvoicesService,
+ RecurringInvoicesProcessor,
+ RecurringInvoicesSchedulerService,
+ ],
+ exports: [RecurringInvoicesService],
+})
+export class RecurringInvoicesModule {}
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts
new file mode 100644
index 00000000..0f134e63
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.processor.ts
@@ -0,0 +1,25 @@
+import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Logger } from '@nestjs/common';
+import { Job } from 'bullmq';
+import { RECURRING_INVOICES_QUEUE } from './recurring-invoices.constants';
+import { RecurringInvoicesService } from './recurring-invoices.service';
+
+/**
+ * Consumes the daily repeatable job and runs generation across every org.
+ * The manual `POST /recurring-invoices/run` endpoint calls
+ * {@link RecurringInvoicesService.runForOrg} directly (single-org, synchronous
+ * response) — this processor is only reached by the scheduled job.
+ */
+@Processor(RECURRING_INVOICES_QUEUE)
+export class RecurringInvoicesProcessor extends WorkerHost {
+ private readonly logger = new Logger(RecurringInvoicesProcessor.name);
+
+ constructor(private readonly recurringInvoices: RecurringInvoicesService) {
+ super();
+ }
+
+ async process(job: Job): Promise {
+ this.logger.debug(`Starting recurring-invoice run (job ${job.id})`);
+ await this.recurringInvoices.runAll();
+ }
+}
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts
new file mode 100644
index 00000000..b360754b
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.spec.ts
@@ -0,0 +1,340 @@
+import { Prisma } from '@repo/db';
+import { RecurringInvoicesService } from './recurring-invoices.service';
+import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
+
+describe('RecurringInvoicesService', () => {
+ const orgId = 'org-1';
+ const buildingId = 'building-1';
+ const leaseId = 'lease-1';
+
+ function makeService(
+ overrides: {
+ invoice?: Partial>;
+ lease?: Partial>;
+ notifications?: Partial>;
+ } = {},
+ ) {
+ const prisma: any = {
+ invoice: {
+ findUnique: jest.fn().mockResolvedValue(null),
+ create: jest
+ .fn()
+ .mockImplementation((args: any) =>
+ Promise.resolve({ id: 'invoice-new', ...args.data }),
+ ),
+ ...overrides.invoice,
+ },
+ lease: {
+ findMany: jest.fn().mockResolvedValue([]),
+ ...overrides.lease,
+ },
+ };
+ const timeline = { emit: jest.fn().mockResolvedValue(undefined) };
+ const notifications = {
+ enqueue: jest.fn().mockResolvedValue(undefined),
+ ...overrides.notifications,
+ };
+ const leaseStatus = new LeaseStatusService();
+ const service = new RecurringInvoicesService(
+ prisma,
+ timeline as any,
+ notifications as any,
+ leaseStatus,
+ );
+ return { service, prisma, timeline, notifications, leaseStatus };
+ }
+
+ const lease = (overrides: Partial> = {}) => ({
+ id: leaseId,
+ orgId,
+ buildingId,
+ startDate: new Date('2026-01-15T00:00:00.000Z'),
+ endDate: new Date('2027-01-15T00:00:00.000Z'),
+ status: 'active',
+ rentAmount: new Prisma.Decimal('1000.00'),
+ renterUserId: null,
+ ...overrides,
+ });
+
+ // runForOrg/runAll query the DB with `renter: { select: { renterUserId } }`
+ // nested (RecurringLeaseInput itself stays flat for generateForLease's
+ // hand-built fixtures — see its doc comment). This wraps a flat `lease()`
+ // fixture into the raw Prisma-shaped row runForOrg's mapping expects.
+ const rawLeaseRow = (l: ReturnType) => {
+ const { renterUserId, ...rest } = l;
+ return { ...rest, renter: { renterUserId: renterUserId ?? null } };
+ };
+
+ describe('generateForLease', () => {
+ it('creates an invoice when the next due date is within the 7-day lead window', async () => {
+ const { service, prisma, timeline } = makeService();
+ const asOf = new Date('2026-06-15T00:00:00.000Z'); // anchor day 15 — due today
+
+ const result = await service.generateForLease(lease(), asOf);
+
+ expect(result).toBe('created');
+ expect(prisma.invoice.create).toHaveBeenCalledWith({
+ data: {
+ orgId,
+ buildingId,
+ leaseId,
+ dueDate: new Date('2026-06-15T00:00:00.000Z'),
+ billingPeriod: '2026-06',
+ lineItems: {
+ create: [
+ {
+ category: 'rent',
+ description: 'Monthly rent',
+ amount: expect.any(Prisma.Decimal),
+ },
+ ],
+ },
+ },
+ });
+ expect(timeline.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ action: 'invoice.auto_generated',
+ targetType: 'Invoice',
+ metadata: { leaseId, billingPeriod: '2026-06' },
+ }),
+ );
+ });
+
+ it('clamps a 31st anchor into a short month (Feb) and still generates within the window', async () => {
+ const { service, prisma } = makeService();
+ const shortMonthLease = lease({
+ startDate: new Date('2026-01-31T00:00:00.000Z'),
+ });
+ const asOf = new Date('2026-02-24T00:00:00.000Z'); // 4 days before Feb 28 (clamped)
+
+ const result = await service.generateForLease(shortMonthLease, asOf);
+
+ expect(result).toBe('created');
+ expect(prisma.invoice.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ dueDate: new Date('2026-02-28T00:00:00.000Z'),
+ billingPeriod: '2026-02',
+ }),
+ }),
+ );
+ });
+
+ it('returns not-due when the next occurrence is more than 7 days away', async () => {
+ const { service, prisma } = makeService();
+ const asOf = new Date('2026-06-01T00:00:00.000Z'); // anchor 15th — 14 days away
+
+ const result = await service.generateForLease(lease(), asOf);
+
+ expect(result).toBe('not-due');
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ });
+
+ it('returns skipped-existing when an invoice already exists for the billing period', async () => {
+ const { service, prisma } = makeService({
+ invoice: {
+ findUnique: jest.fn().mockResolvedValue({ id: 'existing-invoice' }),
+ },
+ });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.generateForLease(lease(), asOf);
+
+ expect(result).toBe('skipped-existing');
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ });
+
+ it('is idempotent: calling generateForLease twice for the same period creates exactly one invoice', async () => {
+ // Fake the DB unique constraint on (leaseId, billingPeriod) with a tiny
+ // in-memory store, so the second call's findUnique sees what the first
+ // call's create wrote.
+ const store = new Map();
+ const { service, prisma } = makeService({
+ invoice: {
+ findUnique: jest.fn().mockImplementation(({ where }: any) => {
+ const key = `${where.leaseId_billingPeriod.leaseId}:${where.leaseId_billingPeriod.billingPeriod}`;
+ return Promise.resolve(store.get(key) ?? null);
+ }),
+ create: jest.fn().mockImplementation(({ data }: any) => {
+ const key = `${data.leaseId}:${data.billingPeriod}`;
+ const row = { id: `invoice-${store.size + 1}`, ...data };
+ store.set(key, row);
+ return Promise.resolve(row);
+ }),
+ },
+ });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+ const theLease = lease();
+
+ const first = await service.generateForLease(theLease, asOf);
+ const second = await service.generateForLease(theLease, asOf);
+
+ expect(first).toBe('created');
+ expect(second).toBe('skipped-existing');
+ expect(prisma.invoice.create).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats a P2002 unique-constraint race on create as skipped-existing', async () => {
+ const { service, prisma } = makeService({
+ invoice: {
+ create: jest.fn().mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError('duplicate', {
+ code: 'P2002',
+ clientVersion: 'test',
+ }),
+ ),
+ },
+ });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.generateForLease(lease(), asOf);
+
+ expect(result).toBe('skipped-existing');
+ });
+
+ it('returns inactive when the lease has already ended by the candidate due date', async () => {
+ const { service, prisma } = makeService();
+ const endedLease = lease({
+ startDate: new Date('2025-01-15T00:00:00.000Z'),
+ endDate: new Date('2026-05-31T00:00:00.000Z'), // ended before the June candidate
+ });
+ const asOf = new Date('2026-06-10T00:00:00.000Z');
+
+ const result = await service.generateForLease(endedLease, asOf);
+
+ expect(result).toBe('inactive');
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ });
+
+ it('returns inactive for a non-active (draft/terminated) lease status', async () => {
+ const { service } = makeService();
+ const draftLease = lease({ status: 'draft' });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.generateForLease(draftLease, asOf);
+
+ expect(result).toBe('inactive');
+ });
+
+ it('returns inactive when the computed candidate falls before the lease actually starts', async () => {
+ const { service, prisma } = makeService();
+ // Lease doesn't start until August 10th; asOf is in early June, well
+ // before the lease starts, yet the periodic anchor's June occurrence
+ // still falls within the 7-day lead window relative to asOf.
+ const futureLease = lease({
+ startDate: new Date('2026-08-10T00:00:00.000Z'),
+ endDate: new Date('2027-08-10T00:00:00.000Z'),
+ });
+ const asOf = new Date('2026-06-05T00:00:00.000Z');
+
+ const result = await service.generateForLease(futureLease, asOf);
+
+ expect(result).toBe('inactive');
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ });
+
+ describe('tenant notification (F5.1)', () => {
+ it('notifies the tenant when the lease has a linked portal user', async () => {
+ const { service, notifications } = makeService();
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.generateForLease(
+ lease({ renterUserId: 'tenant-user-1' }),
+ asOf,
+ );
+
+ expect(result).toBe('created');
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'tenant-user-1',
+ type: 'invoice.issued',
+ }),
+ );
+ });
+
+ it('does not notify when the lease has no linked portal user', async () => {
+ const { service, notifications } = makeService();
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.generateForLease(lease(), asOf);
+
+ expect(result).toBe('created');
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+
+ it('does not notify when no invoice is generated (not-due)', async () => {
+ const { service, notifications } = makeService();
+ const asOf = new Date('2026-06-01T00:00:00.000Z'); // 14 days away
+
+ const result = await service.generateForLease(
+ lease({ renterUserId: 'tenant-user-1' }),
+ asOf,
+ );
+
+ expect(result).toBe('not-due');
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('runForOrg', () => {
+ it("aggregates created/skipped/considered across the org's active leases", async () => {
+ const dueLease = lease({ id: 'lease-due' });
+ const notDueLease = lease({
+ id: 'lease-not-due',
+ startDate: new Date('2026-01-01T00:00:00.000Z'),
+ });
+ const { service, prisma } = makeService({
+ lease: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([
+ rawLeaseRow(dueLease),
+ rawLeaseRow(notDueLease),
+ ]),
+ },
+ });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.runForOrg(orgId, asOf);
+
+ expect(prisma.lease.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({ where: { orgId, status: 'active' } }),
+ );
+ expect(result.leasesConsidered).toBe(2);
+ expect(result.generated).toBe(1);
+ expect(result.skippedExisting).toBe(0);
+ });
+ });
+
+ describe('runAll', () => {
+ it('sums runForOrg results across every org with an active lease', async () => {
+ const { service, prisma } = makeService({
+ lease: {
+ findMany: jest
+ .fn()
+ .mockResolvedValueOnce([{ orgId: 'org-a' }, { orgId: 'org-b' }]) // distinct orgId query
+ .mockResolvedValueOnce([rawLeaseRow(lease({ orgId: 'org-a' }))]) // org-a leases
+ .mockResolvedValueOnce([
+ rawLeaseRow(lease({ orgId: 'org-b', id: 'lease-2' })),
+ ]), // org-b leases
+ },
+ });
+ const asOf = new Date('2026-06-15T00:00:00.000Z');
+
+ const result = await service.runAll(asOf);
+
+ expect(prisma.lease.findMany).toHaveBeenNthCalledWith(1, {
+ where: { status: 'active' },
+ select: { orgId: true },
+ distinct: ['orgId'],
+ });
+ expect(result.leasesConsidered).toBe(2);
+ expect(result.generated).toBe(2);
+ expect(result.skippedExisting).toBe(0);
+ });
+ });
+});
diff --git a/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts
new file mode 100644
index 00000000..a8324242
--- /dev/null
+++ b/apps/api/src/modules/recurring-invoices/recurring-invoices.service.ts
@@ -0,0 +1,270 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { Prisma } from '@repo/db';
+import { PrismaService } from '@/infrastructure/prisma/prisma.service';
+import { TimelineService } from '@/modules/timeline/timeline.service';
+import { NotificationsService } from '@/modules/notifications/notifications.service';
+import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
+import { LeaseStatus, RecurringInvoiceRunResponse } from '@repo/contracts';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+/** Generate 7 days before the computed due date (decision D1). */
+const LEAD_WINDOW_DAYS = 7;
+
+export type GenerateResult =
+ 'created' | 'skipped-existing' | 'not-due' | 'inactive';
+
+/**
+ * The minimal lease shape {@link RecurringInvoicesService.generateForLease}
+ * needs. Deliberately DB-shape-agnostic (plain fields, no Prisma payload
+ * types) so unit tests can hand-build fixtures without a real Lease row.
+ */
+export type RecurringLeaseInput = {
+ id: string;
+ orgId: string;
+ buildingId: string;
+ startDate: Date;
+ endDate: Date;
+ /** Raw DB status ('draft' | 'active' | 'terminated' — 'expired' is derived, never stored). */
+ status: string;
+ rentAmount: Prisma.Decimal | number;
+ /** Keycloak sub of the tenant to notify when an invoice is auto-generated (F5.1); null if the renter has no linked portal user. */
+ renterUserId: string | null;
+};
+
+/**
+ * F3.1 recurring rent invoices (decision D1): monthly, anchored to the
+ * lease's start-date day-of-month, generated 7 days before the computed due
+ * date, idempotent per (lease, billing period) via the Invoice
+ * `@@unique([leaseId, billingPeriod])` constraint. No proration — the line
+ * item is always the flat `lease.rentAmount`.
+ */
+@Injectable()
+export class RecurringInvoicesService {
+ private readonly logger = new Logger(RecurringInvoicesService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly timeline: TimelineService,
+ private readonly notifications: NotificationsService,
+ private readonly leaseStatus: LeaseStatusService,
+ ) {}
+
+ /** UTC midnight for `date` — every comparison below is done at day granularity. */
+ private startOfUtcDay(date: Date): Date {
+ return new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+ );
+ }
+
+ /**
+ * `anchorDay` clamped into (year, month) — e.g. anchor 31 in a 28-day
+ * February becomes Feb 28. `month` may be any integer (including < 0 or
+ * > 11); `Date.UTC` normalizes the overflow/underflow into the correct
+ * year, which is what lets `nextDueDate` roll into next month for free.
+ */
+ private clampedDateForMonth(
+ year: number,
+ month: number,
+ anchorDay: number,
+ ): Date {
+ const lastDayOfMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
+ return new Date(Date.UTC(year, month, Math.min(anchorDay, lastDayOfMonth)));
+ }
+
+ /**
+ * The next occurrence of `anchorDay` on/after `today` (both already
+ * UTC-midnight-normalized): this month's occurrence (clamped for short
+ * months), or next month's if this month's has already passed.
+ */
+ private nextDueDate(anchorDay: number, today: Date): Date {
+ const thisMonth = this.clampedDateForMonth(
+ today.getUTCFullYear(),
+ today.getUTCMonth(),
+ anchorDay,
+ );
+ if (thisMonth < today) {
+ return this.clampedDateForMonth(
+ today.getUTCFullYear(),
+ today.getUTCMonth() + 1,
+ anchorDay,
+ );
+ }
+ return thisMonth;
+ }
+
+ private billingPeriodKey(date: Date): string {
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ return `${date.getUTCFullYear()}-${month}`;
+ }
+
+ /**
+ * Pure, idempotent core: given a lease and an "as of" date, decide whether
+ * a rent invoice is due and — if so — create it. Safe to call repeatedly
+ * for the same (lease, asOf): a second call in the same billing period is
+ * a no-op ('skipped-existing'), backstopped by the DB unique constraint
+ * even under a concurrent-writer race.
+ */
+ async generateForLease(
+ lease: RecurringLeaseInput,
+ asOf: Date,
+ ): Promise {
+ const today = this.startOfUtcDay(asOf);
+ const anchorDay = lease.startDate.getUTCDate();
+ const candidate = this.nextDueDate(anchorDay, today);
+
+ const leadDays = Math.round(
+ (candidate.getTime() - today.getTime()) / MS_PER_DAY,
+ );
+ if (leadDays > LEAD_WINDOW_DAYS) {
+ return 'not-due';
+ }
+
+ const isActive = this.leaseStatus.isEffectivelyActive(
+ { status: lease.status as LeaseStatus, endDate: lease.endDate },
+ candidate,
+ );
+ if (candidate < this.startOfUtcDay(lease.startDate) || !isActive) {
+ return 'inactive';
+ }
+
+ const billingPeriod = this.billingPeriodKey(candidate);
+
+ const existing = await this.prisma.invoice.findUnique({
+ where: { leaseId_billingPeriod: { leaseId: lease.id, billingPeriod } },
+ });
+ if (existing) {
+ return 'skipped-existing';
+ }
+
+ try {
+ const invoice = await this.prisma.invoice.create({
+ data: {
+ orgId: lease.orgId,
+ buildingId: lease.buildingId,
+ leaseId: lease.id,
+ dueDate: candidate,
+ billingPeriod,
+ lineItems: {
+ create: [
+ {
+ category: 'rent',
+ description: 'Monthly rent',
+ amount: lease.rentAmount,
+ },
+ ],
+ },
+ },
+ });
+
+ await this.timeline.emit({
+ orgId: lease.orgId,
+ action: 'invoice.auto_generated',
+ targetType: 'Invoice',
+ targetId: invoice.id,
+ metadata: { leaseId: lease.id, billingPeriod },
+ });
+
+ // F5.1: notify the tenant — system-generated, no actor. Skip silently
+ // when the renter has no linked portal user.
+ if (lease.renterUserId) {
+ await this.notifications.enqueue({
+ orgId: lease.orgId,
+ userId: lease.renterUserId,
+ type: 'invoice.issued',
+ title: 'New invoice issued',
+ body: `Your rent invoice for ${billingPeriod} has been issued, due ${candidate.toISOString().slice(0, 10)}.`,
+ data: { invoiceId: invoice.id, leaseId: lease.id, billingPeriod },
+ });
+ }
+
+ return 'created';
+ } catch (err) {
+ if (
+ err instanceof Prisma.PrismaClientKnownRequestError &&
+ err.code === 'P2002'
+ ) {
+ // Idempotency backstop: another writer created the same
+ // (leaseId, billingPeriod) invoice in the race window between our
+ // existence check and this create.
+ return 'skipped-existing';
+ }
+ throw err;
+ }
+ }
+
+ /** Run generation for every active lease in one org. */
+ async runForOrg(
+ orgId: string,
+ asOf: Date = new Date(),
+ ): Promise {
+ const rawLeases = await this.prisma.lease.findMany({
+ where: { orgId, status: 'active' },
+ select: {
+ id: true,
+ orgId: true,
+ buildingId: true,
+ startDate: true,
+ endDate: true,
+ status: true,
+ rentAmount: true,
+ renter: { select: { renterUserId: true } },
+ },
+ });
+
+ // Flatten Renter.renterUserId onto the lease — RecurringLeaseInput stays
+ // DB-shape-agnostic (see its doc comment) so generateForLease's unit
+ // tests can keep hand-building plain fixtures.
+ const leases: RecurringLeaseInput[] = rawLeases.map((l) => ({
+ id: l.id,
+ orgId: l.orgId,
+ buildingId: l.buildingId,
+ startDate: l.startDate,
+ endDate: l.endDate,
+ status: l.status,
+ rentAmount: l.rentAmount,
+ renterUserId: l.renter?.renterUserId ?? null,
+ }));
+
+ let generated = 0;
+ let skippedExisting = 0;
+ for (const lease of leases) {
+ const result = await this.generateForLease(lease, asOf);
+ if (result === 'created') generated++;
+ else if (result === 'skipped-existing') skippedExisting++;
+ }
+
+ return { generated, leasesConsidered: leases.length, skippedExisting };
+ }
+
+ /**
+ * Run generation across every org — the daily scheduler's entry point.
+ * Iterates orgs (derived from the set of orgs with an active lease) and
+ * aggregates each org's {@link runForOrg} result.
+ */
+ async runAll(asOf: Date = new Date()): Promise {
+ const orgs = await this.prisma.lease.findMany({
+ where: { status: 'active' },
+ select: { orgId: true },
+ distinct: ['orgId'],
+ });
+
+ const totals: RecurringInvoiceRunResponse = {
+ generated: 0,
+ leasesConsidered: 0,
+ skippedExisting: 0,
+ };
+ for (const { orgId } of orgs) {
+ const result = await this.runForOrg(orgId, asOf);
+ totals.generated += result.generated;
+ totals.leasesConsidered += result.leasesConsidered;
+ totals.skippedExisting += result.skippedExisting;
+ }
+
+ this.logger.log(
+ `Recurring invoice run: generated=${totals.generated} considered=${totals.leasesConsidered} skipped=${totals.skippedExisting}`,
+ );
+
+ return totals;
+ }
+}
diff --git a/apps/api/src/modules/renters/dto/create-renter.dto.ts b/apps/api/src/modules/renters/dto/create-renter.dto.ts
index 5796350b..da87a6aa 100644
--- a/apps/api/src/modules/renters/dto/create-renter.dto.ts
+++ b/apps/api/src/modules/renters/dto/create-renter.dto.ts
@@ -1,6 +1,25 @@
-import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import {
+ IsEmail,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ MinLength,
+ ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+export class PortalLoginDto {
+ @ApiProperty()
+ @IsEmail()
+ email: string;
+
+ @ApiProperty()
+ @IsString()
+ @MinLength(8)
+ password: string;
+}
+
export class CreateRenterDto {
@ApiProperty()
@IsString()
@@ -40,4 +59,14 @@ export class CreateRenterDto {
@IsOptional()
@IsString()
renterUserId?: string | null;
+
+ @ApiPropertyOptional({
+ description:
+ 'When present, the API mints a Keycloak tenant login for this renter and links it (sets renterUserId to the new sub). Admin-provisioned only — no self-registration. If both portalLogin and renterUserId are provided, portalLogin wins.',
+ type: PortalLoginDto,
+ })
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => PortalLoginDto)
+ portalLogin?: PortalLoginDto;
}
diff --git a/apps/api/src/modules/renters/renters.service.spec.ts b/apps/api/src/modules/renters/renters.service.spec.ts
index e80ada10..092985b1 100644
--- a/apps/api/src/modules/renters/renters.service.spec.ts
+++ b/apps/api/src/modules/renters/renters.service.spec.ts
@@ -19,6 +19,7 @@ describe('RentersService', () => {
renter?: Partial>;
lease?: Partial>;
buildingAccess?: Partial>;
+ keycloakAdmin?: Partial>;
} = {},
) {
const prisma = {
@@ -42,14 +43,21 @@ describe('RentersService', () => {
getAllowedBuildingIds: jest.fn().mockResolvedValue(null),
...overrides.buildingAccess,
};
+ const keycloakAdmin = {
+ createUserWithPassword: jest.fn().mockResolvedValue('kc-sub-1'),
+ setSingleClientRole: jest.fn().mockResolvedValue(undefined),
+ deleteUser: jest.fn().mockResolvedValue(undefined),
+ ...overrides.keycloakAdmin,
+ };
const leaseStatus = new LeaseStatusService();
const service = new RentersService(
prisma as any,
timeline as any,
buildingAccess as any,
leaseStatus,
+ keycloakAdmin as any,
);
- return { service, prisma, timeline, buildingAccess };
+ return { service, prisma, timeline, buildingAccess, keycloakAdmin };
}
const renterRow = (overrides: Partial> = {}) => ({
@@ -221,7 +229,7 @@ describe('RentersService', () => {
const dto = { fullName: 'Jane Doe' };
it('creates a renter scoped to the org and emits renter.created', async () => {
- const { service, prisma, timeline } = makeService();
+ const { service, prisma, timeline, keycloakAdmin } = makeService();
prisma.renter.create.mockResolvedValue(renterRow());
await service.create(orgId, actorId, dto);
@@ -245,6 +253,71 @@ describe('RentersService', () => {
targetType: 'Renter',
}),
);
+ // No portalLogin was passed → zero Keycloak calls, behavior unchanged.
+ expect(keycloakAdmin.createUserWithPassword).not.toHaveBeenCalled();
+ expect(keycloakAdmin.setSingleClientRole).not.toHaveBeenCalled();
+ expect(keycloakAdmin.deleteUser).not.toHaveBeenCalled();
+ });
+
+ it('mints a Keycloak tenant login when portalLogin is provided, links renterUserId to the new sub, and tags the timeline event', async () => {
+ const { service, prisma, timeline, keycloakAdmin } = makeService();
+ prisma.renter.create.mockResolvedValue(
+ renterRow({ renterUserId: 'kc-sub-1' }),
+ );
+
+ const portalDto = {
+ fullName: 'Jane Doe',
+ portalLogin: { email: 'jane@tenant.test', password: 'password123' },
+ };
+
+ await service.create(orgId, actorId, portalDto);
+
+ expect(keycloakAdmin.createUserWithPassword).toHaveBeenCalledWith({
+ username: 'jane@tenant.test',
+ password: 'password123',
+ email: 'jane@tenant.test',
+ firstName: 'Jane',
+ lastName: 'Doe',
+ attributes: { org_id: [orgId] },
+ });
+ expect(keycloakAdmin.setSingleClientRole).toHaveBeenCalledWith(
+ 'kc-sub-1',
+ Role.TENANT,
+ );
+ expect(prisma.renter.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({ renterUserId: 'kc-sub-1' }),
+ });
+ expect(timeline.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'renter.created',
+ metadata: expect.objectContaining({ portalLogin: true }),
+ }),
+ );
+ });
+
+ it('maps a Keycloak 409 (email already taken) to ConflictException and persists no renter', async () => {
+ const conflict = {
+ isAxiosError: true,
+ response: { status: 409 },
+ message: 'Request failed with status code 409',
+ };
+
+ const { service, prisma, keycloakAdmin } = makeService({
+ keycloakAdmin: {
+ createUserWithPassword: jest.fn().mockRejectedValue(conflict),
+ },
+ });
+
+ const portalDto = {
+ fullName: 'Jane Doe',
+ portalLogin: { email: 'jane@tenant.test', password: 'password123' },
+ };
+
+ await expect(
+ service.create(orgId, actorId, portalDto),
+ ).rejects.toBeInstanceOf(ConflictException);
+ expect(prisma.renter.create).not.toHaveBeenCalled();
+ expect(keycloakAdmin.setSingleClientRole).not.toHaveBeenCalled();
});
});
diff --git a/apps/api/src/modules/renters/renters.service.ts b/apps/api/src/modules/renters/renters.service.ts
index 6153d222..5aae08a4 100644
--- a/apps/api/src/modules/renters/renters.service.ts
+++ b/apps/api/src/modules/renters/renters.service.ts
@@ -2,12 +2,15 @@ import {
ConflictException,
ForbiddenException,
Injectable,
+ Logger,
NotFoundException,
} from '@nestjs/common';
+import { isAxiosError } from 'axios';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { TimelineService } from '@/modules/timeline/timeline.service';
import { BuildingAccessService } from '@/common/building-access/building-access.service';
import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
+import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service';
import { formatLease, LeaseRow } from '@/modules/leases/lease-formatter';
import { Role } from '@/common/enums';
import {
@@ -34,11 +37,14 @@ type RenterRow = {
@Injectable()
export class RentersService {
+ private readonly logger = new Logger(RentersService.name);
+
constructor(
private readonly prisma: PrismaService,
private readonly timeline: TimelineService,
private readonly buildingAccess: BuildingAccessService,
private readonly leaseStatus: LeaseStatusService,
+ private readonly keycloakAdmin: KeycloakAdminService,
) {}
// ── Format helpers ────────────────────────────────────────────────────────
@@ -148,18 +154,65 @@ export class RentersService {
actorId: string,
dto: CreateRenterDto,
): Promise<{ data: RenterResponse }> {
- const renter = await this.prisma.renter.create({
- data: {
- orgId,
- fullName: dto.fullName,
- email: dto.email,
- phone: dto.phone,
- emergencyContactName: dto.emergencyContactName,
- emergencyContactPhone: dto.emergencyContactPhone,
- notes: dto.notes,
- renterUserId: dto.renterUserId,
- },
- });
+ // portalLogin wins over a passed-through renterUserId (see CreateRenterBody).
+ let renterUserId = dto.renterUserId;
+ let portalLoginMinted = false;
+
+ if (dto.portalLogin) {
+ const [firstName = '', ...rest] = dto.fullName.trim().split(' ');
+ const lastName = rest.join(' ');
+
+ try {
+ renterUserId = await this.keycloakAdmin.createUserWithPassword({
+ username: dto.portalLogin.email,
+ password: dto.portalLogin.password,
+ email: dto.portalLogin.email,
+ firstName,
+ lastName,
+ attributes: { org_id: [orgId] },
+ });
+ } catch (error) {
+ if (isAxiosError(error) && error.response?.status === 409) {
+ throw new ConflictException(
+ 'A portal login with this email already exists.',
+ );
+ }
+ throw error;
+ }
+
+ // Tenant is NOT a CAPPED_ROLE and does not get building assignments —
+ // unlike staff (see UsersService.create).
+ await this.keycloakAdmin.setSingleClientRole(renterUserId, Role.TENANT);
+ portalLoginMinted = true;
+ }
+
+ let renter: RenterRow;
+ try {
+ renter = await this.prisma.renter.create({
+ data: {
+ orgId,
+ fullName: dto.fullName,
+ email: dto.email,
+ phone: dto.phone,
+ emergencyContactName: dto.emergencyContactName,
+ emergencyContactPhone: dto.emergencyContactPhone,
+ notes: dto.notes,
+ renterUserId,
+ },
+ });
+ } catch (error) {
+ // Best-effort rollback: don't strand a Keycloak login with no renter row.
+ if (portalLoginMinted && renterUserId) {
+ try {
+ await this.keycloakAdmin.deleteUser(renterUserId);
+ } catch (cleanupError) {
+ this.logger.warn(
+ `Failed to roll back Keycloak user ${renterUserId} after renter creation failure: ${String(cleanupError)}`,
+ );
+ }
+ }
+ throw error;
+ }
await this.timeline.emit({
orgId,
@@ -167,7 +220,10 @@ export class RentersService {
action: 'renter.created',
targetType: 'Renter',
targetId: renter.id,
- metadata: { fullName: renter.fullName },
+ metadata: {
+ fullName: renter.fullName,
+ ...(portalLoginMinted && { portalLogin: true }),
+ },
});
return { data: this.formatRenter(renter, null) };
diff --git a/apps/api/src/modules/tenant/tenant.service.spec.ts b/apps/api/src/modules/tenant/tenant.service.spec.ts
index ecd14283..83dc8d4f 100644
--- a/apps/api/src/modules/tenant/tenant.service.spec.ts
+++ b/apps/api/src/modules/tenant/tenant.service.spec.ts
@@ -72,6 +72,7 @@ describe('TenantService', () => {
expect(data.linked).toBe(false);
expect(data.renter).toBeNull();
expect(data.lease).toBeNull();
+ expect(data.leaseHistory).toEqual([]);
expect(data.invoices).toEqual([]);
expect(data.maintenanceRequests).toEqual([]);
expect(data.balance).toEqual({
@@ -157,6 +158,44 @@ describe('TenantService', () => {
]);
});
+ it('returns the full lease history newest-first, distinct from the single current lease', async () => {
+ const olderLease = leaseRow({
+ id: 'lease-0',
+ startDate: new Date('2024-01-01T00:00:00.000Z'),
+ endDate: new Date('2025-01-01T00:00:00.000Z'),
+ status: 'ended',
+ apartment: { unitNumber: '2A', building: { name: 'Old Building' } },
+ });
+ const currentLease = leaseRow(); // startDate 2026-01-01, newer
+ const { service } = makeService({
+ renter: { findFirst: jest.fn().mockResolvedValue(renter) },
+ // Prisma orders by startDate desc, so the mock returns newest first.
+ lease: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([currentLease, olderLease]),
+ },
+ });
+
+ const { data } = await service.getOverview(orgId, sub, now);
+
+ expect(data.leaseHistory).toHaveLength(2);
+ expect(data.leaseHistory.map((l) => l.id)).toEqual([
+ 'lease-1',
+ 'lease-0',
+ ]);
+ expect(data.leaseHistory[1]).toEqual(
+ expect.objectContaining({
+ id: 'lease-0',
+ unitNumber: '2A',
+ buildingName: 'Old Building',
+ status: 'ended',
+ }),
+ );
+ // `lease` remains the single current one, unaffected by the history list.
+ expect(data.lease?.id).toBe('lease-1');
+ });
+
it('derives effectiveStatus expired for an active lease whose endDate has passed', async () => {
const { service } = makeService({
renter: { findFirst: jest.fn().mockResolvedValue(renter) },
diff --git a/apps/api/src/modules/tenant/tenant.service.ts b/apps/api/src/modules/tenant/tenant.service.ts
index 98e27e35..5e933f55 100644
--- a/apps/api/src/modules/tenant/tenant.service.ts
+++ b/apps/api/src/modules/tenant/tenant.service.ts
@@ -74,6 +74,7 @@ export class TenantService {
linked: false,
renter: null,
lease: null,
+ leaseHistory: [],
balance: { invoiced: '0.00', paid: '0.00', outstanding: '0.00' },
invoices: [],
maintenanceRequests: [],
@@ -226,6 +227,12 @@ export class TenantService {
phone: renter.phone,
},
lease: current ? this.formatLease(current, now) : null,
+ // Full lease history for the portal (TP3) — same per-lease formatter as
+ // `lease`, applied to every lease the tenant has ever held, newest first
+ // (leases were already fetched ordered by startDate desc).
+ leaseHistory: (leases as LeaseRow[]).map((l) =>
+ this.formatLease(l, now),
+ ),
balance: {
invoiced: this.money(totalInvoiced),
paid: this.money(totalPaid),
diff --git a/apps/api/src/modules/timeline/timeline.service.spec.ts b/apps/api/src/modules/timeline/timeline.service.spec.ts
new file mode 100644
index 00000000..05e9b0ad
--- /dev/null
+++ b/apps/api/src/modules/timeline/timeline.service.spec.ts
@@ -0,0 +1,156 @@
+import { TimelineService } from './timeline.service';
+import { AuthenticatedUser } from '@/common/types/authenticated-user.type';
+
+/**
+ * Regression guard for the paginated-list contract drift bug: the timeline feed
+ * envelope MUST expose its rows under `items` (matching PaginatedResponse in
+ * @repo/contracts and the FE reads), never `data`. A `data` array here silently
+ * renders every "Recent activity" feed empty on the client.
+ */
+describe('TimelineService', () => {
+ const orgId = 'org-1';
+
+ function makeService(
+ rows: unknown[] = [],
+ total = 0,
+ overrides: { keycloakAdmin?: Partial> } = {},
+ ) {
+ const prisma: any = {
+ event: {
+ findMany: jest.fn().mockResolvedValue(rows),
+ count: jest.fn().mockResolvedValue(total),
+ },
+ };
+ const keycloakAdmin = {
+ getUser: jest.fn().mockResolvedValue(null),
+ ...overrides.keycloakAdmin,
+ };
+ return {
+ service: new TimelineService(prisma, keycloakAdmin as any),
+ prisma,
+ keycloakAdmin,
+ };
+ }
+
+ const caller = (roles: string[]): AuthenticatedUser =>
+ ({ sub: 'user-1', roles } as unknown as AuthenticatedUser);
+
+ it('returns the paginated envelope keyed by `items`, not `data`', async () => {
+ const rows = [{ id: 'evt-1' }, { id: 'evt-2' }];
+ const { service } = makeService(rows, 2);
+
+ const result = await service.findForCaller(orgId, caller(['org_admin']), 1, 20);
+
+ expect(result).toEqual({
+ items: [
+ { id: 'evt-1', actorName: null },
+ { id: 'evt-2', actorName: null },
+ ],
+ total: 2,
+ page: 1,
+ limit: 20,
+ });
+ expect(result).not.toHaveProperty('data');
+ });
+
+ describe('actorName enrichment (F5.2)', () => {
+ it('resolves the actor name once for two events by the same actor (dedupe/cache)', async () => {
+ const rows = [
+ { id: 'evt-1', actorId: 'user-a' },
+ { id: 'evt-2', actorId: 'user-a' },
+ ];
+ const { service, keycloakAdmin } = makeService(rows, 2, {
+ keycloakAdmin: {
+ getUser: jest
+ .fn()
+ .mockResolvedValue({ firstName: 'Jane', lastName: 'Doe' }),
+ },
+ });
+
+ const result = await service.findForCaller(
+ orgId,
+ caller(['org_admin']),
+ 1,
+ 20,
+ );
+
+ expect(result.items).toEqual([
+ { id: 'evt-1', actorId: 'user-a', actorName: 'Jane Doe' },
+ { id: 'evt-2', actorId: 'user-a', actorName: 'Jane Doe' },
+ ]);
+ expect(keycloakAdmin.getUser).toHaveBeenCalledTimes(1);
+ expect(keycloakAdmin.getUser).toHaveBeenCalledWith('user-a');
+ });
+
+ it('caches a resolved actor name across separate findForCaller calls', async () => {
+ const rows = [{ id: 'evt-1', actorId: 'user-a' }];
+ const { service, keycloakAdmin } = makeService(rows, 1, {
+ keycloakAdmin: {
+ getUser: jest.fn().mockResolvedValue({ firstName: 'Jane' }),
+ },
+ });
+
+ await service.findForCaller(orgId, caller(['org_admin']), 1, 20);
+ await service.findForCaller(orgId, caller(['org_admin']), 1, 20);
+
+ expect(keycloakAdmin.getUser).toHaveBeenCalledTimes(1);
+ });
+
+ it('yields a null actorName for a system-generated event (null actorId)', async () => {
+ const rows = [{ id: 'evt-1', actorId: null }];
+ const { service, keycloakAdmin } = makeService(rows, 1);
+
+ const result = await service.findForCaller(
+ orgId,
+ caller(['org_admin']),
+ 1,
+ 20,
+ );
+
+ expect(result.items).toEqual([
+ { id: 'evt-1', actorId: null, actorName: null },
+ ]);
+ expect(keycloakAdmin.getUser).not.toHaveBeenCalled();
+ });
+
+ it('yields a null actorName (never throws) when the Keycloak lookup fails', async () => {
+ const rows = [{ id: 'evt-1', actorId: 'user-a' }];
+ const { service } = makeService(rows, 1, {
+ keycloakAdmin: {
+ getUser: jest.fn().mockRejectedValue(new Error('KC unreachable')),
+ },
+ });
+
+ const result = await service.findForCaller(
+ orgId,
+ caller(['org_admin']),
+ 1,
+ 20,
+ );
+
+ expect(result.items).toEqual([
+ { id: 'evt-1', actorId: 'user-a', actorName: null },
+ ]);
+ });
+
+ it('falls back to email when no first/last name is available', async () => {
+ const rows = [{ id: 'evt-1', actorId: 'user-a' }];
+ const { service } = makeService(rows, 1, {
+ keycloakAdmin: {
+ getUser: jest.fn().mockResolvedValue({ email: 'jane@example.com' }),
+ },
+ });
+
+ const result = await service.findForCaller(
+ orgId,
+ caller(['org_admin']),
+ 1,
+ 20,
+ );
+
+ expect(result.items[0]).toEqual(
+ expect.objectContaining({ actorName: 'jane@example.com' }),
+ );
+ });
+ });
+});
diff --git a/apps/api/src/modules/timeline/timeline.service.ts b/apps/api/src/modules/timeline/timeline.service.ts
index e0b3d330..27e0ffc9 100644
--- a/apps/api/src/modules/timeline/timeline.service.ts
+++ b/apps/api/src/modules/timeline/timeline.service.ts
@@ -3,6 +3,7 @@ import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { Prisma } from '@repo/db';
import { Role } from '@/common/enums';
import { AuthenticatedUser } from '@/common/types/authenticated-user.type';
+import { KeycloakAdminService } from '@/infrastructure/keycloak/keycloak-admin.service';
export interface EmitEventOptions {
orgId: string;
@@ -13,11 +14,30 @@ export interface EmitEventOptions {
metadata?: Record;
}
+/** F5.2: how long a resolved actor display name is cached before re-fetching. */
+const ACTOR_NAME_CACHE_TTL_MS = 5 * 60 * 1000;
+
+interface ActorNameCacheEntry {
+ name: string | null;
+ expiresAt: number;
+}
+
@Injectable()
export class TimelineService {
private readonly logger = new Logger(TimelineService.name);
- constructor(private readonly prisma: PrismaService) {}
+ /**
+ * F5.2: process-local cache of actorId (Keycloak sub) -> resolved display
+ * name, so the activity feed doesn't hit Keycloak once per event per
+ * request. Small TTL rather than forever, so a user's name change (rare)
+ * eventually shows up without a restart.
+ */
+ private readonly actorNameCache = new Map();
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly keycloakAdmin: KeycloakAdminService,
+ ) {}
async emit(options: EmitEventOptions): Promise {
try {
@@ -53,17 +73,21 @@ export class TimelineService {
// Tenant: only own events
where = { orgId, actorId: caller.sub };
} else if (role === Role.FINANCE) {
- // Finance: billing/payment/subscription events only
+ // Finance: invoice/invoice-payment/expense events only (finance's actual
+ // domain — NOT the platform's own Stripe subscription billing, which
+ // finance cannot even reach; see billing.controller.ts @Roles).
where = {
orgId,
action: {
in: [
- 'subscription.created',
- 'subscription.updated',
- 'subscription.canceled',
- 'payment.paid',
- 'payment.failed',
- 'checkout.completed',
+ 'invoice.created',
+ 'invoice.updated',
+ 'invoice.deleted',
+ 'invoice_payment.created',
+ 'invoice_payment.deleted',
+ 'expense.created',
+ 'expense.updated',
+ 'expense.deleted',
],
},
};
@@ -80,6 +104,81 @@ export class TimelineService {
this.prisma.event.count({ where }),
]);
- return { items, total, page, limit };
+ return {
+ items: await this.enrichWithActorNames(items),
+ total,
+ page,
+ limit,
+ };
+ }
+
+ /**
+ * F5.2: resolve each event's `actorId` (a Keycloak sub) to a display name
+ * for the activity feed's "who" column. Deduped per page (one KC lookup per
+ * distinct actor, not per event) and cached across requests; a null/missing
+ * actorId (system-generated event) or an unresolvable actor both yield a
+ * null `actorName` rather than throwing — the feed must always return.
+ */
+ private async enrichWithActorNames<
+ T extends { actorId?: string | null },
+ >(events: T[]): Promise<(T & { actorName: string | null })[]> {
+ const distinctActorIds = [
+ ...new Set(
+ events
+ .map((e) => e.actorId)
+ .filter((id): id is string => Boolean(id)),
+ ),
+ ];
+
+ const nameByActorId = new Map();
+ await Promise.all(
+ distinctActorIds.map(async (actorId) => {
+ nameByActorId.set(actorId, await this.resolveActorName(actorId));
+ }),
+ );
+
+ return events.map((e) => ({
+ ...e,
+ actorName: e.actorId ? (nameByActorId.get(e.actorId) ?? null) : null,
+ }));
+ }
+
+ /** Resolve (and cache) a single actor's display name by Keycloak sub. */
+ private async resolveActorName(sub: string): Promise {
+ const cached = this.actorNameCache.get(sub);
+ const now = Date.now();
+ if (cached && cached.expiresAt > now) {
+ return cached.name;
+ }
+
+ let name: string | null = null;
+ try {
+ const user = await this.keycloakAdmin.getUser(sub);
+ name = this.extractDisplayName(user);
+ } catch (error) {
+ // Never let a KC hiccup fail the whole feed — the event still renders,
+ // just without a resolved actor name.
+ this.logger.warn(`Failed to resolve actor name for ${sub}: ${String(error)}`);
+ name = null;
+ }
+
+ this.actorNameCache.set(sub, {
+ name,
+ expiresAt: now + ACTOR_NAME_CACHE_TTL_MS,
+ });
+ return name;
+ }
+
+ /** firstName + lastName, falling back to email, then null. */
+ private extractDisplayName(
+ user: Record | null,
+ ): string | null {
+ if (!user) return null;
+ const firstName = typeof user.firstName === 'string' ? user.firstName : '';
+ const lastName = typeof user.lastName === 'string' ? user.lastName : '';
+ const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
+ if (fullName) return fullName;
+ const email = user.email;
+ return typeof email === 'string' && email.length > 0 ? email : null;
}
}
diff --git a/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts b/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts
index 778203b3..35f735db 100644
--- a/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts
+++ b/apps/api/src/modules/work-orders/dto/create-work-order.dto.ts
@@ -1,4 +1,11 @@
-import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
+import {
+ IsBoolean,
+ IsEnum,
+ IsNumber,
+ IsOptional,
+ IsString,
+ Min,
+} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WorkOrderStatus } from '@repo/db';
@@ -31,4 +38,18 @@ export class CreateWorkOrderDto {
@IsOptional()
@IsString()
resolutionNotes?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Opt-in: bill this work order to the tenant on completion. Defaults to false.',
+ })
+ @IsOptional()
+ @IsBoolean()
+ chargeToTenant?: boolean;
+
+ @ApiPropertyOptional({ nullable: true })
+ @IsOptional()
+ @IsNumber({ maxDecimalPlaces: 2 })
+ @Min(0)
+ tenantChargeAmount?: number | null;
}
diff --git a/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts b/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts
index b169dd18..5312b024 100644
--- a/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts
+++ b/apps/api/src/modules/work-orders/dto/update-work-order.dto.ts
@@ -1,4 +1,5 @@
import {
+ IsBoolean,
IsDateString,
IsEnum,
IsNumber,
@@ -40,4 +41,17 @@ export class UpdateWorkOrderDto {
@IsOptional()
@IsDateString()
completedAt?: string | null;
+
+ @ApiPropertyOptional({
+ description: 'Opt-in: bill this work order to the tenant on completion.',
+ })
+ @IsOptional()
+ @IsBoolean()
+ chargeToTenant?: boolean;
+
+ @ApiPropertyOptional({ nullable: true })
+ @IsOptional()
+ @IsNumber({ maxDecimalPlaces: 2 })
+ @Min(0)
+ tenantChargeAmount?: number | null;
}
diff --git a/apps/api/src/modules/work-orders/work-order-formatter.ts b/apps/api/src/modules/work-orders/work-order-formatter.ts
index 6c36cf12..32bb1b76 100644
--- a/apps/api/src/modules/work-orders/work-order-formatter.ts
+++ b/apps/api/src/modules/work-orders/work-order-formatter.ts
@@ -1,9 +1,10 @@
import { Prisma } from '@repo/db';
-import { WorkOrderResponse } from '@repo/contracts';
+import { WorkOrderResponse, formatWorkOrderNumber } from '@repo/contracts';
export type WorkOrderRow = {
id: string;
orgId: string;
+ number: number;
maintenanceRequestId: string;
vendorId: string | null;
assignedUserId: string | null;
@@ -11,6 +12,9 @@ export type WorkOrderRow = {
cost: Prisma.Decimal | null;
resolutionNotes: string | null;
completedAt: Date | null;
+ chargeToTenant: boolean;
+ tenantChargeAmount: Prisma.Decimal | null;
+ tenantChargedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
@@ -20,6 +24,8 @@ export function formatWorkOrder(workOrder: WorkOrderRow): WorkOrderResponse {
return {
id: workOrder.id,
orgId: workOrder.orgId,
+ number: workOrder.number,
+ numberLabel: formatWorkOrderNumber(workOrder.number),
maintenanceRequestId: workOrder.maintenanceRequestId,
vendorId: workOrder.vendorId,
assignedUserId: workOrder.assignedUserId,
@@ -27,6 +33,9 @@ export function formatWorkOrder(workOrder: WorkOrderRow): WorkOrderResponse {
cost: workOrder.cost?.toString() ?? null,
resolutionNotes: workOrder.resolutionNotes,
completedAt: workOrder.completedAt?.toISOString() ?? null,
+ chargeToTenant: workOrder.chargeToTenant,
+ tenantChargeAmount: workOrder.tenantChargeAmount?.toString() ?? null,
+ tenantChargedAt: workOrder.tenantChargedAt?.toISOString() ?? null,
createdAt: workOrder.createdAt.toISOString(),
updatedAt: workOrder.updatedAt.toISOString(),
};
diff --git a/apps/api/src/modules/work-orders/work-orders-overview.controller.ts b/apps/api/src/modules/work-orders/work-orders-overview.controller.ts
new file mode 100644
index 00000000..3676959a
--- /dev/null
+++ b/apps/api/src/modules/work-orders/work-orders-overview.controller.ts
@@ -0,0 +1,31 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { WorkOrdersService } from './work-orders.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';
+
+/**
+ * Org-wide "my work orders" list (Sprint F2.2) — feeds a maintenance user's
+ * assigned-to-me view across every maintenance request. Complements (does
+ * not replace) the nested
+ * maintenance-requests/:maintenanceRequestId/work-orders controller, which
+ * stays scoped to a single request.
+ */
+@ApiTags('work-orders')
+@ApiBearerAuth()
+@Controller('work-orders')
+export class WorkOrdersOverviewController {
+ constructor(
+ private readonly workOrdersService: WorkOrdersService,
+ private readonly orgScope: OrgScopeService,
+ ) {}
+
+ @Roles(Role.ORG_ADMIN, Role.SUPERVISOR, Role.MAINTENANCE)
+ @Get('assigned-to-me')
+ async getAssignedToMe(@CurrentUser() user: AuthenticatedUser) {
+ const { orgId } = await this.orgScope.resolveForCaller(user);
+ return this.workOrdersService.findAssignedToCaller(orgId, user.sub);
+ }
+}
diff --git a/apps/api/src/modules/work-orders/work-orders.module.ts b/apps/api/src/modules/work-orders/work-orders.module.ts
index 89bdbe27..7042403d 100644
--- a/apps/api/src/modules/work-orders/work-orders.module.ts
+++ b/apps/api/src/modules/work-orders/work-orders.module.ts
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { WorkOrdersController } from './work-orders.controller';
+import { WorkOrdersOverviewController } from './work-orders-overview.controller';
import { WorkOrdersService } from './work-orders.service';
import { WorkOrderApartmentStatusService } from './work-order-apartment-status.service';
+import { NotificationsModule } from '@/modules/notifications/notifications.module';
@Module({
- controllers: [WorkOrdersController],
+ imports: [NotificationsModule],
+ controllers: [WorkOrdersController, WorkOrdersOverviewController],
providers: [WorkOrdersService, WorkOrderApartmentStatusService],
exports: [WorkOrdersService],
})
diff --git a/apps/api/src/modules/work-orders/work-orders.service.spec.ts b/apps/api/src/modules/work-orders/work-orders.service.spec.ts
index 56fe6722..246efa31 100644
--- a/apps/api/src/modules/work-orders/work-orders.service.spec.ts
+++ b/apps/api/src/modules/work-orders/work-orders.service.spec.ts
@@ -21,8 +21,12 @@ describe('WorkOrdersService', () => {
maintenanceRequest?: Partial>;
workOrder?: Partial>;
expense?: Partial>;
+ lease?: Partial>;
+ invoice?: Partial>;
buildingAccess?: Partial>;
+ leaseStatus?: Partial>;
workOrderApartmentStatus?: Partial>;
+ notifications?: Partial>;
} = {},
) {
const prisma: any = {
@@ -32,12 +36,14 @@ describe('WorkOrdersService', () => {
orgId,
buildingId,
apartmentId,
+ title: 'Leaking faucet',
}),
...overrides.maintenanceRequest,
},
workOrder: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
+ aggregate: jest.fn().mockResolvedValue({ _max: { number: 0 } }),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
@@ -47,12 +53,36 @@ describe('WorkOrdersService', () => {
count: jest.fn().mockResolvedValue(0),
...overrides.expense,
},
+ // F3.2 tenant-charge path: find the apartment's active lease and create
+ // a new invoice on it.
+ lease: {
+ findFirst: jest.fn().mockResolvedValue(null),
+ ...overrides.lease,
+ },
+ invoice: {
+ create: jest.fn().mockResolvedValue({ id: 'invoice-1' }),
+ ...overrides.invoice,
+ },
+ // create() assigns the org-scoped number inside a transaction + advisory
+ // lock; the tenant-charge path also runs inside a $transaction. Run the
+ // callback against the same mock and stub the raw lock.
+ $executeRaw: jest.fn().mockResolvedValue(1),
+ $transaction: jest.fn(),
};
+ prisma.$transaction.mockImplementation((cb: any) => cb(prisma));
const buildingAccess = {
assertBuildingAccess: jest.fn().mockResolvedValue(undefined),
...overrides.buildingAccess,
};
const timeline = { emit: jest.fn().mockResolvedValue(undefined) };
+ const notifications = {
+ enqueue: jest.fn().mockResolvedValue(undefined),
+ ...overrides.notifications,
+ };
+ const leaseStatus = {
+ isEffectivelyActive: jest.fn().mockReturnValue(true),
+ ...overrides.leaseStatus,
+ };
const workOrderApartmentStatus = {
onWorkOrderOpened: jest.fn().mockResolvedValue(undefined),
onWorkOrderClosed: jest.fn().mockResolvedValue(undefined),
@@ -62,6 +92,8 @@ describe('WorkOrdersService', () => {
prisma,
buildingAccess as any,
timeline as any,
+ notifications as any,
+ leaseStatus as any,
workOrderApartmentStatus as any,
);
return {
@@ -69,6 +101,8 @@ describe('WorkOrdersService', () => {
prisma,
buildingAccess,
timeline,
+ notifications,
+ leaseStatus,
workOrderApartmentStatus,
};
}
@@ -76,6 +110,7 @@ describe('WorkOrdersService', () => {
const workOrderRow = (overrides: Partial> = {}) => ({
id: 'wo-1',
orgId,
+ number: 1,
maintenanceRequestId,
vendorId: 'vendor-1',
assignedUserId: null,
@@ -83,6 +118,9 @@ describe('WorkOrdersService', () => {
cost: new Prisma.Decimal('150.00'),
resolutionNotes: null,
completedAt: null,
+ chargeToTenant: false,
+ tenantChargeAmount: null,
+ tenantChargedAt: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
@@ -241,6 +279,126 @@ describe('WorkOrdersService', () => {
});
});
+ describe('findAssignedToCaller', () => {
+ const assignedRow = (overrides: Partial> = {}) => ({
+ ...workOrderRow({ assignedUserId: callerId, vendorId: null }),
+ maintenanceRequest: {
+ title: 'Leaking faucet',
+ status: 'open',
+ buildingId,
+ apartmentId,
+ apartment: {
+ unitNumber: '101',
+ building: { name: 'Tower A' },
+ },
+ },
+ ...overrides,
+ });
+
+ it('queries only work orders assigned to the caller, ordered by createdAt desc', async () => {
+ const { service, prisma } = makeService({
+ workOrder: { findMany: jest.fn().mockResolvedValue([assignedRow()]) },
+ });
+
+ await service.findAssignedToCaller(orgId, callerId);
+
+ expect(prisma.workOrder.findMany).toHaveBeenCalledWith({
+ where: { orgId, assignedUserId: callerId },
+ include: {
+ maintenanceRequest: {
+ select: {
+ title: true,
+ status: true,
+ buildingId: true,
+ apartmentId: true,
+ apartment: {
+ select: {
+ unitNumber: true,
+ building: { select: { name: true } },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+ });
+
+ it('enriches each row with request/apartment/building context and the numberLabel', async () => {
+ const { service } = makeService({
+ workOrder: {
+ findMany: jest.fn().mockResolvedValue([assignedRow({ number: 123 })]),
+ },
+ });
+
+ const result = await service.findAssignedToCaller(orgId, callerId);
+
+ expect(result.data).toEqual([
+ expect.objectContaining({
+ id: 'wo-1',
+ number: 123,
+ numberLabel: 'WO-000123',
+ requestTitle: 'Leaking faucet',
+ requestStatus: 'open',
+ buildingId,
+ buildingName: 'Tower A',
+ apartmentId,
+ apartmentUnit: '101',
+ }),
+ ]);
+ });
+
+ it('returns an empty list when the caller has no assigned work orders', async () => {
+ const { service, prisma } = makeService({
+ workOrder: { findMany: jest.fn().mockResolvedValue([]) },
+ });
+
+ const result = await service.findAssignedToCaller(orgId, 'other-caller');
+
+ expect(prisma.workOrder.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { orgId, assignedUserId: 'other-caller' },
+ }),
+ );
+ expect(result.data).toEqual([]);
+ });
+
+ it('surfaces active (scheduled/in_progress) work orders before completed/canceled ones', async () => {
+ const completed = assignedRow({
+ id: 'wo-completed',
+ status: 'completed',
+ createdAt: new Date('2026-01-03T00:00:00.000Z'),
+ });
+ const scheduled = assignedRow({
+ id: 'wo-scheduled',
+ status: 'scheduled',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ });
+ const inProgress = assignedRow({
+ id: 'wo-in-progress',
+ status: 'in_progress',
+ createdAt: new Date('2026-01-02T00:00:00.000Z'),
+ });
+ const { service } = makeService({
+ // Simulate the query's createdAt-desc ordering: completed (newest)
+ // first, then in_progress, then scheduled (oldest).
+ workOrder: {
+ findMany: jest
+ .fn()
+ .mockResolvedValue([completed, inProgress, scheduled]),
+ },
+ });
+
+ const result = await service.findAssignedToCaller(orgId, callerId);
+
+ expect(result.data.map((w) => w.id)).toEqual([
+ 'wo-in-progress',
+ 'wo-scheduled',
+ 'wo-completed',
+ ]);
+ });
+ });
+
describe('create', () => {
it('creates a work order with a vendor assigned, opens the apartment, and emits work_order.created', async () => {
const { service, prisma, timeline, workOrderApartmentStatus } =
@@ -262,6 +420,7 @@ describe('WorkOrdersService', () => {
expect(prisma.workOrder.create).toHaveBeenCalledWith({
data: {
orgId,
+ number: 1,
maintenanceRequestId,
vendorId: 'vendor-1',
assignedUserId: undefined,
@@ -270,6 +429,10 @@ describe('WorkOrdersService', () => {
resolutionNotes: undefined,
},
});
+ expect(prisma.workOrder.aggregate).toHaveBeenCalledWith({
+ where: { orgId },
+ _max: { number: true },
+ });
expect(workOrderApartmentStatus.onWorkOrderOpened).toHaveBeenCalledWith(
apartmentId,
);
@@ -295,6 +458,36 @@ describe('WorkOrdersService', () => {
expect(result.data.assignedUserId).toBe('user-1');
});
+ it('assigns the next org-scoped number (max + 1) and exposes a WO-000123 label', async () => {
+ const { service, prisma } = makeService({
+ workOrder: {
+ aggregate: jest.fn().mockResolvedValue({ _max: { number: 41 } }),
+ create: jest
+ .fn()
+ .mockResolvedValue(
+ workOrderRow({ number: 42, vendorId: 'vendor-1' }),
+ ),
+ },
+ });
+
+ const result = await service.create(
+ orgId,
+ actorId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ { vendorId: 'vendor-1' },
+ );
+
+ expect(prisma.$executeRaw).toHaveBeenCalled(); // advisory lock taken
+ expect(prisma.workOrder.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({ number: 42 }),
+ }),
+ );
+ expect(result.data.number).toBe(42);
+ expect(result.data.numberLabel).toBe('WO-000042');
+ });
+
it('rejects with BadRequestException when both vendorId and assignedUserId are set', async () => {
const { service } = makeService();
@@ -343,6 +536,173 @@ describe('WorkOrdersService', () => {
});
});
+ describe('create — tenant charge on completion (F3.2)', () => {
+ const activeLeaseRow = {
+ id: 'lease-1',
+ buildingId,
+ status: 'active',
+ endDate: new Date('2030-01-01T00:00:00.000Z'),
+ };
+
+ it("charges the apartment's active lease when created directly as completed with chargeToTenant", async () => {
+ const { service, prisma, timeline } = makeService({
+ lease: { findFirst: jest.fn().mockResolvedValue(activeLeaseRow) },
+ });
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: new Prisma.Decimal('75.00'),
+ }),
+ );
+
+ await service.create(
+ orgId,
+ actorId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ {
+ vendorId: 'vendor-1',
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: 75,
+ },
+ );
+
+ expect(prisma.lease.findFirst).toHaveBeenCalledWith({
+ where: { orgId, apartmentId, status: 'active' },
+ orderBy: { startDate: 'desc' },
+ });
+ expect(prisma.invoice.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ orgId,
+ buildingId,
+ leaseId: 'lease-1',
+ lineItems: {
+ create: [
+ expect.objectContaining({
+ category: 'other',
+ description: 'Work order WO-000001: Leaking faucet',
+ }),
+ ],
+ },
+ }),
+ });
+ expect(prisma.workOrder.update).toHaveBeenCalledWith({
+ where: { id: 'wo-1' },
+ data: { tenantChargedAt: expect.any(Date) },
+ });
+ expect(timeline.emit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'work_order.tenant_charged',
+ targetId: 'wo-1',
+ }),
+ );
+ });
+
+ it('does not charge and does not throw when the apartment has no active lease', async () => {
+ const { service, prisma } = makeService({
+ lease: { findFirst: jest.fn().mockResolvedValue(null) },
+ });
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: new Prisma.Decimal('75.00'),
+ }),
+ );
+
+ await expect(
+ service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, {
+ vendorId: 'vendor-1',
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: 75,
+ }),
+ ).resolves.toBeDefined();
+
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ expect(prisma.workOrder.update).not.toHaveBeenCalled();
+ });
+
+ it('does not charge when chargeToTenant is false even though tenantChargeAmount is set', async () => {
+ const { service, prisma } = makeService();
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({
+ status: 'completed',
+ chargeToTenant: false,
+ tenantChargeAmount: new Prisma.Decimal('75.00'),
+ }),
+ );
+
+ await service.create(
+ orgId,
+ actorId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ {
+ vendorId: 'vendor-1',
+ status: 'completed',
+ },
+ );
+
+ expect(prisma.lease.findFirst).not.toHaveBeenCalled();
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('create — assignment notification (F5.1)', () => {
+ it('notifies the assignee when a work order is created with assignedUserId set', async () => {
+ const { service, prisma, notifications } = makeService();
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({ vendorId: null, assignedUserId: 'user-1', number: 7 }),
+ );
+
+ await service.create(
+ orgId,
+ actorId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ { assignedUserId: 'user-1' },
+ );
+
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'user-1',
+ type: 'work_order.assigned',
+ }),
+ );
+ });
+
+ it('does not notify when the work order is created with a vendor instead of an assignee', async () => {
+ const { service, prisma, notifications } = makeService();
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({ vendorId: 'vendor-1', assignedUserId: null }),
+ );
+
+ await service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, {
+ vendorId: 'vendor-1',
+ });
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+
+ it('does not notify when the actor assigns the work order to themself', async () => {
+ const { service, prisma, notifications } = makeService();
+ prisma.workOrder.create.mockResolvedValue(
+ workOrderRow({ vendorId: null, assignedUserId: actorId }),
+ );
+
+ await service.create(orgId, actorId, Role.ORG_ADMIN, maintenanceRequestId, {
+ assignedUserId: actorId,
+ });
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
+
describe('update', () => {
const existingRow = () => ({
...workOrderRow(),
@@ -543,6 +903,192 @@ describe('WorkOrdersService', () => {
});
});
+ describe('update — reassignment notification (F5.1)', () => {
+ const existingRow = () => ({
+ ...workOrderRow({ assignedUserId: 'user-old' }),
+ maintenanceRequest: { apartmentId, title: 'Leaking faucet' },
+ });
+
+ it('notifies the newly-assigned user on reassignment', async () => {
+ const { service, prisma, notifications } = makeService({
+ workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) },
+ });
+ prisma.workOrder.update.mockResolvedValue(
+ workOrderRow({ vendorId: null, assignedUserId: 'user-new' }),
+ );
+
+ await service.update(
+ orgId,
+ actorId,
+ callerId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ 'wo-1',
+ { assignedUserId: 'user-new' },
+ );
+
+ expect(notifications.enqueue).toHaveBeenCalledTimes(1);
+ expect(notifications.enqueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orgId,
+ userId: 'user-new',
+ type: 'work_order.assigned',
+ }),
+ );
+ });
+
+ it('does not notify when the assignee is unchanged', async () => {
+ const { service, prisma, notifications } = makeService({
+ workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) },
+ });
+ prisma.workOrder.update.mockResolvedValue(
+ workOrderRow({ assignedUserId: 'user-old', status: 'in_progress' }),
+ );
+
+ await service.update(
+ orgId,
+ actorId,
+ callerId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ 'wo-1',
+ { status: 'in_progress' },
+ );
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+
+ it('does not notify when the actor reassigns the work order to themself', async () => {
+ const { service, prisma, notifications } = makeService({
+ workOrder: { findFirst: jest.fn().mockResolvedValue(existingRow()) },
+ });
+ prisma.workOrder.update.mockResolvedValue(
+ workOrderRow({ vendorId: null, assignedUserId: actorId }),
+ );
+
+ await service.update(
+ orgId,
+ actorId,
+ callerId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ 'wo-1',
+ { assignedUserId: actorId },
+ );
+
+ expect(notifications.enqueue).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('update — tenant charge transition (F3.2)', () => {
+ const existingInProgressWithCharge = () => ({
+ ...workOrderRow({
+ status: 'in_progress',
+ chargeToTenant: true,
+ tenantChargeAmount: new Prisma.Decimal('120.00'),
+ }),
+ maintenanceRequest: { apartmentId, title: 'Leaking faucet' },
+ });
+
+ const activeLeaseRow = {
+ id: 'lease-1',
+ buildingId,
+ status: 'active',
+ endDate: new Date('2030-01-01T00:00:00.000Z'),
+ };
+
+ it('charges the active lease when a chargeToTenant work order transitions to completed', async () => {
+ const { service, prisma, timeline } = makeService({
+ workOrder: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue(existingInProgressWithCharge()),
+ update: jest.fn().mockResolvedValue(
+ workOrderRow({
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: new Prisma.Decimal('120.00'),
+ }),
+ ),
+ },
+ lease: { findFirst: jest.fn().mockResolvedValue(activeLeaseRow) },
+ });
+
+ await service.update(
+ orgId,
+ actorId,
+ callerId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ 'wo-1',
+ { status: 'completed' },
+ );
+
+ expect(prisma.lease.findFirst).toHaveBeenCalledWith({
+ where: { orgId, apartmentId, status: 'active' },
+ orderBy: { startDate: 'desc' },
+ });
+ expect(prisma.invoice.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ leaseId: 'lease-1',
+ buildingId,
+ lineItems: {
+ create: [
+ expect.objectContaining({
+ category: 'other',
+ description: 'Work order WO-000001: Leaking faucet',
+ }),
+ ],
+ },
+ }),
+ }),
+ );
+ // First call is the main status update; the second is the tenantChargedAt
+ // stamp inside chargeTenantIfDue's transaction.
+ expect(prisma.workOrder.update).toHaveBeenNthCalledWith(2, {
+ where: { id: 'wo-1' },
+ data: { tenantChargedAt: expect.any(Date) },
+ });
+ expect(timeline.emit).toHaveBeenCalledWith(
+ expect.objectContaining({ action: 'work_order.tenant_charged' }),
+ );
+ });
+
+ it('does not double-charge when re-completing an already-charged work order', async () => {
+ const { service, prisma } = makeService({
+ workOrder: {
+ findFirst: jest
+ .fn()
+ .mockResolvedValue(existingInProgressWithCharge()),
+ update: jest.fn().mockResolvedValue(
+ workOrderRow({
+ status: 'completed',
+ chargeToTenant: true,
+ tenantChargeAmount: new Prisma.Decimal('120.00'),
+ tenantChargedAt: new Date('2026-01-05T00:00:00.000Z'),
+ }),
+ ),
+ },
+ });
+
+ await service.update(
+ orgId,
+ actorId,
+ callerId,
+ Role.ORG_ADMIN,
+ maintenanceRequestId,
+ 'wo-1',
+ { status: 'completed' },
+ );
+
+ expect(prisma.lease.findFirst).not.toHaveBeenCalled();
+ expect(prisma.invoice.create).not.toHaveBeenCalled();
+ // Only the main status update — no second (tenantChargedAt-stamp) call.
+ expect(prisma.workOrder.update).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('remove', () => {
it('deletes the work order, reverts the apartment via onWorkOrderClosed, and emits work_order.deleted', async () => {
const { service, prisma, timeline, workOrderApartmentStatus } =
diff --git a/apps/api/src/modules/work-orders/work-orders.service.ts b/apps/api/src/modules/work-orders/work-orders.service.ts
index 1e8b1e17..c1b4642a 100644
--- a/apps/api/src/modules/work-orders/work-orders.service.ts
+++ b/apps/api/src/modules/work-orders/work-orders.service.ts
@@ -3,30 +3,141 @@ import {
ConflictException,
ForbiddenException,
Injectable,
+ Logger,
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 { NotificationsService } from '@/modules/notifications/notifications.service';
+import { LeaseStatusService } from '@/common/lease-status/lease-status.service';
import { WorkOrderApartmentStatusService } from './work-order-apartment-status.service';
import { Role } from '@/common/enums';
-import { WorkOrderResponse } from '@repo/contracts';
+import {
+ AssignedWorkOrderListResponse,
+ MaintenanceRequestStatus,
+ WorkOrderResponse,
+ formatWorkOrderNumber,
+} from '@repo/contracts';
import { formatWorkOrder } from './work-order-formatter';
import { CreateWorkOrderDto } from './dto/create-work-order.dto';
import { UpdateWorkOrderDto } from './dto/update-work-order.dto';
const OPEN_STATUSES = new Set(['scheduled', 'in_progress']);
const MAINTENANCE_ALLOWED_FIELDS = new Set(['status', 'resolutionNotes']);
+/** How long a tenant has to pay a work-order charge (F3.2, decision D2). */
+const TENANT_CHARGE_DUE_DAYS = 30;
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+/** Just what {@link WorkOrdersService.chargeTenantIfDue} needs from a freshly created/updated row. */
+type ChargeableWorkOrder = {
+ id: string;
+ number: number;
+ chargeToTenant: boolean;
+ tenantChargeAmount: Prisma.Decimal | null;
+ tenantChargedAt: Date | null;
+};
@Injectable()
export class WorkOrdersService {
+ private readonly logger = new Logger(WorkOrdersService.name);
+
constructor(
private readonly prisma: PrismaService,
private readonly buildingAccess: BuildingAccessService,
private readonly timeline: TimelineService,
+ private readonly notifications: NotificationsService,
+ private readonly leaseStatus: LeaseStatusService,
private readonly workOrderApartmentStatus: WorkOrderApartmentStatusService,
) {}
+ /**
+ * F3.2 (decision D2, opt-in/default OFF): when a work order is or becomes
+ * `completed` and is flagged `chargeToTenant` with a `tenantChargeAmount`,
+ * bill the apartment's currently active lease via a new one-line-item
+ * invoice, due in 30 days. Guarded by `tenantChargedAt` so re-completing an
+ * already-charged work order is a no-op. Never throws — a missing active
+ * lease is logged and skipped rather than blocking the status transition.
+ */
+ private async chargeTenantIfDue(
+ orgId: string,
+ actorId: string,
+ apartmentId: string,
+ requestTitle: string,
+ workOrder: ChargeableWorkOrder,
+ ): Promise {
+ if (
+ !workOrder.chargeToTenant ||
+ workOrder.tenantChargeAmount === null ||
+ workOrder.tenantChargedAt !== null
+ ) {
+ return;
+ }
+
+ const now = new Date();
+ const activeLease = await this.prisma.lease.findFirst({
+ where: { orgId, apartmentId, status: 'active' },
+ orderBy: { startDate: 'desc' },
+ });
+
+ if (
+ !activeLease ||
+ !this.leaseStatus.isEffectivelyActive(
+ { status: activeLease.status, endDate: activeLease.endDate },
+ now,
+ )
+ ) {
+ this.logger.warn(
+ `Work order ${workOrder.id} is chargeToTenant but apartment ${apartmentId} has no active lease — skipping tenant charge.`,
+ );
+ return;
+ }
+
+ const dueDate = new Date(
+ now.getTime() + TENANT_CHARGE_DUE_DAYS * MS_PER_DAY,
+ );
+ const numberLabel = formatWorkOrderNumber(workOrder.number);
+
+ const invoice = await this.prisma.$transaction(async (tx) => {
+ const created = await tx.invoice.create({
+ data: {
+ orgId,
+ buildingId: activeLease.buildingId,
+ leaseId: activeLease.id,
+ dueDate,
+ lineItems: {
+ create: [
+ {
+ category: 'other',
+ description: `Work order ${numberLabel}: ${requestTitle}`,
+ amount: workOrder.tenantChargeAmount,
+ },
+ ],
+ },
+ },
+ });
+ await tx.workOrder.update({
+ where: { id: workOrder.id },
+ data: { tenantChargedAt: now },
+ });
+ return created;
+ });
+
+ await this.timeline.emit({
+ orgId,
+ actorId,
+ action: 'work_order.tenant_charged',
+ targetType: 'WorkOrder',
+ targetId: workOrder.id,
+ metadata: {
+ invoiceId: invoice.id,
+ leaseId: activeLease.id,
+ amount: workOrder.tenantChargeAmount.toString(),
+ },
+ });
+ }
+
async findAllForRequest(
orgId: string,
callerId: string,
@@ -81,6 +192,58 @@ export class WorkOrdersService {
return { data: formatWorkOrder(workOrder) };
}
+ /**
+ * All work orders assigned to the caller across every maintenance request
+ * in the org (Sprint F2.2 "My work orders" data source). Scoped by
+ * assignedUserId = callerId, so no separate building-access check is
+ * needed — a maintenance user can only ever see their own assignments.
+ */
+ async findAssignedToCaller(
+ orgId: string,
+ callerId: string,
+ ): Promise {
+ const workOrders = await this.prisma.workOrder.findMany({
+ where: { orgId, assignedUserId: callerId },
+ include: {
+ maintenanceRequest: {
+ select: {
+ title: true,
+ status: true,
+ buildingId: true,
+ apartmentId: true,
+ apartment: {
+ select: {
+ unitNumber: true,
+ building: { select: { name: true } },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ // Active (scheduled/in_progress) work orders surface first; within each
+ // group the query's createdAt-desc order is preserved because
+ // Array#sort is a stable sort.
+ const activeRank = (status: string) => (OPEN_STATUSES.has(status) ? 0 : 1);
+ const sorted = [...workOrders].sort(
+ (a, b) => activeRank(a.status) - activeRank(b.status),
+ );
+
+ return {
+ data: sorted.map((w) => ({
+ ...formatWorkOrder(w),
+ requestTitle: w.maintenanceRequest.title,
+ requestStatus: w.maintenanceRequest.status as MaintenanceRequestStatus,
+ buildingId: w.maintenanceRequest.buildingId,
+ buildingName: w.maintenanceRequest.apartment.building.name,
+ apartmentId: w.maintenanceRequest.apartmentId,
+ apartmentUnit: w.maintenanceRequest.apartment.unitNumber,
+ })),
+ };
+ }
+
// ── CRUD (write) ──────────────────────────────────────────────────────────
async create(
@@ -98,7 +261,7 @@ export class WorkOrdersService {
const request = await this.prisma.maintenanceRequest.findFirst({
where: { id: maintenanceRequestId, orgId },
- select: { apartmentId: true },
+ select: { apartmentId: true, title: true },
});
if (!request) {
throw new NotFoundException('Maintenance request not found.');
@@ -110,16 +273,30 @@ export class WorkOrdersService {
);
}
- const workOrder = await this.prisma.workOrder.create({
- data: {
- orgId,
- maintenanceRequestId,
- vendorId: dto.vendorId,
- assignedUserId: dto.assignedUserId,
- status: dto.status,
- cost: dto.cost,
- resolutionNotes: dto.resolutionNotes,
- },
+ // Assign the next org-scoped sequential number under a per-org advisory lock
+ // so concurrent creates cannot collide on a number. The @@unique([orgId,
+ // number]) index is the backstop if two writers ever race the lock.
+ const workOrder = await this.prisma.$transaction(async (tx) => {
+ await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`work_order_number:${orgId}`}))`;
+ const { _max } = await tx.workOrder.aggregate({
+ where: { orgId },
+ _max: { number: true },
+ });
+ const nextNumber = (_max.number ?? 0) + 1;
+ return tx.workOrder.create({
+ data: {
+ orgId,
+ number: nextNumber,
+ maintenanceRequestId,
+ vendorId: dto.vendorId,
+ assignedUserId: dto.assignedUserId,
+ status: dto.status,
+ cost: dto.cost,
+ resolutionNotes: dto.resolutionNotes,
+ chargeToTenant: dto.chargeToTenant,
+ tenantChargeAmount: dto.tenantChargeAmount,
+ },
+ });
});
await this.workOrderApartmentStatus.onWorkOrderOpened(request.apartmentId);
@@ -137,6 +314,36 @@ export class WorkOrdersService {
},
});
+ // F5.1: notify the assignee — skip silently if there is none (vendor-
+ // assigned work orders have no in-app user to notify) or if the actor
+ // assigned it to themself.
+ if (workOrder.assignedUserId && workOrder.assignedUserId !== actorId) {
+ await this.notifications.enqueue({
+ orgId,
+ userId: workOrder.assignedUserId,
+ type: 'work_order.assigned',
+ title: 'You were assigned a work order',
+ body: `You've been assigned to work order ${formatWorkOrderNumber(workOrder.number)}: ${request.title}`,
+ data: {
+ workOrderId: workOrder.id,
+ maintenanceRequestId,
+ numberLabel: formatWorkOrderNumber(workOrder.number),
+ },
+ });
+ }
+ // TODO F5.1 follow-up: role-fanout notifications (e.g. maintenance-created
+ // -> supervisors) need a shared org-members-by-role helper — out of scope here.
+
+ if (workOrder.status === 'completed') {
+ await this.chargeTenantIfDue(
+ orgId,
+ actorId,
+ request.apartmentId,
+ request.title,
+ workOrder,
+ );
+ }
+
return { data: formatWorkOrder(workOrder) };
}
@@ -151,7 +358,9 @@ export class WorkOrdersService {
): Promise<{ data: WorkOrderResponse }> {
const existing = await this.prisma.workOrder.findFirst({
where: { id: workOrderId, orgId, maintenanceRequestId },
- include: { maintenanceRequest: { select: { apartmentId: true } } },
+ include: {
+ maintenanceRequest: { select: { apartmentId: true, title: true } },
+ },
});
if (!existing) {
throw new NotFoundException('Work order not found.');
@@ -219,6 +428,19 @@ export class WorkOrdersService {
}
const reassigning = vendorProvided || assigneeProvided;
+ const completingNow =
+ dto.status === 'completed' && existing.status !== 'completed';
+
+ // F5.1: only notify when this update is a genuine reassignment TO a new
+ // user (not just re-affirming the existing assignee), and never notify
+ // the actor about assigning it to themself.
+ const newAssignee =
+ assigneeProvided &&
+ assignedUserId &&
+ assignedUserId !== existing.assignedUserId &&
+ assignedUserId !== actorId
+ ? assignedUserId
+ : null;
const workOrder = await this.prisma.workOrder.update({
where: { id: workOrderId },
@@ -230,6 +452,12 @@ export class WorkOrdersService {
resolutionNotes: dto.resolutionNotes,
}),
...(completedAt !== undefined && { completedAt }),
+ ...(dto.chargeToTenant !== undefined && {
+ chargeToTenant: dto.chargeToTenant,
+ }),
+ ...(dto.tenantChargeAmount !== undefined && {
+ tenantChargeAmount: dto.tenantChargeAmount,
+ }),
},
});
@@ -252,6 +480,31 @@ export class WorkOrdersService {
metadata: { changes: Object.keys(dto) },
});
+ if (newAssignee) {
+ await this.notifications.enqueue({
+ orgId,
+ userId: newAssignee,
+ type: 'work_order.assigned',
+ title: 'You were assigned a work order',
+ body: `You've been assigned to work order ${formatWorkOrderNumber(workOrder.number)}: ${existing.maintenanceRequest.title}`,
+ data: {
+ workOrderId: workOrder.id,
+ maintenanceRequestId,
+ numberLabel: formatWorkOrderNumber(workOrder.number),
+ },
+ });
+ }
+
+ if (completingNow) {
+ await this.chargeTenantIfDue(
+ orgId,
+ actorId,
+ existing.maintenanceRequest.apartmentId,
+ existing.maintenanceRequest.title,
+ workOrder,
+ );
+ }
+
return { data: formatWorkOrder(workOrder) };
}
diff --git a/apps/web/src/app/[lang]/dashboard/billing/page.tsx b/apps/web/src/app/[lang]/dashboard/billing/page.tsx
index bc2d1d83..41a8db17 100644
--- a/apps/web/src/app/[lang]/dashboard/billing/page.tsx
+++ b/apps/web/src/app/[lang]/dashboard/billing/page.tsx
@@ -1,5 +1,6 @@
import { requireSession } from '@/auth/guards';
import { normalizeRole } from '@/auth/roles';
+import { canAccess } from '@/auth/permissions';
import { redirect } from 'next/navigation';
import { isLocale } from '@/i18n/config';
import { getDictionary } from '@/i18n/get-dictionary';
@@ -14,7 +15,7 @@ export default async function DashboardBillingPage({
const locale = isLocale(lang) ? lang : 'en';
const session = await requireSession({ locale });
const role = normalizeRole(session.role ?? session.user?.role);
- if (role !== 'org_admin') redirect(`/${locale}/dashboard`);
+ if (!canAccess(role, 'billing')) redirect(`/${locale}/dashboard`);
const dict = await getDictionary(locale);
return ;
}
diff --git a/apps/web/src/app/[lang]/dashboard/layout.tsx b/apps/web/src/app/[lang]/dashboard/layout.tsx
index 91067d63..968cefbf 100644
--- a/apps/web/src/app/[lang]/dashboard/layout.tsx
+++ b/apps/web/src/app/[lang]/dashboard/layout.tsx
@@ -1,4 +1,6 @@
+import { redirect } from 'next/navigation';
import { requireSession, requireActiveOrg } from '@/auth/guards';
+import { normalizeRole } from '@/auth/roles';
import { getDictionary } from '@/i18n/get-dictionary';
import { isLocale } from '@/i18n/config';
import { serverEnv } from '@/lib/env';
@@ -32,6 +34,11 @@ export default async function DashboardLayout({
const locale = isLocale(lang) ? lang : 'en';
const session = await requireSession({ locale });
+ // Tenants belong to the resident portal, never the admin dashboard shell.
+ // Authoritative in-RSC counterpart to the edge redirect in proxy.ts.
+ const sessionRole = normalizeRole(session.role ?? session.user?.role);
+ if (sessionRole === 'tenant') redirect(`/${locale}/portal`);
+
// Authoritative paywall for the WHOLE dashboard subtree (incl. every
// sub-page). "Has paid?" is org.status === ACTIVE in the DB — token-
// independent, so it correctly bounces a lapsed (PAST_DUE/CANCELED) or
@@ -49,7 +56,11 @@ export default async function DashboardLayout({
const userName = session.user?.name ?? session.user?.email ?? '';
return (
-
+ // h-screen + overflow-hidden turns this into a fixed app-shell: the sidebar
+ // stays viewport-height and the main column scrolls on its own, so a long
+ // page (e.g. the Activity feed) can never push the sidebar's Sign-out
+ // control below the fold. See dashboard-sidebar for the internal nav scroll.
+
- {children}
+ {children}
);
diff --git a/apps/web/src/app/[lang]/dashboard/page.tsx b/apps/web/src/app/[lang]/dashboard/page.tsx
index a328262f..1eb6f9bd 100644
--- a/apps/web/src/app/[lang]/dashboard/page.tsx
+++ b/apps/web/src/app/[lang]/dashboard/page.tsx
@@ -1,3 +1,4 @@
+import { redirect } from 'next/navigation';
import { requireSession, requireActiveOrg } from '@/auth/guards';
import { normalizeRole } from '@/auth/roles';
import { isLocale } from '@/i18n/config';
@@ -7,7 +8,6 @@ import { SessionRefresher } from '@/components/auth/session-refresher';
import { OrgAdminDashboard } from '@/components/dashboard/org-admin-dashboard';
import { FinanceDashboard } from '@/components/dashboard/finance-dashboard';
import { StaffDashboard } from '@/components/dashboard/staff-dashboard';
-import { TenantDashboard } from '@/components/dashboard/tenant-dashboard';
import { DefaultDashboard } from '@/components/dashboard/default-dashboard';
import type { MeResponse } from '@/types/api';
@@ -75,8 +75,11 @@ export default async function DashboardPage({
if (role === 'supervisor' || role === 'maintenance') {
return ;
}
+ // Tenants belong to the resident portal, never the admin dashboard — the
+ // dashboard layout + proxy already redirect them. This is the last-resort
+ // guard so a tenant can never render an admin dashboard body.
if (role === 'tenant') {
- return ;
+ redirect(`/${locale}/portal`);
}
return (
diff --git a/apps/web/src/app/[lang]/dashboard/profile/page.tsx b/apps/web/src/app/[lang]/dashboard/profile/page.tsx
new file mode 100644
index 00000000..d1d5224d
--- /dev/null
+++ b/apps/web/src/app/[lang]/dashboard/profile/page.tsx
@@ -0,0 +1,111 @@
+import { UserIcon, ExternalLinkIcon } from 'lucide-react';
+
+import { requireSession } from '@/auth/guards';
+import { normalizeRole } from '@/auth/roles';
+import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
+import { serverEnv } from '@/lib/env';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import type { MeResponse } from '@/types/api';
+
+async function fetchMe(accessToken: string): Promise {
+ try {
+ const res = await fetch(`${serverEnv.API_URL}/me`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ cache: 'no-store',
+ });
+ if (!res.ok) return null;
+ const json = (await res.json()) as MeResponse | { data: MeResponse };
+ return 'data' in json ? json.data : json;
+ } catch {
+ return null;
+ }
+}
+
+export default async function ProfilePage({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ const locale = isLocale(lang) ? lang : 'en';
+ const dict = await getDictionary(locale);
+ const t = dict.profile;
+
+ // Any authenticated role may view their own profile — no role gate here.
+ // proxy.ts intentionally excludes /dashboard/profile from its permission
+ // matrix, so this requireSession() call is the sole guard for this page.
+ const session = await requireSession({ locale });
+
+ let me: MeResponse | null = null;
+ if (session.accessToken) {
+ me = await fetchMe(session.accessToken);
+ }
+
+ const name = me?.user?.fullName ?? session.user?.name ?? '—';
+ const email = me?.user?.email ?? session.user?.email ?? '—';
+ const role = normalizeRole(me?.role ?? session.role ?? session.user?.role);
+ const roleLabel = role ? dict.auth.roles[role] : '—';
+ const orgName = me?.org?.name ?? '—';
+ const orgStatus = me?.org?.status;
+ const orgStatusLabel = orgStatus ? dict.billing.status[orgStatus] : null;
+
+ const accountUrl = `${serverEnv.KEYCLOAK_BASE}/realms/${serverEnv.KEYCLOAK_REALM}/account`;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx b/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx
index ed05d997..2d1967b5 100644
--- a/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx
+++ b/apps/web/src/app/[lang]/dashboard/tasks/[id]/page.tsx
@@ -6,6 +6,28 @@ import { isLocale } from '@/i18n/config';
import { getDictionary } from '@/i18n/get-dictionary';
import { MaintenanceRequestDetailPage } from '@/components/dashboard/maintenance-request-detail-page';
+/**
+ * The NextAuth session object does not expose the Keycloak `sub` (the JWT
+ * strategy's session callback only surfaces name/email/image by default, and
+ * this app's own session callback — auth/auth.ts — doesn't add it either).
+ * `session.accessToken` is the raw Keycloak-issued JWT though, and its `sub`
+ * claim is exactly the id `WorkOrder.assignedUserId` is compared against
+ * (per repo convention: users are referenced by their Keycloak `sub`). Decode
+ * it here rather than widening the shared session type for one call site.
+ */
+function decodeAccessTokenSub(token: string | undefined): string | undefined {
+ if (!token) return undefined;
+ try {
+ const payload = token.split('.')[1];
+ if (!payload) return undefined;
+ const decoded = Buffer.from(payload, 'base64url').toString('utf8');
+ const parsed = JSON.parse(decoded) as { sub?: string };
+ return parsed.sub;
+ } catch {
+ return undefined;
+ }
+}
+
export default async function MaintenanceRequestDetailPageRoute({
params,
}: {
@@ -26,12 +48,15 @@ export default async function MaintenanceRequestDetailPageRoute({
// only update status/resolutionNotes on a Work Order assigned to them
// (enforced in WorkOrdersService) — neither is the plain 'tasks'
// canWrite() value, which is 'full' for maintenance at the page level.
+ const callerSub = decodeAccessTokenSub(session.accessToken);
+
return (
);
diff --git a/apps/web/src/app/[lang]/dashboard/timeline/page.tsx b/apps/web/src/app/[lang]/dashboard/timeline/page.tsx
index 7a5b8730..ab89c0a0 100644
--- a/apps/web/src/app/[lang]/dashboard/timeline/page.tsx
+++ b/apps/web/src/app/[lang]/dashboard/timeline/page.tsx
@@ -4,6 +4,7 @@ import { requireSession } from '@/auth/guards';
import { normalizeRole } from '@/auth/roles';
import { canAccess } from '@/auth/permissions';
import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
import { TimelineFeed } from '@/components/dashboard/timeline-feed';
export default async function TimelinePageRoute({
@@ -22,10 +23,12 @@ export default async function TimelinePageRoute({
redirect(`/${locale}/dashboard`);
}
+ const dict = await getDictionary(locale);
+
return (
-
Activity
-
+
{dict.nav.timeline}
+
);
}
diff --git a/apps/web/src/app/[lang]/portal/available-units/page.tsx b/apps/web/src/app/[lang]/portal/available-units/page.tsx
new file mode 100644
index 00000000..d25a6dbe
--- /dev/null
+++ b/apps/web/src/app/[lang]/portal/available-units/page.tsx
@@ -0,0 +1,15 @@
+import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
+import { PortalAvailableUnits } from '@/components/portal/portal-available-units';
+
+export default async function PortalAvailableUnitsPage({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ const locale = isLocale(lang) ? lang : 'en';
+ const dict = await getDictionary(locale);
+
+ return ;
+}
diff --git a/apps/web/src/app/[lang]/portal/layout.tsx b/apps/web/src/app/[lang]/portal/layout.tsx
new file mode 100644
index 00000000..d72c20e9
--- /dev/null
+++ b/apps/web/src/app/[lang]/portal/layout.tsx
@@ -0,0 +1,41 @@
+import { redirect } from 'next/navigation';
+import type { ReactNode } from 'react';
+
+import { requireSession } from '@/auth/guards';
+import { dashboardPathForRole, normalizeRole } from '@/auth/roles';
+import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
+import { PortalShell } from '@/components/portal/portal-shell';
+
+/**
+ * Tenant portal shell (TP2). A visually distinct, light/warm, mobile-first
+ * resident experience — NOT the admin dashboard chrome. Only the `tenant`
+ * role belongs here; every other role is bounced back to their dashboard.
+ * Routing-isolation (roles/permissions/middleware) is owned by the
+ * orchestrator, so this guard is intentionally self-contained here.
+ */
+export default async function PortalLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ 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 (role !== 'tenant') {
+ redirect(dashboardPathForRole(role ?? 'org_admin', locale));
+ }
+
+ const dict = await getDictionary(locale);
+ const userName = session.user?.name ?? session.user?.email ?? '';
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/app/[lang]/portal/page.tsx b/apps/web/src/app/[lang]/portal/page.tsx
new file mode 100644
index 00000000..72d6b09e
--- /dev/null
+++ b/apps/web/src/app/[lang]/portal/page.tsx
@@ -0,0 +1,40 @@
+import { requireSession } from '@/auth/guards';
+import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
+import { serverEnv } from '@/lib/env';
+import { PortalHome } from '@/components/portal/portal-home';
+import type { MeResponse } from '@/types/api';
+
+async function fetchMe(accessToken: string): Promise {
+ try {
+ const res = await fetch(`${serverEnv.API_URL}/me`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ cache: 'no-store',
+ });
+ if (!res.ok) return null;
+ const json = (await res.json()) as MeResponse | { data: MeResponse };
+ return 'data' in json ? json.data : json;
+ } catch {
+ return null;
+ }
+}
+
+export default async function PortalHomePage({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ const locale = isLocale(lang) ? lang : 'en';
+ const dict = await getDictionary(locale);
+
+ const session = await requireSession({ locale });
+ const userName = session.user?.name ?? session.user?.email ?? '';
+
+ let me: MeResponse | null = null;
+ if (session.accessToken) {
+ me = await fetchMe(session.accessToken);
+ }
+
+ return ;
+}
diff --git a/apps/web/src/app/[lang]/portal/profile/page.tsx b/apps/web/src/app/[lang]/portal/profile/page.tsx
new file mode 100644
index 00000000..ac358360
--- /dev/null
+++ b/apps/web/src/app/[lang]/portal/profile/page.tsx
@@ -0,0 +1,158 @@
+import { UserIcon, ExternalLinkIcon, KeyRoundIcon } from 'lucide-react';
+
+import { requireSession } from '@/auth/guards';
+import { normalizeRole } from '@/auth/roles';
+import { isLocale } from '@/i18n/config';
+import { getDictionary } from '@/i18n/get-dictionary';
+import { serverEnv } from '@/lib/env';
+import { getPortalDict } from '@/components/portal/portal-dict';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import type { MeResponse } from '@/types/api';
+
+async function fetchMe(accessToken: string): Promise {
+ try {
+ const res = await fetch(`${serverEnv.API_URL}/me`, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ cache: 'no-store',
+ });
+ if (!res.ok) return null;
+ const json = (await res.json()) as MeResponse | { data: MeResponse };
+ return 'data' in json ? json.data : json;
+ } catch {
+ return null;
+ }
+}
+
+export default async function PortalProfilePage({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ const locale = isLocale(lang) ? lang : 'en';
+ const dict = await getDictionary(locale);
+ // Shared profile labels (same as the admin profile page); the extra
+ // change-password link copy lives in the portal namespace.
+ const t = dict.profile;
+ const tp = getPortalDict(dict, locale).profile;
+
+ const session = await requireSession({ locale });
+
+ let me: MeResponse | null = null;
+ if (session.accessToken) {
+ me = await fetchMe(session.accessToken);
+ }
+
+ const name = me?.user?.fullName ?? session.user?.name ?? '—';
+ const email = me?.user?.email ?? session.user?.email ?? '—';
+ const role = normalizeRole(me?.role ?? session.role ?? session.user?.role);
+ const roleLabel = role ? dict.auth.roles[role] : '—';
+ const orgName = me?.org?.name ?? '—';
+
+ const accountUrl = `${serverEnv.KEYCLOAK_BASE}/realms/${serverEnv.KEYCLOAK_REALM}/account`;
+
+ return (
+