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