Files
trackgo-be/src/modules/sales/sales-payments/sales-payments.service.ts
T
shancheas 5579cf6566 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.
2026-08-31 15:15:06 +07:00

437 lines
13 KiB
TypeScript

import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import {
pickCodeRelation,
pickUserRelation,
toListPage,
} from '../../../common/http/response';
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
import { Status } from '../../../common/value-objects/status/status';
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
import {
isValidDocumentCode,
isValidDocumentNotes,
isValidImageDescription,
isValidImageUrl,
isAllowedStatusTransition,
parseCsvRecord,
PAYABLE_INVOICE_STATUSES,
SALES_PAYMENT_STATUSES,
SALES_PAYMENT_USER_TRANSITIONS,
} from '../shared/sales-fields';
import type {
CreateSalesPaymentInput,
SalesPayment,
SalesPaymentAllocationInput,
SalesPaymentImageInput,
UpdateSalesPaymentInput,
} from './sales-payment';
import { SalesPaymentsRepository } from './sales-payments.repository';
export type PaymentAllocationBody = {
readonly invoiceId: string;
readonly amount: string;
};
export type SalesImageBody = {
readonly url: string;
readonly description?: string | null;
};
export type ListSalesPaymentsQuery = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const CSV_REQUIRED_HEADERS = ['date'] as const;
@Injectable()
export class SalesPaymentsService {
constructor(
private readonly salesPaymentsRepository: SalesPaymentsRepository,
private readonly salesInvoicesService: SalesInvoicesService,
) {}
async list(
query: ListSalesPaymentsQuery,
): Promise<
PaginationResponse<ReturnType<SalesPaymentsService['toListItem']>>
> {
const page = toListPage(query);
const { data, total } = await this.salesPaymentsRepository.list({
code: query.code,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toListItem(item)),
total,
};
}
async findById(
id: string,
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
const found = await this.salesPaymentsRepository.findById(id);
if (!found) {
throw new NotFoundException('Sales payment not found');
}
return this.toDetail(found);
}
async create(input: {
code?: string;
date: string;
notes?: string | null;
invoices: PaymentAllocationBody[];
images?: SalesImageBody[];
status?: string;
userId: string;
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
const created = await this.salesPaymentsRepository.create(
await this.toCreateInput(input),
);
return this.toDetail(created);
}
async update(
id: string,
input: {
code?: string;
date?: string;
notes?: string | null;
invoices?: PaymentAllocationBody[];
images?: SalesImageBody[];
status?: unknown;
userId: string;
},
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const payload: UpdateSalesPaymentInput = {
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
notes:
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
invoices:
input.invoices !== undefined
? await this.assertAllocations(input.invoices)
: undefined,
images:
input.images !== undefined
? input.images.map((image) => this.assertImage(image))
: undefined,
userId: input.userId,
};
const updated = await this.salesPaymentsRepository.update(id, payload);
return this.toDetail(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
const next = this.assertStatus(statusRaw);
const existing = await this.salesPaymentsRepository.findById(id);
if (!existing) {
throw new NotFoundException('Sales payment not found');
}
if (
!isAllowedStatusTransition(
existing.status.value,
next.value,
SALES_PAYMENT_USER_TRANSITIONS,
)
) {
throw new BadRequestException('Invalid status transition');
}
const becomingApproved =
next.value === 'approved' && existing.status.value !== 'approved';
const leavingApproved =
existing.status.value === 'approved' && next.value !== 'approved';
if (becomingApproved) {
await this.assertAllocationsFit(existing.invoices);
}
const updated = await this.salesPaymentsRepository.updateStatus(
id,
next,
userId,
);
if (becomingApproved || leavingApproved) {
const invoiceIds = [
...new Set(updated.invoices.map((line) => line.invoiceId)),
];
for (const invoiceId of invoiceIds) {
await this.salesInvoicesService.applyPaymentEffects(invoiceId, userId);
}
}
return this.toDetail(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
for (const id of ids) {
await this.updateStatus(id, statusRaw, userId);
}
return { updated: ids.length };
}
async delete(id: string): Promise<void> {
await this.salesPaymentsRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.salesPaymentsRepository.bulkDelete(ids);
return { deleted };
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const rawLines = csv.split(/\r?\n/);
const filled = rawLines
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
.filter((entry) => entry.line.length > 0);
if (filled.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (filled.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = parseCsvRecord(filled[0].line).map((h) =>
h.trim().toLowerCase(),
);
const missing = CSV_REQUIRED_HEADERS.filter(
(h) => header.indexOf(h.toLowerCase()) < 0,
);
if (missing.length > 0) {
throw new BadRequestException('CSV must include required headers');
}
const idx = (key: string) => header.indexOf(key.toLowerCase());
const errors: string[] = [];
const rows: CreateSalesPaymentInput[] = [];
for (let i = 1; i < filled.length; i++) {
const cols = parseCsvRecord(filled[i].line);
const rowNum = filled[i].lineNo;
try {
rows.push(
await this.toCreateInput({
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
date: cols[idx('date')] ?? '',
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
invoices: [],
images: [],
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
userId,
}),
);
} catch (error) {
const reason =
error instanceof BadRequestException ? error.message : 'invalid data';
errors.push(`row ${rowNum}: ${reason}`);
}
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
await this.salesPaymentsRepository.createMany(rows);
return { imported: rows.length };
}
toListItem(item: SalesPayment) {
return {
id: item.id,
code: item.code,
date: item.date.value,
notes: item.notes,
status: item.status.value,
createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value,
createdBy: pickUserRelation(item.createdByUser),
updatedBy: pickUserRelation(item.updatedByUser),
};
}
toDetail(item: SalesPayment) {
return {
...this.toListItem(item),
invoices: item.invoices.map((line) => ({
id: line.id,
invoice: pickCodeRelation(line.invoice),
amount: line.amount.value,
})),
images: item.images.map((image) => ({
id: image.id,
url: image.url,
description: image.description,
})),
};
}
private async toCreateInput(input: {
code?: string;
date: string;
notes?: string | null;
invoices: PaymentAllocationBody[];
images?: SalesImageBody[];
status?: string;
userId: string;
}): Promise<CreateSalesPaymentInput> {
return {
code:
input.code !== undefined && input.code !== ''
? this.assertCode(input.code)
: undefined,
date: this.assertDate(input.date),
notes: this.assertNotes(input.notes ?? null),
invoices: await this.assertAllocations(input.invoices),
images: (input.images ?? []).map((image) => this.assertImage(image)),
status: input.status
? this.assertStatus(input.status)
: Status.create('draft', SALES_PAYMENT_STATUSES),
userId: input.userId,
};
}
private async assertAllocationsFit(
allocations: readonly SalesPayment['invoices'][number][],
): Promise<void> {
const extras = new Map<string, Decimal>();
for (const line of allocations) {
const current = extras.get(line.invoiceId) ?? Decimal.create('0');
extras.set(line.invoiceId, current.add(line.amount));
}
for (const [invoiceId, extra] of extras) {
const totals = await this.salesInvoicesService.getTotals(invoiceId);
if (totals.paid.add(extra).compare(totals.total) > 0) {
throw new BadRequestException('Payment exceeds invoice total');
}
}
}
private async assertAllocations(
lines: PaymentAllocationBody[],
): Promise<SalesPaymentAllocationInput[]> {
if (!Array.isArray(lines)) {
throw new BadRequestException(
'At least one invoice allocation is required',
);
}
const result: SalesPaymentAllocationInput[] = [];
for (const line of lines) {
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');
}
result.push({ invoiceId: line.invoiceId, amount });
}
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');
}
const description = image.description ?? null;
if (description !== null && !isValidImageDescription(description)) {
throw new BadRequestException('Invalid image description');
}
return { url: image.url, description };
}
private assertCode(raw: string): string {
const code = raw.trim();
if (!isValidDocumentCode(code)) {
throw new BadRequestException('Invalid document code');
}
return code;
}
private assertDate(raw: string): DateTime {
try {
return DateTime.create(raw);
} catch (error) {
if (error instanceof InvalidDateTimeError) {
throw new BadRequestException('Invalid date');
}
throw error;
}
}
private assertNotes(raw: string | null): string | null {
if (raw === null || raw === '') {
return null;
}
if (!isValidDocumentNotes(raw)) {
throw new BadRequestException('Invalid notes');
}
return raw;
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw, SALES_PAYMENT_STATUSES);
} catch {
throw new BadRequestException('Invalid status');
}
}
private parseDecimal(raw: string): Decimal {
try {
return Decimal.create(raw);
} catch (error) {
if (error instanceof InvalidDecimalError) {
throw new BadRequestException('Invalid decimal');
}
throw error;
}
}
}