import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; import { 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 { BranchesService } from '../../configuration/branches/branches.service'; import { CustomersService } from '../../configuration/customers/customers.service'; import { DivisionsService } from '../../configuration/divisions/divisions.service'; import { EmployeesService } from '../../configuration/employees/employees.service'; import { ProductsService } from '../../configuration/products/products.service'; import { PackingSlipsService } from '../packing-slips/packing-slips.service'; import { SalesOrdersService } from '../sales-orders/sales-orders.service'; import { isValidDocumentCode, isValidDocumentNotes, parseCsvRecord, SALES_INVOICE_STATUSES, } from '../shared/sales-fields'; import type { CreateSalesInvoiceInput, SalesInvoice, SalesInvoiceLineInput, UpdateSalesInvoiceInput, } from './sales-invoice'; import { SalesInvoicesRepository } from './sales-invoices.repository'; export type SalesLineBody = { readonly productId: string; readonly quantity: string; readonly price?: string; }; export type ListSalesInvoicesQuery = { readonly code?: string; readonly status?: string; readonly customerId?: string; readonly salesPersonId?: string; readonly branchId?: string; readonly divisionId?: string; readonly salesOrderId?: string; readonly packingSlipId?: string; readonly search?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; }; const CSV_REQUIRED_HEADERS = [ 'date', 'salesPersonId', 'branchId', 'divisionId', 'customerId', ] as const; @Injectable() export class SalesInvoicesService { constructor( private readonly salesInvoicesRepository: SalesInvoicesRepository, private readonly employeesService: EmployeesService, private readonly branchesService: BranchesService, private readonly divisionsService: DivisionsService, private readonly customersService: CustomersService, private readonly productsService: ProductsService, private readonly salesOrdersService: SalesOrdersService, private readonly packingSlipsService: PackingSlipsService, ) {} async list( query: ListSalesInvoicesQuery, ): Promise< PaginationResponse> > { const page = toListPage(query); const { data, total } = await this.salesInvoicesRepository.list({ code: query.code, status: query.status, customerId: query.customerId, salesPersonId: query.salesPersonId, branchId: query.branchId, divisionId: query.divisionId, salesOrderId: query.salesOrderId, packingSlipId: query.packingSlipId, search: query.search, limit: page.limit, offset: page.offset, }); return { data: data.map((item) => this.toListItem(item)), total, }; } async findById( id: string, ): Promise> { const found = await this.salesInvoicesRepository.findById(id); if (!found) { throw new NotFoundException('Sales invoice not found'); } return this.toDetail(found); } async create(input: { code?: string; salesOrderId?: string; packingSlipId?: string; date?: string; salesPersonId?: string; branchId?: string; divisionId?: string; customerId?: string; notes?: string | null; products?: SalesLineBody[]; status?: string; userId: string; }): Promise> { const merged = await this.mergeFromParents(input); await this.assertRelations(merged); const created = await this.salesInvoicesRepository.create( await this.toCreateInput(merged), ); return this.toDetail(created); } async update( id: string, input: { code?: string; salesOrderId?: string | null; packingSlipId?: string | null; date?: string; salesPersonId?: string; branchId?: string; divisionId?: string; customerId?: string; notes?: string | null; products?: SalesLineBody[]; status?: unknown; userId: string; }, ): Promise> { if (input.status !== undefined) { throw new BadRequestException('status cannot be updated via PATCH'); } await this.assertRelations(input); let salesOrderCode: string | null | undefined; let packingSlipCode: string | null | undefined; if (input.salesOrderId) { const order = await this.salesOrdersService.findById(input.salesOrderId); salesOrderCode = order.code; } else if (input.salesOrderId === null) { salesOrderCode = null; } if (input.packingSlipId) { const slip = await this.packingSlipsService.findById(input.packingSlipId); packingSlipCode = slip.code; } else if (input.packingSlipId === null) { packingSlipCode = null; } const payload: UpdateSalesInvoiceInput = { code: input.code !== undefined ? this.assertCode(input.code) : undefined, salesOrderId: input.salesOrderId, salesOrderCode, packingSlipId: input.packingSlipId, packingSlipCode, date: input.date !== undefined ? this.assertDate(input.date) : undefined, salesPersonId: input.salesPersonId, branchId: input.branchId, divisionId: input.divisionId, customerId: input.customerId, notes: input.notes !== undefined ? this.assertNotes(input.notes) : undefined, products: input.products !== undefined ? await this.assertLines(input.products) : undefined, userId: input.userId, }; const updated = await this.salesInvoicesRepository.update(id, payload); return this.toDetail(updated); } async updateStatus( id: string, statusRaw: string, userId: string, ): Promise> { const status = this.assertStatus(statusRaw); const updated = await this.salesInvoicesRepository.updateStatus( id, status, userId, ); return this.toDetail(updated); } async applyPaymentEffects( invoiceId: string, userId: string, ): Promise> { const invoice = await this.salesInvoicesRepository.findById(invoiceId); if (!invoice) { throw new NotFoundException('Sales invoice not found'); } const totals = await this.salesInvoicesRepository.computeTotals(invoiceId); if (totals.paid.compare(totals.total) > 0) { throw new BadRequestException('Payment exceeds invoice total'); } await this.salesInvoicesRepository.refreshStoredBalance(invoiceId); if (totals.paid.isZero()) { return this.findById(invoiceId); } const nextStatus = totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial'; const updated = await this.salesInvoicesRepository.updateStatus( invoiceId, Status.create(nextStatus, SALES_INVOICE_STATUSES), userId, ); return this.toDetail(updated); } async getTotals(invoiceId: string) { return this.salesInvoicesRepository.computeTotals(invoiceId); } async bulkUpdateStatus( ids: string[], statusRaw: string, userId: string, ): Promise<{ updated: number }> { const status = this.assertStatus(statusRaw); const updated = await this.salesInvoicesRepository.bulkUpdateStatus( ids, status, userId, ); return { updated }; } async delete(id: string): Promise { await this.salesInvoicesRepository.delete(id); } async bulkDelete(ids: string[]): Promise<{ deleted: number }> { const deleted = await this.salesInvoicesRepository.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: CreateSalesInvoiceInput[] = []; 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')] ?? '', salesPersonId: cols[idx('salespersonid')] ?? '', branchId: cols[idx('branchid')] ?? '', divisionId: cols[idx('divisionid')] ?? '', customerId: cols[idx('customerid')] ?? '', notes: idx('notes') >= 0 ? cols[idx('notes')] : null, products: [], 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.salesInvoicesRepository.createMany(rows); return { imported: rows.length }; } toListItem(item: SalesInvoice) { return { id: item.id, code: item.code, salesOrderId: item.salesOrderId, salesOrderCode: item.salesOrderCode, packingSlipId: item.packingSlipId, packingSlipCode: item.packingSlipCode, date: item.date.value, salesPersonId: item.salesPersonId, branchId: item.branchId, divisionId: item.divisionId, customerId: item.customerId, notes: item.notes, balance: item.balance.value, status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, createdBy: item.createdBy, updatedBy: item.updatedBy, }; } toDetail(item: SalesInvoice) { return { ...this.toListItem(item), products: item.products.map((line) => ({ id: line.id, productId: line.productId, quantity: line.quantity.value, price: line.price.value, })), }; } private async mergeFromParents(input: { code?: string; salesOrderId?: string; packingSlipId?: string; date?: string; salesPersonId?: string; branchId?: string; divisionId?: string; customerId?: string; notes?: string | null; products?: SalesLineBody[]; status?: string; userId: string; }) { let date = input.date ?? ''; let salesPersonId = input.salesPersonId ?? ''; let branchId = input.branchId ?? ''; let divisionId = input.divisionId ?? ''; let customerId = input.customerId ?? ''; let notes = input.notes; let products = input.products; let salesOrderCode: string | null = null; let packingSlipCode: string | null = null; if (input.salesOrderId) { const order = await this.salesOrdersService.findById(input.salesOrderId); salesOrderCode = order.code; date = date || DateTime.fromUnixMs(order.date).format(); salesPersonId = salesPersonId || order.salesPersonId; branchId = branchId || order.branchId; divisionId = divisionId || order.divisionId; customerId = customerId || order.customerId; notes = notes !== undefined ? notes : order.notes; products = products ?? order.products.map((line) => ({ productId: line.productId, quantity: line.quantity, price: line.price, })); } if (input.packingSlipId) { const slip = await this.packingSlipsService.findById(input.packingSlipId); packingSlipCode = slip.code; date = date || DateTime.fromUnixMs(slip.date).format(); customerId = customerId || slip.customerId; notes = notes !== undefined ? notes : slip.notes; products = input.products ?? products ?? slip.products.map((line) => ({ productId: line.productId, quantity: line.quantity, price: line.price, })); } return { ...input, salesOrderId: input.salesOrderId ?? null, salesOrderCode, packingSlipId: input.packingSlipId ?? null, packingSlipCode, date, salesPersonId, branchId, divisionId, customerId, notes: notes ?? null, products: products ?? [], }; } private async toCreateInput(input: { code?: string; salesOrderId?: string | null; salesOrderCode?: string | null; packingSlipId?: string | null; packingSlipCode?: string | null; date: string; salesPersonId: string; branchId: string; divisionId: string; customerId: string; notes?: string | null; products: SalesLineBody[]; status?: string; userId: string; }): Promise { return { code: input.code !== undefined && input.code !== '' ? this.assertCode(input.code) : undefined, salesOrderId: input.salesOrderId ?? null, salesOrderCode: input.salesOrderCode ?? null, packingSlipId: input.packingSlipId ?? null, packingSlipCode: input.packingSlipCode ?? null, date: this.assertDate(input.date), salesPersonId: input.salesPersonId, branchId: input.branchId, divisionId: input.divisionId, customerId: input.customerId, notes: this.assertNotes(input.notes ?? null), products: await this.assertLines(input.products), status: input.status ? this.assertStatus(input.status) : Status.create('draft', SALES_INVOICE_STATUSES), userId: input.userId, }; } private async assertRelations(input: { salesPersonId?: string; branchId?: string; divisionId?: string; customerId?: string; }): Promise { if (input.salesPersonId) { await this.employeesService.findById(input.salesPersonId); } if (input.branchId) { await this.branchesService.findById(input.branchId); } if (input.divisionId) { await this.divisionsService.findById(input.divisionId); } if (input.customerId) { await this.customersService.findById(input.customerId); } } private async assertLines( lines: SalesLineBody[], ): Promise { if (!Array.isArray(lines) || lines.length === 0) { throw new BadRequestException('At least one product line is required'); } const result: SalesInvoiceLineInput[] = []; for (const line of lines) { const product = await this.productsService.findById(line.productId); const quantity = this.assertPositiveDecimal(line.quantity, 'quantity'); let price: Decimal; if (line.price === undefined || line.price === '') { if (product.price === null) { throw new BadRequestException('Product price is required'); } price = Decimal.create(product.price); } else { price = this.assertNonNegativeDecimal(line.price, 'price'); } result.push({ productId: product.id, quantity, price }); } return result; } 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_INVOICE_STATUSES); } catch { throw new BadRequestException('Invalid status'); } } private assertPositiveDecimal(raw: string, label: string): Decimal { const value = this.parseDecimal(raw); if (!value.isPositive()) { throw new BadRequestException(`Invalid ${label}`); } return value; } private assertNonNegativeDecimal(raw: string, label: string): Decimal { const value = this.parseDecimal(raw); if (value.isNegative()) { throw new BadRequestException(`Invalid ${label}`); } return value; } private parseDecimal(raw: string): Decimal { try { return Decimal.create(raw); } catch (error) { if (error instanceof InvalidDecimalError) { throw new BadRequestException('Invalid decimal'); } throw error; } } }