Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { VendorsModule } from '@/modules/vendors/vendors.module';
import { MaintenanceRequestsModule } from '@/modules/maintenance-requests/maintenance-requests.module';
import { WorkOrdersModule } from '@/modules/work-orders/work-orders.module';
import { ExpensesModule } from '@/modules/expenses/expenses.module';
import { InvoicesModule } from '@/modules/invoices/invoices.module';
import { InvoicePaymentsModule } from '@/modules/invoice-payments/invoice-payments.module';

@Module({
imports: [
Expand Down Expand Up @@ -76,6 +78,8 @@ import { ExpensesModule } from '@/modules/expenses/expenses.module';
MaintenanceRequestsModule,
WorkOrdersModule,
ExpensesModule,
InvoicesModule,
InvoicePaymentsModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
Expand Down
109 changes: 109 additions & 0 deletions apps/api/src/common/invoice-summary/compute-invoice-summary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { computeInvoiceSummary } from './compute-invoice-summary';

describe('computeInvoiceSummary', () => {
const dueDate = new Date('2026-02-01T00:00:00.000Z');
const beforeDue = new Date('2026-01-15T00:00:00.000Z');
const afterDue = new Date('2026-02-15T00:00:00.000Z');

it('is "open" when there are no payments and the due date has not passed', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[],
dueDate,
beforeDue,
);

expect(result).toEqual({ totalAmount: 1000, paidAmount: 0, status: 'open' });
});

it('is "partially_paid" when some but not all of the total has been paid before the due date', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[{ amount: 400 }],
dueDate,
beforeDue,
);

expect(result).toEqual({
totalAmount: 1000,
paidAmount: 400,
status: 'partially_paid',
});
});

it('is "paid" when payments exactly match the total', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[{ amount: 1000 }],
dueDate,
beforeDue,
);

expect(result.status).toBe('paid');
});

it('is "paid" when payments exceed the total (overpayment)', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[{ amount: 1200 }],
dueDate,
beforeDue,
);

expect(result).toEqual({
totalAmount: 1000,
paidAmount: 1200,
status: 'paid',
});
});

it('is "overdue" when unpaid and the due date has passed', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[],
dueDate,
afterDue,
);

expect(result.status).toBe('overdue');
});

it('is "overdue" when partially paid and the due date has passed', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[{ amount: 300 }],
dueDate,
afterDue,
);

expect(result.status).toBe('overdue');
});

it('is "paid", not "overdue", when fully paid even past the due date (overdue-vs-paid precedence)', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }],
[{ amount: 1000 }],
dueDate,
afterDue,
);

expect(result.status).toBe('paid');
});

it('sums multiple line items into the total', () => {
const result = computeInvoiceSummary(
[{ amount: 1000 }, { amount: 50 }, { amount: 25 }],
[],
dueDate,
beforeDue,
);

expect(result.totalAmount).toBe(1075);
});

it('treats an empty line-item list as a zero total', () => {
const result = computeInvoiceSummary([], [], dueDate, beforeDue);

expect(result.totalAmount).toBe(0);
});
});
39 changes: 39 additions & 0 deletions apps/api/src/common/invoice-summary/compute-invoice-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { InvoiceStatus } from '@repo/contracts';

export type InvoiceSummaryLineItem = { amount: number };
export type InvoiceSummaryPayment = { amount: number };

export type InvoiceSummary = {
totalAmount: number;
paidAmount: number;
status: InvoiceStatus;
};

/**
* Pure, dependency-free derivation of an Invoice's total/paid amounts and
* status from its line items and payments. No Prisma, no I/O — the single
* source of truth for these derived fields, consumed by both InvoicesService
* and InvoicePaymentsService so they can never disagree.
*/
export function computeInvoiceSummary(
lineItems: InvoiceSummaryLineItem[],
payments: InvoiceSummaryPayment[],
dueDate: Date,
now: Date,
): InvoiceSummary {
const totalAmount = lineItems.reduce((sum, item) => sum + item.amount, 0);
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0);

let status: InvoiceStatus;
if (paidAmount >= totalAmount) {
status = 'paid';
} else if (dueDate < now) {
status = 'overdue';
} else if (paidAmount > 0) {
status = 'partially_paid';
} else {
status = 'open';
}

return { totalAmount, paidAmount, status };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { InvoicePaymentMethod } from '@repo/db';

export class CreateInvoicePaymentDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
invoiceId: string;

@ApiProperty()
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount: number;

@ApiProperty({ enum: InvoicePaymentMethod })
@IsEnum(InvoicePaymentMethod)
method: InvoicePaymentMethod;

@ApiProperty()
@IsDateString()
paidAt: string;

@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { InvoicePaymentsService } from './invoice-payments.service';
import { OrgScopeService } from '@/common/org-scope/org-scope.service';
import { CurrentUser, Roles } from '@/common/decorators';
import { AuthenticatedUser } from '@/common/types/authenticated-user.type';
import { Role } from '@/common/enums';
import { CreateInvoicePaymentDto } from './dto/create-invoice-payment.dto';

@ApiTags('invoice-payments')
@ApiBearerAuth()
@Controller('invoice-payments')
export class InvoicePaymentsController {
constructor(
private readonly invoicePaymentsService: InvoicePaymentsService,
private readonly orgScope: OrgScopeService,
) {}

@Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR)
@Get()
async getInvoicePayments(
@CurrentUser() user: AuthenticatedUser,
@Query('invoiceId') invoiceId?: string,
) {
const { orgId, role } = await this.orgScope.resolveForCaller(user);
return this.invoicePaymentsService.findAll(
orgId,
user.sub,
role,
invoiceId,
);
}

@Roles(Role.ORG_ADMIN, Role.FINANCE, Role.SUPERVISOR)
@Get(':id')
async getInvoicePayment(
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
const { orgId, role } = await this.orgScope.resolveForCaller(user);
return this.invoicePaymentsService.findOne(orgId, user.sub, role, id);
}

@Roles(Role.ORG_ADMIN, Role.FINANCE)
@Post()
async createInvoicePayment(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateInvoicePaymentDto,
) {
const { orgId, role } = await this.orgScope.resolveForCaller(user);
return this.invoicePaymentsService.create(orgId, user.sub, role, dto);
}

@Roles(Role.ORG_ADMIN, Role.FINANCE)
@Delete(':id')
async deleteInvoicePayment(
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
const { orgId, role } = await this.orgScope.resolveForCaller(user);
return this.invoicePaymentsService.remove(orgId, user.sub, role, id);
}
}
10 changes: 10 additions & 0 deletions apps/api/src/modules/invoice-payments/invoice-payments.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InvoicePaymentsController } from './invoice-payments.controller';
import { InvoicePaymentsService } from './invoice-payments.service';

@Module({
controllers: [InvoicePaymentsController],
providers: [InvoicePaymentsService],
exports: [InvoicePaymentsService],
})
export class InvoicePaymentsModule {}
Loading
Loading