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.
This commit is contained in:
Hagernesh
2026-08-17 17:33:58 +00:00
parent 8ed5642544
commit 7d8ab932c2
5 changed files with 294 additions and 1 deletions

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator";
export class BulkCancelEimsItemDto {
@ApiProperty({ description: "Invoice ID to cancel." })
@IsUUID()
invoiceId!: string;
@ApiProperty({
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
example: "1",
})
@IsString()
@Length(1, 8)
reasonCode!: string;
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
@IsOptional()
@IsString()
@Length(0, 500)
remark?: string;
}
/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */
export class BulkCancelEimsRegistrationDto {
@ApiProperty({ type: [BulkCancelEimsItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BulkCancelEimsItemDto)
items!: BulkCancelEimsItemDto[];
}

View File

@@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors";
import { EimsInvoiceStatus } from "./eims-registration.types";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06";
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
@@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
});
});
describe("EimsCancellationService.cancelBulkWithEims", () => {
it("cancels every eligible invoice in one call, matching results back by IRN", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
{ id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" },
]);
expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [
{ Irn: IRN, ReasonCode: "1", Remark: "" },
{ Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
]);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
// Bulk success carries no cancellationDate at all, unlike single cancel.
expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull();
});
it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => {
const db = new FakeDb([
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }),
invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }),
]);
const postBearer = jest.fn();
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(postBearer).not.toHaveBeenCalled();
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") },
]);
});
it("a mix of MoR success and rejection only updates the succeeding invoice", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
{ Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN },
],
});
const results = await build(db, postBearer).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(results).toEqual([
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
{ invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." },
]);
});
it("makes no HTTP call at all when every item fails the local eligibility check", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
const postBearer = jest.fn();
await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]);
expect(postBearer).not.toHaveBeenCalled();
});
it("notifies the buyer only for invoices that actually got cancelled", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockResolvedValue(undefined);
const postBearer = jest.fn().mockResolvedValue({
statusCode: 200,
body: [
{ status: "C", Irn: IRN },
{ Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN },
],
});
await build(db, postBearer, directSend).cancelBulkWithEims([
{ invoiceId: INVOICE_ID, reasonCode: "1" },
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
]);
expect(directSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -6,7 +6,15 @@ 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 { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
import {
EimsBulkCancelItemResult,
EimsBulkCancelRequest,
EimsBulkCancelResponse,
EimsCancelRequest,
EimsCancelResponse,
EimsInvoiceStatus,
EimsInvoiceStatusView,
} from "./eims-registration.types";
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
/**
@@ -92,6 +100,102 @@ export class EimsCancellationService {
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`);

View File

@@ -5,6 +5,7 @@ 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";
@@ -89,6 +90,17 @@ export class EimsInvoiceController {
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" })

View File

@@ -89,6 +89,46 @@ export interface EimsCancelResponse {
body?: EimsCancelResponseBody;
}
/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */
export type EimsBulkCancelRequest = EimsCancelRequest[];
/**
* One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same
* array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status`
* (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no
* `cancellationDate` at all.
*/
export interface EimsBulkCancelSuccessItem {
id?: number;
tin?: string;
status: string;
mode?: string;
Irn: string;
ReasonCode?: string;
Remark?: string;
}
export interface EimsBulkCancelErrorItem {
Status: string;
msg: string;
Irn: string;
}
export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem;
export interface EimsBulkCancelResponse {
statusCode?: number;
message?: string;
body?: EimsBulkCancelResponseItem[];
}
/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */
export interface EimsBulkCancelItemResult {
invoiceId: string;
success: boolean;
message: string;
}
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
export interface EimsInvoiceError {
kind: string;