Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts

327 lines
14 KiB
TypeScript

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<EimsConfig>("eims")!;
}
async registerSalesReceipt(invoiceId: string, dto: RegisterSalesReceiptDto): Promise<EimsReceipt> {
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<EimsReceipt> {
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<EimsReceipt[]> {
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<typeof toReceiptDocumentModel>;
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<EimsReceipt> {
// 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<string, unknown>,
});
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<EimsReceipt>);
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<void> {
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<Invoice> {
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;
}