mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 20:13:38 +00:00
327 lines
14 KiB
TypeScript
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;
|
|
}
|