- 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.
437 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|