diff --git a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts index 82da22b..000b411 100644 --- a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts +++ b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts @@ -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 { diff --git a/src/modules/sales/sales-invoices/sales-invoice.ts b/src/modules/sales/sales-invoices/sales-invoice.ts index 18fb3bc..f7b3798 100644 --- a/src/modules/sales/sales-invoices/sales-invoice.ts +++ b/src/modules/sales/sales-invoices/sales-invoice.ts @@ -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; diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts index cff176e..2a222f8 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.repository.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -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; } diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts index 574c212..9e3aabc 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts @@ -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({ diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.ts b/src/modules/sales/sales-invoices/sales-invoices.service.ts index 840c36b..efbee88 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.ts @@ -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, diff --git a/src/modules/sales/sales-payments/sales-payments.service.spec.ts b/src/modules/sales/sales-payments/sales-payments.service.spec.ts index 5a713b9..698d60e 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.spec.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.spec.ts @@ -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' }), diff --git a/src/modules/sales/sales-payments/sales-payments.service.ts b/src/modules/sales/sales-payments/sales-payments.service.ts index bb110c1..bf2515b 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.ts @@ -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'); diff --git a/src/modules/sales/shared/sales-fields.spec.ts b/src/modules/sales/shared/sales-fields.spec.ts index c551bae..871b476 100644 --- a/src/modules/sales/shared/sales-fields.spec.ts +++ b/src/modules/sales/shared/sales-fields.spec.ts @@ -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', () => { diff --git a/src/modules/sales/shared/sales-fields.ts b/src/modules/sales/shared/sales-fields.ts index ae42549..2fbbb20 100644 --- a/src/modules/sales/shared/sales-fields.ts +++ b/src/modules/sales/shared/sales-fields.ts @@ -69,13 +69,15 @@ export const PACKING_SLIP_USER_TRANSITIONS: Record = export const SALES_INVOICE_USER_TRANSITIONS: Record = { - 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 = { draft: ['pending'], diff --git a/test/sales-invoices.e2e-spec.ts b/test/sales-invoices.e2e-spec.ts index 494ecdf..9e959f0 100644 --- a/test/sales-invoices.e2e-spec.ts +++ b/test/sales-invoices.e2e-spec.ts @@ -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}`) diff --git a/test/sales-payments.e2e-spec.ts b/test/sales-payments.e2e-spec.ts index 80d48ef..ad80d11 100644 --- a/test/sales-payments.e2e-spec.ts +++ b/test/sales-payments.e2e-spec.ts @@ -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}`)