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> > { 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> { 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> { 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> { 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> { 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 { 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 { 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 { const extras = new Map(); 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 { 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; } } }