import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntityManager } from "typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { NotificationsService } from "../notifications/notifications.service"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { EimsClientService } from "./eims-client.service"; import { EimsBulkCancelItemResult, EimsBulkCancelRequest, EimsBulkCancelResponse, EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView, } from "./eims-registration.types"; import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; /** * `POST /v1/cancel` for one already-registered invoice. * * Simpler than registration: cancellation carries no `InvoiceCounter`/`DocumentNumber`, so none of * `EimsSystemState`'s reservation machinery applies, and — unlike registration — a retried cancel * is safe: the collection's bulk-cancel example shows MoR itself rejects a second cancel with * "IRN already Canceled.", so there is no double-filing risk the way an unacknowledged register * call has. That is what makes the simpler shape below correct: no system-wide block, no in-flight * marker, just a lock-check-unlock before the call and a fresh lock-check-write after it. * * ponytail: the eligibility check (TX1) and the write (TX2) are not one atomic operation, so two * concurrent cancels on the same invoice could both pass TX1 and both call MoR — wasted, but safe, * per the paragraph above. Upgrade to a single locked reservation (like registration's) only if * MoR's cancel endpoint turns out not to be idempotent after all. * * Bearer-authenticated but unsigned (`postBearer`), same as `/v1/verify` — the collection's saved * `/v1/cancel` request carries no `{request,signature,certificate}` envelope. */ @Injectable() export class EimsCancellationService { private readonly logger = new Logger(EimsCancellationService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly client: EimsClientService, private readonly notifications: NotificationsService, ) {} /** * Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in * MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a * repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not * a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to * cancel. */ async cancelInvoiceWithEims( invoiceId: string, reasonCode: string, remark?: string, ): Promise { const eligible = await this.dataSource.transaction(async (manager) => { const invoice = await this.lockInvoice(manager, invoiceId); if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) { throw new ConflictException({ code: "EIMS_ALREADY_CANCELLED", message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`, }); } if (!invoice.eimsIrn) { throw new BadRequestException({ code: "EIMS_NOT_REGISTERED", message: `Invoice ${invoice.invoiceNumber} was never registered with EIMS — nothing to cancel.`, }); } return invoice; }); const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" }; // Outside any transaction — no DB lock is held across the wire. const response = await this.client.postBearer( "/v1/cancel", request, ); const cancellationDate = response?.body?.cancellationDate ?? null; await this.dataSource.transaction(async (manager) => { const invoice = await this.lockInvoice(manager, invoiceId); // Re-checked under lock: a concurrent call may have already recorded this cancellation. if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return; await manager.update(Invoice, invoiceId, { eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancelledAt: new Date(), eimsCancellationDate: cancellationDate, eimsCancellationReasonCode: reasonCode, eimsCancellationRemark: remark ?? null, }); }); this.logger.log(`Invoice ${eligible.invoiceNumber} cancelled with EIMS (IRN ${eligible.eimsIrn})`); await this.notifyBuyer(eligible); return this.getEimsCancellationStatus(invoiceId); } /** * `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the * collection's own shape (an array in, an array of mixed success/error results back). * * Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything * goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP * call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible * invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back * immediately. * * ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch — * same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across * every item for the whole call isn't needed for correctness, only for avoiding a wasted call on * an item that's already ineligible). */ async cancelBulkWithEims( items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>, ): Promise { const results = new Map(); const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = []; for (const item of items) { try { const invoice = await this.dataSource.transaction(async (manager) => { const inv = await this.lockInvoice(manager, item.invoiceId); if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) { throw new ConflictException( `Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`, ); } if (!inv.eimsIrn) { throw new BadRequestException( `Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`, ); } return inv; }); eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark }); } catch (err) { results.set(item.invoiceId, { invoiceId: item.invoiceId, success: false, message: (err as Error).message, }); } } if (eligible.length > 0) { const request: EimsBulkCancelRequest = eligible.map((e) => ({ Irn: e.invoice.eimsIrn!, ReasonCode: e.reasonCode, Remark: e.remark ?? "", })); // Outside any transaction — no DB lock held across the wire, same as single cancel. const response = await this.client.postBearer( "/v1/bulkCancel", request, ); const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry])); for (const { invoice, reasonCode, remark } of eligible) { const entry = byIrn.get(invoice.eimsIrn!); const failed = !entry || "Status" in entry; if (failed) { const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice."; this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`); results.set(invoice.id, { invoiceId: invoice.id, success: false, message }); continue; } await this.dataSource.transaction(async (manager) => { const fresh = await this.lockInvoice(manager, invoice.id); // Re-checked under lock: a concurrent call may have already recorded this cancellation. if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return; await manager.update(Invoice, invoice.id, { eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancelledAt: new Date(), // The bulk success shape carries no cancellationDate at all, unlike single cancel. eimsCancellationDate: null, eimsCancellationReasonCode: reasonCode, eimsCancellationRemark: remark ?? null, }); }); this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`); await this.notifyBuyer(invoice); results.set(invoice.id, { invoiceId: invoice.id, success: true, message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`, }); } } return items.map((item) => results.get(item.invoiceId)!); } async getEimsCancellationStatus(invoiceId: string): Promise { const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } }); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); return toEimsInvoiceStatusView(invoice); } /** Best-effort — a notification failure must never mask a cancellation that already succeeded. */ private async notifyBuyer(invoice: Invoice): Promise { if (!invoice.companyId) return; try { await sendCompanyChannels( this.dataSource, this.notifications, invoice.companyId, `Invoice ${invoice.invoiceNumber} has been cancelled with MoR EIMS.`, ); } catch (err) { this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`); } } private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { const invoice = await manager .createQueryBuilder(Invoice, "invoice") .setLock("pessimistic_write") .where("invoice.id = :invoiceId", { invoiceId }) .getOne(); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); return invoice; } }