mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user