Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.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

140 lines
6.1 KiB
TypeScript

import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { sendPdf } from "../billing/billing.controller";
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto";
import { EimsCancellationService } from "./eims-cancellation.service";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsReceiptService } from "./eims-receipt.service";
/**
* Manual EIMS actions on an existing invoice.
*
* Invoices are produced by the freight workflow, not by a person, so these routes are **not** the
* normal production path — they exist for controlled testing and exceptional operations. Automatic
* submission after an invoice is issued is a separate phase; nothing here is called by it.
*
* `eims_register`, `eims_resolve`, `eims_cancel` and `eims_receipt_register` are intentionally left
* out of every role preset and assigned to named admins instead. They are also separate
* permissions: resolving clears the system-wide chain block and can record an IRN against an
* invoice, cancelling is its own irreversible-at-MoR action, and filing a receipt is a third —
* none follows from the right to register. Only `eims/status` and `eims/receipts` ride on the
* ordinary `invoices:view`.
*
* Filing gets its own permission (`invoices:eims_register`) rather than riding on an existing key:
* registration is irreversible at MoR, so it must not follow from the right to download a PDF.
* The key is seeded through FINANCE_PERMISSIONS, which reaches `iam.permissions` via
* ADVANCED_BACKOFFICE_PERMISSIONS → BOOKING_RULE_ENGINE_PERMISSIONS → EDR_FREIGHT_PERMISSIONS.
*/
@ApiTags("eims")
@ApiBearerAuth()
@Controller("invoices")
export class EimsInvoiceController {
constructor(
private readonly registration: EimsInvoiceRegistrationService,
private readonly cancellation: EimsCancellationService,
private readonly receipts: EimsReceiptService,
) {}
@Post(":id/eims/register")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({
summary:
"Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged.",
})
register(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.registerInvoiceWithEims(id);
}
@Post(":id/eims/verify")
@BookingStaff(FREIGHT_PERMS.invoices.eimsRegister)
@ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" })
verify(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.verifyInvoiceWithEims(id);
}
@Post(":id/eims/resolve")
@BookingStaff(FREIGHT_PERMS.invoices.eimsResolve)
@ApiOperation({
summary:
"Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block.",
})
resolve(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ResolveEimsRegistrationDto,
) {
return this.registration.resolveEimsRegistration(id, dto);
}
@Get(":id/eims/status")
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiOperation({ summary: "EIMS registration status, IRN and last error for the invoice" })
status(@Param("id", ParseUUIDPipe) id: string) {
return this.registration.getEimsStatus(id);
}
@Post(":id/eims/cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.",
})
cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) {
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
}
@Post("eims/bulk-cancel")
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
@ApiOperation({
summary:
"Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " +
"reported independently — one failure never blocks the rest.",
})
bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) {
return this.cancellation.cancelBulkWithEims(dto.items);
}
@Post(":id/eims/receipt/sales")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
registerSalesReceipt(@Param("id", ParseUUIDPipe) id: string, @Body() dto: RegisterSalesReceiptDto) {
return this.receipts.registerSalesReceipt(id, dto);
}
@Post(":id/eims/receipt/withholding")
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
@ApiOperation({ summary: "Register a withholding receipt with MoR EIMS against a registered invoice" })
registerWithholdingReceipt(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RegisterWithholdingReceiptDto,
) {
return this.receipts.registerWithholdingReceipt(id, dto);
}
@Get(":id/eims/receipts")
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiOperation({ summary: "List every EIMS receipt filed against this invoice, newest first" })
listReceipts(@Param("id", ParseUUIDPipe) id: string) {
return this.receipts.listReceipts(id);
}
@Get(":id/eims/receipts/:receiptId/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed receipt PDF (RRN + QR) for a filed EIMS receipt" })
async receiptDocument(
@Param("id", ParseUUIDPipe) id: string,
@Param("receiptId", ParseUUIDPipe) receiptId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.receipts.document(id, receiptId);
sendPdf(res, filename, buffer);
}
}