Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts
Hagernesh 7d8ab932c2 feat(eims): implement POST /v1/bulkCancel
New endpoint: POST invoices/eims/bulk-cancel, body { items: [{invoiceId,
reasonCode, remark?}] }. Same eimsCancel permission as single cancel — a
batch-scale version of the same irreversible-at-MoR action, not a new
capability.

Same local-eligibility doctrine as single cancel: an already-cancelled or
never-registered invoice is refused right here, no HTTP call, before it
gets a seat in the batch. Only genuinely eligible invoices go into the one
/v1/bulkCancel request; every outcome (local refusal or MoR's own
per-IRN result) is reported back independently — one invoice failing
never blocks the rest.

MoR's bulk response mixes success and error shapes in the same array,
disambiguated by Status (capital, error) vs status (lowercase, success)
— matched back to our invoices by IRN. Notably the bulk success shape
carries no cancellationDate at all, unlike single cancel.

Left out of this pass: bulkRegister. It's async (returns only a
conversationId immediately, results arrive via a webhook callback we
don't have yet) and needs manual counter/previousIrn management per
the collection's own docs — a materially different reservation model
than today's single-invoice TX1/TX2 pattern. Scoping that is a
separate, bigger piece of work.
2026-08-17 17:49:33 +00:00

230 lines
10 KiB
TypeScript

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<EimsInvoiceStatusView> {
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<EimsCancelRequest, EimsCancelResponse>(
"/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<EimsBulkCancelItemResult[]> {
const results = new Map<string, EimsBulkCancelItemResult>();
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<EimsBulkCancelRequest, EimsBulkCancelResponse>(
"/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<EimsInvoiceStatusView> {
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<void> {
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<Invoice> {
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;
}
}