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:
@@ -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', () => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user