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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
ArrayNotEmpty, ArrayNotEmpty,
IsArray, IsArray,
IsBoolean,
IsIn, IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
@@ -230,6 +231,12 @@ export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
search?: string; search?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
payable?: boolean;
} }
export class SalesInvoiceDto { export class SalesInvoiceDto {
@@ -94,6 +94,7 @@ export type ListSalesInvoicesFilters = {
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly packingSlipId?: string; readonly packingSlipId?: string;
readonly search?: string; readonly search?: string;
readonly payable?: boolean;
readonly orderBy?: string; readonly orderBy?: string;
readonly orderType?: string; readonly orderType?: string;
readonly limit: number; readonly limit: number;
@@ -4,7 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { toOrderClauses } from '../../../common/http/response';
import { import {
catalogRelationFromMap, catalogRelationFromMap,
@@ -31,7 +31,10 @@ import {
} from '../../../database/sales-payments-table'; } from '../../../database/sales-payments-table';
import { DocumentCodeService } from '../shared/document-code.service'; import { DocumentCodeService } from '../shared/document-code.service';
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes'; 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 { import type {
CreateSalesInvoiceInput, CreateSalesInvoiceInput,
ListSalesInvoicesFilters, ListSalesInvoicesFilters,
@@ -393,6 +396,10 @@ export class SalesInvoicesRepository {
parts.push(search); parts.push(search);
} }
} }
if (filters.payable) {
parts.push(inArray(salesInvoices.status, [...PAYABLE_INVOICE_STATUSES]));
parts.push(gt(salesInvoices.balance, '0'));
}
if (parts.length === 0) { if (parts.length === 0) {
return undefined; return undefined;
} }
@@ -191,6 +191,36 @@ describe('SalesInvoicesService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).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 () => { it('applyPaymentEffects sets partial when underpaid', async () => {
repository.findById.mockResolvedValue(sample); repository.findById.mockResolvedValue(sample);
repository.computeTotals.mockResolvedValue({ repository.computeTotals.mockResolvedValue({
@@ -57,6 +57,7 @@ export type ListSalesInvoicesQuery = {
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly packingSlipId?: string; readonly packingSlipId?: string;
readonly search?: string; readonly search?: string;
readonly payable?: boolean;
readonly orderBy?: string; readonly orderBy?: string;
readonly orderType?: string; readonly orderType?: string;
readonly page?: number; readonly page?: number;
@@ -103,6 +104,7 @@ export class SalesInvoicesService {
salesOrderId: query.salesOrderId, salesOrderId: query.salesOrderId,
packingSlipId: query.packingSlipId, packingSlipId: query.packingSlipId,
search: query.search, search: query.search,
payable: query.payable,
orderBy: query.orderBy, orderBy: query.orderBy,
orderType: query.orderType, orderType: query.orderType,
limit: page.limit, limit: page.limit,
@@ -215,7 +217,6 @@ export class SalesInvoicesService {
} }
const status = this.assertStatus(statusRaw); const status = this.assertStatus(statusRaw);
if ( if (
current.salesOrderId ||
!isAllowedStatusTransition( !isAllowedStatusTransition(
current.status.value, current.status.value,
status.value, status.value,
@@ -67,7 +67,11 @@ describe('SalesPaymentsService', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); 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({ const moduleRef: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SalesPaymentsService, SalesPaymentsService,
@@ -92,6 +96,38 @@ describe('SalesPaymentsService', () => {
expect(arg.invoices[0]?.amount.value).toBe('10000.0000'); 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 () => { it('update rejects status on PATCH', async () => {
await expect( await expect(
service.update('sp-1', { status: 'approved', userId: 'user-1' }), service.update('sp-1', { status: 'approved', userId: 'user-1' }),
@@ -22,6 +22,7 @@ import {
isValidImageUrl, isValidImageUrl,
isAllowedStatusTransition, isAllowedStatusTransition,
parseCsvRecord, parseCsvRecord,
PAYABLE_INVOICE_STATUSES,
SALES_PAYMENT_STATUSES, SALES_PAYMENT_STATUSES,
SALES_PAYMENT_USER_TRANSITIONS, SALES_PAYMENT_USER_TRANSITIONS,
} from '../shared/sales-fields'; } from '../shared/sales-fields';
@@ -341,7 +342,8 @@ export class SalesPaymentsService {
} }
const result: SalesPaymentAllocationInput[] = []; const result: SalesPaymentAllocationInput[] = [];
for (const line of lines) { 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); const amount = this.parseDecimal(line.amount);
if (!amount.isPositive()) { if (!amount.isPositive()) {
throw new BadRequestException('Invalid amount'); throw new BadRequestException('Invalid amount');
@@ -351,6 +353,28 @@ export class SalesPaymentsService {
return result; 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 { private assertImage(image: SalesImageBody): SalesPaymentImageInput {
if (!isValidImageUrl(image.url)) { if (!isValidImageUrl(image.url)) {
throw new BadRequestException('Invalid image URL'); throw new BadRequestException('Invalid image URL');
@@ -77,14 +77,14 @@ describe('sales fields', () => {
).toBe(false); ).toBe(false);
}); });
it('limits invoice user transitions to cancelled', () => { it('allows invoice user transitions from draft to processed or cancelled', () => {
expect( expect(
isAllowedStatusTransition( isAllowedStatusTransition(
'draft', 'draft',
'processed', 'processed',
SALES_INVOICE_USER_TRANSITIONS, SALES_INVOICE_USER_TRANSITIONS,
), ),
).toBe(false); ).toBe(true);
expect( expect(
isAllowedStatusTransition( isAllowedStatusTransition(
'draft', 'draft',
@@ -92,6 +92,13 @@ describe('sales fields', () => {
SALES_INVOICE_USER_TRANSITIONS, SALES_INVOICE_USER_TRANSITIONS,
), ),
).toBe(true); ).toBe(true);
expect(
isAllowedStatusTransition(
'processed',
'completed',
SALES_INVOICE_USER_TRANSITIONS,
),
).toBe(false);
}); });
it('allows payment pending to approved, rejected, or draft', () => { 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[]> = export const SALES_INVOICE_USER_TRANSITIONS: Record<string, readonly string[]> =
{ {
draft: ['cancelled'], draft: ['processed', 'cancelled'],
processed: ['cancelled'], processed: ['cancelled'],
partial: ['cancelled'], partial: ['cancelled'],
completed: ['cancelled'], completed: ['cancelled'],
cancelled: [], cancelled: [],
}; };
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
export const SALES_PAYMENT_USER_TRANSITIONS: Record<string, readonly string[]> = export const SALES_PAYMENT_USER_TRANSITIONS: Record<string, readonly string[]> =
{ {
draft: ['pending'], draft: ['pending'],
+6
View File
@@ -181,6 +181,12 @@ describe('Sales invoices (e2e)', () => {
.send({ status: 'processed' }) .send({ status: 'processed' })
.expect(400); .expect(400);
await request(app.getHttpServer())
.patch(`/sales-invoices/${id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'processed' })
.expect(200);
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/sales-invoices/${id}`) .delete(`/sales-invoices/${id}`)
.set('Authorization', `Bearer ${token}`) .set('Authorization', `Bearer ${token}`)
+11
View File
@@ -147,6 +147,11 @@ describe('Sales payments (e2e)', () => {
}) })
.expect(201); .expect(201);
invoiceId = (invoice.body as { id: string }).id; 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 () => { afterAll(async () => {
@@ -182,6 +187,12 @@ describe('Sales payments (e2e)', () => {
.send({ status: 'approved' }) .send({ status: 'approved' })
.expect(400); .expect(400);
await request(app.getHttpServer())
.patch(`/sales-payments/${id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'pending' })
.expect(200);
await request(app.getHttpServer()) await request(app.getHttpServer())
.patch(`/sales-payments/${id}/status`) .patch(`/sales-payments/${id}/status`)
.set('Authorization', `Bearer ${token}`) .set('Authorization', `Bearer ${token}`)