mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
MoR stamps systemNumber and systemType into the access token it issues for the authenticating credentials, which makes the token the authority on them. Registration now reads both from there instead of from configuration, so the SourceSystem block cannot drift from what the gateway believes we are. EimsAuthService decodes the token payload after login, requires both claims to be non-empty, and exposes them through getSessionContext(). The token is decoded but never verified -- it is MoR's, signed with MoR's key -- and is kept out of the log line, which names only the system it identified. EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather than inputs: when set they are compared against the claims and a mismatch fails fast, so neither side silently wins. Neither is required to register any more. Registration and manual resolution both resolve the session before touching the state row, which is keyed by the system number: a login failure now costs nothing because no counter has been reserved yet. Test fixtures move to eims-test-fixtures.ts. They previously lived in eims-auth.service.spec.ts, which made jest execute that suite again inside every importing spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
566 lines
20 KiB
TypeScript
566 lines
20 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 { 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;
|
|
|
|
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,
|
|
previousIrn: null,
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: 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 () => 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 = jest.fn().mockResolvedValue(SESSION),
|
|
) =>
|
|
new EimsInvoiceRegistrationService(
|
|
db.asDataSource(),
|
|
{ get: () => cfg } as unknown as ConfigService,
|
|
{ postSigned, postBearer } as unknown as EimsClientService,
|
|
{ getSessionContext } as unknown as EimsAuthService,
|
|
);
|
|
|
|
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
|
|
const DOCUMENT_NUMBER = "INV-20260807-00042";
|
|
|
|
/**
|
|
* `/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) =>
|
|
({ statusCode: 200, message: "SUCCESS", body: { irn, ackDate: "2026-08-07T09:05:03Z[Etc/UTC]" } });
|
|
|
|
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("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("INV-20260807-00042");
|
|
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,
|
|
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
|
|
});
|
|
});
|
|
|
|
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("never reuses a counter once an attempt has begun", 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);
|
|
|
|
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
|
|
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
|
|
});
|
|
});
|
|
|
|
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 })], {
|
|
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: "INV-20260807-99999" },
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
|
|
).rejects.toThrow(/not INV-20260807-00042/);
|
|
|
|
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 }));
|
|
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);
|
|
});
|
|
});
|