import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js"; import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceDocumentService } from "../billing/documents/invoice-document.service"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException } from "./eims.errors"; import { toReceiptDocumentModel } from "./eims-receipt-document.mapper"; import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment, EimsReceiptResponse, EimsSalesReceiptRequest, EimsWithholdReceiptRequest, } from "./eims-receipt.types"; import { EimsInvoiceError } from "./eims-registration.types"; /** `ReceiptCurrency` / withholding `InvoiceDetail.Currency` — confirmed by a live rule error. */ const EIMS_RECEIPT_CURRENCIES = ["ETB", "USD", "CAD"] as const; /** Failure kinds where MoR gave a complete answer — the receipt definitively did not register. */ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); /** * `POST /v1/receipt/sales` and `POST /v1/receipt/withholding`, both against an already-registered * invoice. * * No counter/reservation machinery: the collection shows no MoR-enforced ordering on * `ReceiptCounter` the way `InvoiceCounter` has a documented "expected: N" rule, so — unlike * registration — there is no shared sequence to protect. Each attempt is its own `EimsReceipt` row: * created before the HTTP call (so a crash mid-flight leaves an UNKNOWN row instead of nothing), * settled after it. An ambiguous outcome is never auto-retried — receipts have no evidenced * double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution * applies as an unacknowledged registration: a human must check the MoR portal first. * * Sales receipts derive what the invoice's payment ledger actually records — amount, date, * finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode * where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source * (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never * guessed — see the two DTOs. */ @Injectable() export class EimsReceiptService { private readonly logger = new Logger(EimsReceiptService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, private readonly auth: EimsAuthService, private readonly notifications: NotificationsService, private readonly documents: InvoiceDocumentService, ) {} private get cfg(): EimsConfig { return this.config.get("eims")!; } async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise { const invoice = await this.loadRegisteredInvoice(invoiceId); const currency = dto.currency ?? invoice.currency; this.assertReceiptCurrency(currency); const session = await this.auth.getSessionContext(); const receiptNumber = this.generateReceiptNumber(invoice); // The newest ledger entry is the payment this receipt vouches for. A gateway settlement's // `reference` is the provider transaction id; a manual settlement's `reference` is finance's // own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered // receipt matches finance's books. const lastPayment = invoice.payments?.length ? invoice.payments[invoice.payments.length - 1] : null; const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY"; const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method); if (!modeOfPayment) { throw new BadRequestException({ code: "EIMS_MODE_OF_PAYMENT_REQUIRED", message: `Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` + `ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`, }); } const collectedAmount = dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount)); const balance = Number(invoice.balanceAmount); const request: EimsSalesReceiptRequest = { ReceiptNumber: receiptNumber, ReceiptType: "Sales Receipts", Reason: dto.reason ?? "Payment received", // ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema // error for this field was ever observed to confirm which form MoR actually requires. ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(), ReceiptCounter: String(Date.now()), ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber, SourceSystemType: session.systemType, SourceSystemNumber: session.systemNumber, ReceiptCurrency: currency, ExchangeRate: dto.exchangeRate ?? null, CollectedAmount: collectedAmount, SellerTIN: this.cfg.tin, Invoices: [ { InvoiceIRN: invoice.eimsIrn!, PaymentCoverage: dto.paymentCoverage ?? (balance <= 0 ? "FULL" : "PARTIAL"), InvoicePaidAmount: collectedAmount, DiscountAmount: null, RemainingAmount: balance, TotalAmount: Number(invoice.totalAmount), }, ], TransactionDetails: { ModeOfPayment: modeOfPayment, ChequeNumber: dto.chequeNumber ?? null, CPONumber: dto.cpoNumber ?? null, DocumentNumber: dto.documentNumber ?? null, CollectorName: dto.collectorName ?? null, PaymentServiceProvider: dto.paymentServiceProvider ?? null, OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null, AccountNumber: dto.accountNumber ?? null, TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null), }, }; return this.submit(invoice, "SALES", receiptNumber, request, "/v1/receipt/sales"); } async registerWithholdingReceipt( invoiceId: string, dto: RegisterWithholdingReceiptDto, ): Promise { const invoice = await this.loadRegisteredInvoice(invoiceId); if (invoice.currency !== "ETB" && dto.exchangeRate == null) { throw new BadRequestException({ code: "EIMS_EXCHANGE_RATE_REQUIRED", message: `Invoice ${invoice.invoiceNumber} is in ${invoice.currency} and needs an exchangeRate`, }); } const session = await this.auth.getSessionContext(); const receiptNumber = this.generateReceiptNumber(invoice); const request: EimsWithholdReceiptRequest = { ReceiptNumber: receiptNumber, Reason: dto.reason ?? "Withholding", ReceiptCounter: String(Date.now()), ManualReceiptNumber: receiptNumber, SourceSystemType: session.systemType, SourceSystemNumber: session.systemNumber, InvoiceDetail: { InvoiceIRN: invoice.eimsIrn!, Currency: invoice.currency, ExchangeRate: dto.exchangeRate ?? null, }, WithholdDetail: { Type: dto.type, Rate: dto.rate ?? null, PreTaxAmount: dto.preTaxAmount, WithholdingAmount: dto.withholdingAmount, }, }; return this.submit(invoice, "WITHHOLDING", receiptNumber, request, "/v1/receipt/withholding"); } async listReceipts(invoiceId: string): Promise { return this.dataSource.manager.find(EimsReceipt, { where: { invoiceId }, order: { createdAt: "DESC" }, }); } /** * Sealed PDF for one filed receipt (RRN + QR), scoped to the invoice it belongs to. Not on * `loadRegisteredInvoice` — a receipt refused/never-acknowledged by MoR must not render as a * sealed tax document, and `toReceiptDocumentModel` is the one place that guards it. */ async document(invoiceId: string, receiptId: string): Promise<{ filename: string; buffer: Buffer }> { const receipt = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receiptId, invoiceId }, }); if (!receipt) throw new NotFoundException(`Receipt ${receiptId} not found on invoice ${invoiceId}`); const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); let model: ReturnType; try { model = toReceiptDocumentModel(receipt, invoice, this.cfg); } catch (err) { // Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 — // a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService // itself throws. throw new BadRequestException((err as Error).message); } return this.documents.render(model); } // ── internals ──────────────────────────────────────────────────────────────────────────────── private async submit( invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string, request: EimsSalesReceiptRequest | EimsWithholdReceiptRequest, path: "/v1/receipt/sales" | "/v1/receipt/withholding", ): Promise { // Committed before the HTTP call — a crash mid-flight leaves an UNKNOWN row, not nothing. const receipt = await this.dataSource.manager.save(EimsReceipt, { invoiceId: invoice.id, kind, status: EimsReceiptStatus.Submitting, receiptNumber, submittedAt: new Date(), request: request as unknown as Record, }); try { const response = await this.client.postBearer< EimsSalesReceiptRequest | EimsWithholdReceiptRequest, EimsReceiptResponse >(path, request); const rrn = response?.body?.rrn; if (!rrn) { throw new EimsApiException( "SCHEMA_VALIDATION", "EIMS receipt registration returned no rrn", response?.statusCode, ); } await this.dataSource.manager.update(EimsReceipt, receipt.id, { status: EimsReceiptStatus.Registered, rrn, qr: response.body?.qr ?? null, ackStatus: response.body?.status ?? null, }); this.logger.log(`${kind} receipt ${receiptNumber} registered for invoice ${invoice.invoiceNumber} (RRN ${rrn})`); await this.notifyBuyer(invoice, kind, receiptNumber); } catch (err) { const api = err instanceof EimsApiException ? err : null; const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; const lastError: EimsInvoiceError = { kind: api?.kind ?? "UNKNOWN", message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, at: new Date().toISOString(), }; await this.dataSource.manager.update(EimsReceipt, receipt.id, { status: deterministic ? EimsReceiptStatus.Failed : EimsReceiptStatus.Unknown, lastError, } as QueryDeepPartialEntity); this.logger.error( `${kind} receipt ${receiptNumber} for invoice ${invoice.invoiceNumber} ${deterministic ? "FAILED" : "UNKNOWN"}: ${lastError.message}`, ); throw err; } const saved = await this.dataSource.manager.findOne(EimsReceipt, { where: { id: receipt.id } }); return saved!; } private async notifyBuyer(invoice: Invoice, kind: EimsReceiptKind, receiptNumber: string): Promise { if (!invoice.companyId) return; try { await sendCompanyChannels( this.dataSource, this.notifications, invoice.companyId, `A ${kind === "SALES" ? "sales" : "withholding"} receipt (${receiptNumber}) has been registered ` + `with MoR EIMS for invoice ${invoice.invoiceNumber}.`, ); } catch (err) { this.logger.warn(`EIMS receipt buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`); } } private async loadRegisteredInvoice(invoiceId: string): Promise { const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); if (!invoice.eimsIrn) { throw new BadRequestException({ code: "EIMS_NOT_REGISTERED", message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — no IRN to file a receipt against.`, }); } return invoice; } private assertReceiptCurrency(currency: string): void { if (!EIMS_RECEIPT_CURRENCIES.includes(currency as (typeof EIMS_RECEIPT_CURRENCIES)[number])) { throw new BadRequestException({ code: "EIMS_RECEIPT_CURRENCY_INVALID", message: `EIMS receipt currency must be one of ${EIMS_RECEIPT_CURRENCIES.join(", ")}, got "${currency}"`, }); } } private generateReceiptNumber(invoice: Invoice): string { return `REC-${invoice.invoiceNumber}-${Date.now()}`; } } /** * Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money * methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real * channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing * a tax field is worse than asking. */ function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined { if (!method) return undefined; const normalized = method.toUpperCase().replace(/-/g, "_"); const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized); if (direct) return direct; return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined; }