Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts
2026-08-12 14:12:23 +00:00

747 lines
28 KiB
TypeScript

import { BadRequestException, ConflictException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DataSource } from "typeorm";
import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
import { EimsSystemState } from "./entities/eims-system-state.entity";
import { EimsInvoiceStatus } from "./eims-registration.types";
const SYSTEM_NUMBER = "B0360154BA";
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
const config = (over: Partial<EimsConfig["invoice"]> = {}): EimsConfig =>
({
enabled: true,
baseUrl: "https://core.mor.gov.et",
clientId: "cid",
clientSecret: "secret",
apiKey: "key",
tin: "0000034558",
systemNumber: SYSTEM_NUMBER,
systemType: "SYS",
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
invoice: eimsInvoiceConfig(over),
}) as EimsConfig;
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
({
id: INVOICE_ID,
invoiceNumber: "INV-20260807-00042",
currency: "ETB",
issuedAt: new Date(2026, 7, 7, 9, 5, 3),
totalAmount: "10000.00",
eimsStatus: EimsInvoiceStatus.NotSubmitted,
eimsIrn: null,
eimsInvoiceCounter: null,
eimsSubmittedAt: null,
eimsAckDate: null,
eimsLastError: null,
company: {
name: "ABC Trading PLC",
tin: "0999930000",
vatNumber: "123475885858",
phone: "0912345678",
email: "buyer@abc.et",
region: "13",
zone: "SHA",
woreda: "574",
kebele: "03",
houseNo: "NEW",
country: "Ethiopia",
},
...over,
}) as unknown as Invoice;
const LINES = [
{
chargeType: "RAIL_FREIGHT",
description: "Addis to Djibouti",
quantity: "1.00",
unitRate: "10000.00",
amount: "10000.00",
},
];
/**
* In-memory stand-in for the two locked rows. `update` merges, `createQueryBuilder(...).getOne()`
* returns the live object — enough to assert ordering, values and the reservation lifecycle without
* a database.
*/
class FakeDb {
invoices = new Map<string, Invoice>();
state: EimsSystemState | null = null;
/** Runs before every transaction body, to simulate a concurrent writer. */
onTransaction: (() => void) | null = null;
/** `sendCompanyChannels`'s contact lookup, when a test needs it non-empty. */
companyContact: { phone: string | null; email: string | null } | null = null;
constructor(invoices: Invoice[], state?: Partial<EimsSystemState>) {
for (const inv of invoices) this.invoices.set(inv.id, inv);
this.state = {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
nextDocumentNumber: 5,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
...state,
} as EimsSystemState;
}
private manager = {
createQueryBuilder: (entity: unknown) => {
const isInvoice = entity === Invoice;
let id: string | undefined;
const builder = {
setLock: () => builder,
where: (_clause: string, params: Record<string, string>) => {
id = params.invoiceId ?? params.systemNumber;
return builder;
},
getOne: async () => (isInvoice ? (this.invoices.get(id!) ?? null) : this.state),
};
return builder;
},
findOne: async (_entity: unknown, options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
update: async (entity: unknown, id: string, patch: Record<string, unknown>) => {
if (entity === Invoice) Object.assign(this.invoices.get(id)!, patch);
else Object.assign(this.state!, patch);
},
query: async () => [],
getRepository: () => ({
findOne: async (options: { where: { id: string } }) =>
this.invoices.get(options.where.id) ?? null,
}),
};
asDataSource(): DataSource {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async (sql: string) => {
if (sql.includes("eims_system_state")) {
return [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }];
}
if (sql.includes("freight.companies")) return this.companyContact ? [this.companyContact] : [];
return LINES;
},
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
},
} as unknown as DataSource;
}
}
/** The source system comes from the access token, so the service is handed a session, not config. */
const SESSION = { systemNumber: SYSTEM_NUMBER, systemType: "SYS" };
const build = (
db: FakeDb,
postSigned: jest.Mock,
cfg: EimsConfig = config(),
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
directSend: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer } as unknown as EimsClientService,
{
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
{ directSend } as unknown as NotificationsService,
);
/**
* Document number the fixtures register under; `/v1/verify` must echo it back.
*
* A plain integer, not our `invoiceNumber`: MoR validates the field against
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
*/
const DOCUMENT_NUMBER = "5";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
* `irn`.
*
* The fixture is deliberately *coherent* — same IRN on both sides. The supplied Postman collection
* pairs a saved request and a saved response whose literal IRNs disagree, which is an artefact of
* the mock rather than gateway behaviour; asserting against that inconsistency would encode the
* mock's bug as a requirement. Resolution requires the returned `Irn` to match the one asked for,
* and these fixtures exercise that honestly.
*/
const verifyResponse = (over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "SUCCESS",
body: {
Irn: IRN,
TransactionType: "B2B",
DocumentDetails: { Type: "INV", DocumentNumber: DOCUMENT_NUMBER, Date: "07-08-2026T09:05:03" },
Version: "1",
...over,
},
});
const okResponse = (irn = IRN, over: Record<string, unknown> = {}) => ({
statusCode: 200,
message: "SUCCESS",
body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]", ...over },
});
const apiError = (kind: string, status?: number) =>
new EimsApiException(kind as never, `EIMS register failed (${status ?? "-"})`, status);
describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
it("registers, persists the IRN and advances the chain", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(postSigned).toHaveBeenCalledTimes(1);
expect(postSigned.mock.calls[0][0]).toBe("/v1/register");
expect(view).toMatchObject({
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: IRN,
eimsInvoiceCounter: 7,
eimsAckDate: "2026-08-07T09:05:03Z[Etc/UTC]",
});
expect(db.state).toMatchObject({
previousIrn: IRN,
nextInvoiceCounter: 8,
inFlightInvoiceId: null,
inFlightCounter: null,
blockedReason: null,
});
});
it("persists signedQR alongside the IRN", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse(IRN, { signedQR: "signed-qr-payload" }));
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBe("signed-qr-payload");
expect(db.invoices.get(INVOICE_ID)?.eimsSignedQr).toBe("signed-qr-payload");
});
it("leaves eimsSignedQr null when the gateway does not return one", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsSignedQr).toBeNull();
});
it("notifies the buyer company on a successful registration, without blocking the result", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: "buyer@abc.et" };
const directSend = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
expect(directSend).toHaveBeenCalledWith("sms", "+251911000000", expect.stringContaining(IRN));
expect(directSend).toHaveBeenCalledWith("email", "buyer@abc.et", expect.stringContaining(IRN));
});
it("does not fail registration when the buyer notification itself fails", async () => {
const db = new FakeDb([invoiceRow({ companyId: "company-1" } as Partial<Invoice>)]);
db.companyContact = { phone: "+251911000000", email: null };
const directSend = jest.fn().mockRejectedValue(new Error("sms provider down"));
const postSigned = jest.fn().mockResolvedValue(okResponse());
const view = await build(db, postSigned, config(), jest.fn(), undefined, undefined, directSend)
.registerInvoiceWithEims(INVOICE_ID);
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Registered);
});
it("sends the exact reserved counter and previous IRN to the mapper", async () => {
const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 42, previousIrn: "PRIOR-IRN" });
const postSigned = jest.fn().mockResolvedValue(okResponse());
await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
it("takes SourceSystem from the token session, not from configuration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue(okResponse());
// Config disagrees on purpose: only the session may reach the wire.
const cfg = config();
(cfg as { systemNumber: string }).systemNumber = "CONFIG-ONLY";
(cfg as { systemType: string }).systemType = "MAN";
await build(
db,
postSigned,
cfg,
jest.fn(),
jest.fn().mockResolvedValue({ systemNumber: "FROM-TOKEN", systemType: "POS" }),
).registerInvoiceWithEims(INVOICE_ID);
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.SystemNumber).toBe("FROM-TOKEN");
expect(request.SourceSystem.SystemType).toBe("POS");
});
it("does not consume a counter when authentication fails", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn();
const getSessionContext = jest.fn().mockRejectedValue(new Error("login failed"));
await expect(
build(db, postSigned, config(), jest.fn(), getSessionContext).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/login failed/);
expect(postSigned).not.toHaveBeenCalled();
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
});
it("is idempotent — an invoice with an IRN never reaches EIMS", async () => {
const db = new FakeDb([
invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }),
]);
const postSigned = jest.fn();
const view = await build(db, postSigned).registerInvoiceWithEims(INVOICE_ID);
expect(postSigned).not.toHaveBeenCalled();
expect(view.eimsIrn).toBe(IRN);
});
it("lets only one of two concurrent calls reach EIMS", async () => {
const db = new FakeDb([invoiceRow()]);
let resolvePost: (v: unknown) => void = () => {};
const postSigned = jest
.fn()
.mockImplementation(() => new Promise((resolve) => (resolvePost = resolve)));
const service = build(db, postSigned);
const first = service.registerInvoiceWithEims(INVOICE_ID);
// Let the first reservation commit and its HTTP call start; it is now parked on `resolvePost`.
await new Promise((resolve) => setImmediate(resolve));
expect(postSigned).toHaveBeenCalledTimes(1);
const second = service.registerInvoiceWithEims(INVOICE_ID);
await expect(second).rejects.toBeInstanceOf(ConflictException);
resolvePost(okResponse());
await first;
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("blocks a different invoice while a submission is in flight (survives a restart)", async () => {
// A committed reservation left behind by a dead process.
const db = new FakeDb(
[
invoiceRow({ eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: 7 }),
invoiceRow({ id: OTHER_INVOICE_ID, invoiceNumber: "INV-20260807-00043" }),
],
{ inFlightInvoiceId: INVOICE_ID, inFlightCounter: 7, nextInvoiceCounter: 8 },
);
const postSigned = jest.fn();
await expect(
build(db, postSigned).registerInvoiceWithEims(OTHER_INVOICE_ID),
).rejects.toThrow(/already in flight/);
expect(postSigned).not.toHaveBeenCalled();
});
it("fails locally on incomplete tax configuration, with zero HTTP calls", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn();
await expect(
build(db, postSigned, config({ taxCode: "", taxRatePercent: null })).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(postSigned).not.toHaveBeenCalled();
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.NotSubmitted);
expect(db.state).toMatchObject({ nextInvoiceCounter: 7, inFlightInvoiceId: null });
});
it.each([
["SCHEMA_VALIDATION", 400],
["RULE_VALIDATION", 406],
])("marks %s (%i) FAILED and clears the global block", async (kind, status) => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockRejectedValue(apiError(kind, status));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Failed,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
// Returned, not consumed: MoR tracks the sequence and rejects a gap
// ("Invoice counter is not correct. expected : 1").
nextInvoiceCounter: 7,
});
});
it("treats a success response with no IRN as a failed registration", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
/returned no IRN/,
);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
expect(db.state).toMatchObject({ inFlightInvoiceId: null, blockedReason: null });
});
it("marks a timeout UNKNOWN and keeps the system blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state!.inFlightInvoiceId).toBe(INVOICE_ID);
expect(db.state!.blockedReason).toMatch(/never acknowledged/);
expect(db.state!.previousIrn).toBeNull();
});
it("an UNKNOWN result blocks a different invoice too", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest.fn().mockRejectedValueOnce(apiError("TIMEOUT"));
const service = build(db, postSigned);
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
await expect(service.registerInvoiceWithEims(OTHER_INVOICE_ID)).rejects.toThrow(
/registration is blocked/,
);
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("returns both the counter and the document number after a refusal, keeps both after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
.mockRejectedValueOnce(apiError("RULE_VALIDATION", 406))
.mockResolvedValueOnce(okResponse());
const service = build(db, postSigned);
await expect(service.registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
EimsApiException,
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// A deterministic rejection rolls both numbers back — MoR's expected-next-value for either
// sequence only advances on acceptance (rule 7001 for DocumentNumber, same as InvoiceCounter).
const first = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
const second = postSigned.mock.calls[1][1] as EimsInvoiceRequest;
expect(first.SourceSystem.InvoiceCounter).toBe(7);
expect(second.SourceSystem.InvoiceCounter).toBe(7);
expect(first.DocumentDetails.DocumentNumber).toBe("5");
expect(second.DocumentDetails.DocumentNumber).toBe("5");
});
it("blocks and alerts with the real IRN when MoR accepts but persisting it locally fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockResolvedValue(okResponse());
// 1st/2nd calls are the reserve() updates; the 3rd is settleSuccess's invoice update — the one
// that actually failed live (eims_irn too narrow for the real value MoR returned).
let call = 0;
const manager = (db as unknown as { manager: { update: jest.Mock } }).manager;
const realUpdate = manager.update;
manager.update = jest.fn(async (entity: unknown, id: string, patch: Record<string, unknown>) => {
call++;
if (call === 3) throw new Error("value too long for type character varying(64)");
return realUpdate(entity, id, patch);
});
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/value too long/);
// The IRN is never lost, even though the normal success path never committed.
expect(db.state?.blockedReason).toContain(IRN);
expect(db.state?.blockedReason).toContain("ACCEPTED");
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Submitting);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.body).toContain(IRN);
});
});
describe("EimsInvoiceRegistrationService staff alerting", () => {
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.title).toMatch(/blocked/i);
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
});
it("raises a normal-priority alert for a deterministic rejection", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
});
it("does not alert on a successful filing", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn();
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
.registerInvoiceWithEims(INVOICE_ID);
expect(notify).not.toHaveBeenCalled();
});
it("lets the filing outcome stand even if the alert itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/EIMS register failed \(406\)/);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
});
});
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
it("verifies the stored IRN over the unsigned bearer transport", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postSigned = jest.fn();
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
INVOICE_ID,
);
// Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched.
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(postSigned).not.toHaveBeenCalled();
expect(result.body).toMatchObject({ Irn: IRN });
});
it("rejects a 200 that carries no Irn", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
await expect(
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/returned no Irn/);
});
it("refuses to verify an invoice with no IRN", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
const postBearer = jest.fn();
await expect(
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/no EIMS IRN to verify/);
expect(postBearer).not.toHaveBeenCalled();
});
});
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsInvoiceCounter: 7,
eimsDocumentNumber: DOCUMENT_NUMBER,
}),
],
{
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
blockedReason: "never acknowledged",
});
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
INVOICE_ID,
{ irn: IRN },
);
// The IRN is confirmed at the gateway before it is ever written.
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
expect(db.state).toMatchObject({
previousIrn: IRN,
inFlightInvoiceId: null,
blockedReason: null,
});
});
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
const db = blocked();
const postBearer = jest
.fn()
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/answered the lookup for IRN/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: INVOICE_ID,
blockedReason: "never acknowledged",
previousIrn: null,
});
});
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsIrn: null,
});
expect(db.state).toMatchObject({
inFlightInvoiceId: INVOICE_ID,
blockedReason: "never acknowledged",
previousIrn: null,
});
});
it("refuses an IRN the gateway does not acknowledge at all", async () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/returned no Irn/);
expect(db.state!.blockedReason).toBe("never acknowledged");
});
it("discards the attempt, leaving the chain where it was", async () => {
const db = blocked();
const postBearer = jest.fn();
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
INVOICE_ID,
{ discard: true },
);
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null });
expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm
expect(db.state).toMatchObject({
previousIrn: null,
inFlightInvoiceId: null,
blockedReason: null,
});
});
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(
OTHER_INVOICE_ID,
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
);
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, {
irn: IRN,
}),
).rejects.toThrow(/in-flight EIMS submission is invoice/);
});
it("requires either an IRN or an explicit discard", async () => {
await expect(
build(blocked(), jest.fn()).resolveEimsRegistration(INVOICE_ID, {}),
).rejects.toBeInstanceOf(BadRequestException);
});
});