Add payable filter to sales invoices and enhance invoice status handling

- Introduced a new `payable` filter in the `ListSalesInvoicesFilters` and `ListSalesInvoicesQuery` types to allow querying of invoices based on their payable status.
- Updated the `SalesInvoicesRepository` to incorporate logic for filtering invoices that are payable, checking both status and balance.
- Enhanced the `SalesInvoicesService` to support the new `payable` filter in query handling.
- Modified the `SalesInvoiceDto` to include the `payable` property for better API response representation.
- Added unit tests to validate the new filter functionality and ensure proper handling of invoice statuses during updates.
- Updated e2e tests to cover scenarios involving the new payable filter and status transitions for invoices.
This commit is contained in:
shancheas
2026-08-31 15:15:06 +07:00
parent afed5ff0f5
commit 5579cf6566
11 changed files with 141 additions and 9 deletions
@@ -1,8 +1,9 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
@@ -230,6 +231,12 @@ export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
payable?: boolean;
}
export class SalesInvoiceDto {
@@ -94,6 +94,7 @@ export type ListSalesInvoicesFilters = {
readonly salesOrderId?: string;
readonly packingSlipId?: string;
readonly search?: string;
readonly payable?: boolean;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
import { and, asc, count, eq, gt, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import {
catalogRelationFromMap,
@@ -31,7 +31,10 @@ import {
} from '../../../database/sales-payments-table';
import { DocumentCodeService } from '../shared/document-code.service';
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
import { SALES_INVOICE_STATUSES } from '../shared/sales-fields';
import {
PAYABLE_INVOICE_STATUSES,
SALES_INVOICE_STATUSES,
} from '../shared/sales-fields';
import type {
CreateSalesInvoiceInput,
ListSalesInvoicesFilters,
@@ -393,6 +396,10 @@ export class SalesInvoicesRepository {
parts.push(search);
}
}
if (filters.payable) {
parts.push(inArray(salesInvoices.status, [...PAYABLE_INVOICE_STATUSES]));
parts.push(gt(salesInvoices.balance, '0'));
}
if (parts.length === 0) {
return undefined;
}
@@ -191,6 +191,36 @@ describe('SalesInvoicesService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('updateStatus allows draft to processed even when sourced from a sales order', async () => {
repository.findById.mockResolvedValue({
...sample,
salesOrderId: 'so-1',
});
repository.updateStatus.mockResolvedValue({
...sample,
salesOrderId: 'so-1',
status: Status.create('processed', SALES_INVOICE_STATUSES),
});
const result = await service.updateStatus('si-1', 'processed', 'user-1');
expect(repository.updateStatus).toHaveBeenCalledWith(
'si-1',
expect.objectContaining({ value: 'processed' }),
'user-1',
);
expect(result.status).toBe('processed');
});
it('updateStatus rejects processed to completed as a user transition', async () => {
repository.findById.mockResolvedValue({
...sample,
status: Status.create('processed', SALES_INVOICE_STATUSES),
});
await expect(
service.updateStatus('si-1', 'completed', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.updateStatus).not.toHaveBeenCalled();
});
it('applyPaymentEffects sets partial when underpaid', async () => {
repository.findById.mockResolvedValue(sample);
repository.computeTotals.mockResolvedValue({
@@ -57,6 +57,7 @@ export type ListSalesInvoicesQuery = {
readonly salesOrderId?: string;
readonly packingSlipId?: string;
readonly search?: string;
readonly payable?: boolean;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
@@ -103,6 +104,7 @@ export class SalesInvoicesService {
salesOrderId: query.salesOrderId,
packingSlipId: query.packingSlipId,
search: query.search,
payable: query.payable,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
@@ -215,7 +217,6 @@ export class SalesInvoicesService {
}
const status = this.assertStatus(statusRaw);
if (
current.salesOrderId ||
!isAllowedStatusTransition(
current.status.value,
status.value,
@@ -67,7 +67,11 @@ describe('SalesPaymentsService', () => {
beforeEach(async () => {
jest.clearAllMocks();
salesInvoicesService.findById.mockResolvedValue({ id: 'si-1' });
salesInvoicesService.findById.mockResolvedValue({
id: 'si-1',
status: 'processed',
balance: '25000.0000',
});
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
SalesPaymentsService,
@@ -92,6 +96,38 @@ describe('SalesPaymentsService', () => {
expect(arg.invoices[0]?.amount.value).toBe('10000.0000');
});
it('create rejects a draft invoice allocation', async () => {
salesInvoicesService.findById.mockResolvedValue({
id: 'si-1',
status: 'draft',
balance: '25000.0000',
});
await expect(
service.create({
date: '2026-08-24T10:00:00+07:00',
invoices: [{ invoiceId: 'si-1', amount: '10000' }],
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('create rejects an invoice with no remaining balance', async () => {
salesInvoicesService.findById.mockResolvedValue({
id: 'si-1',
status: 'processed',
balance: '0.0000',
});
await expect(
service.create({
date: '2026-08-24T10:00:00+07:00',
invoices: [{ invoiceId: 'si-1', amount: '10000' }],
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('update rejects status on PATCH', async () => {
await expect(
service.update('sp-1', { status: 'approved', userId: 'user-1' }),
@@ -22,6 +22,7 @@ import {
isValidImageUrl,
isAllowedStatusTransition,
parseCsvRecord,
PAYABLE_INVOICE_STATUSES,
SALES_PAYMENT_STATUSES,
SALES_PAYMENT_USER_TRANSITIONS,
} from '../shared/sales-fields';
@@ -341,7 +342,8 @@ export class SalesPaymentsService {
}
const result: SalesPaymentAllocationInput[] = [];
for (const line of lines) {
await this.salesInvoicesService.findById(line.invoiceId);
const invoice = await this.salesInvoicesService.findById(line.invoiceId);
this.assertInvoicePayable(invoice);
const amount = this.parseDecimal(line.amount);
if (!amount.isPositive()) {
throw new BadRequestException('Invalid amount');
@@ -351,6 +353,28 @@ export class SalesPaymentsService {
return result;
}
private assertInvoicePayable(invoice: {
status?: string;
balance?: string;
}): void {
if (
!invoice.status ||
!(PAYABLE_INVOICE_STATUSES as readonly string[]).includes(invoice.status)
) {
throw new BadRequestException('Invoice is not payable');
}
try {
if (!Decimal.create(invoice.balance ?? '0').isPositive()) {
throw new BadRequestException('Invoice is not payable');
}
} catch (error) {
if (error instanceof InvalidDecimalError) {
throw new BadRequestException('Invoice is not payable');
}
throw error;
}
}
private assertImage(image: SalesImageBody): SalesPaymentImageInput {
if (!isValidImageUrl(image.url)) {
throw new BadRequestException('Invalid image URL');
@@ -77,14 +77,14 @@ describe('sales fields', () => {
).toBe(false);
});
it('limits invoice user transitions to cancelled', () => {
it('allows invoice user transitions from draft to processed or cancelled', () => {
expect(
isAllowedStatusTransition(
'draft',
'processed',
SALES_INVOICE_USER_TRANSITIONS,
),
).toBe(false);
).toBe(true);
expect(
isAllowedStatusTransition(
'draft',
@@ -92,6 +92,13 @@ describe('sales fields', () => {
SALES_INVOICE_USER_TRANSITIONS,
),
).toBe(true);
expect(
isAllowedStatusTransition(
'processed',
'completed',
SALES_INVOICE_USER_TRANSITIONS,
),
).toBe(false);
});
it('allows payment pending to approved, rejected, or draft', () => {
+3 -1
View File
@@ -69,13 +69,15 @@ export const PACKING_SLIP_USER_TRANSITIONS: Record<string, readonly string[]> =
export const SALES_INVOICE_USER_TRANSITIONS: Record<string, readonly string[]> =
{
draft: ['cancelled'],
draft: ['processed', 'cancelled'],
processed: ['cancelled'],
partial: ['cancelled'],
completed: ['cancelled'],
cancelled: [],
};
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
export const SALES_PAYMENT_USER_TRANSITIONS: Record<string, readonly string[]> =
{
draft: ['pending'],
+6
View File
@@ -181,6 +181,12 @@ describe('Sales invoices (e2e)', () => {
.send({ status: 'processed' })
.expect(400);
await request(app.getHttpServer())
.patch(`/sales-invoices/${id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'processed' })
.expect(200);
await request(app.getHttpServer())
.delete(`/sales-invoices/${id}`)
.set('Authorization', `Bearer ${token}`)
+11
View File
@@ -147,6 +147,11 @@ describe('Sales payments (e2e)', () => {
})
.expect(201);
invoiceId = (invoice.body as { id: string }).id;
await request(app.getHttpServer())
.patch(`/sales-invoices/${invoiceId}/status`)
.set(auth)
.send({ status: 'processed' })
.expect(200);
});
afterAll(async () => {
@@ -182,6 +187,12 @@ describe('Sales payments (e2e)', () => {
.send({ status: 'approved' })
.expect(400);
await request(app.getHttpServer())
.patch(`/sales-payments/${id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'pending' })
.expect(200);
await request(app.getHttpServer())
.patch(`/sales-payments/${id}/status`)
.set('Authorization', `Bearer ${token}`)