feat(eims): take the source system from the access token

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>
This commit is contained in:
Hagernesh
2026-08-07 13:50:05 +00:00
parent eadecf3fcf
commit 2e7ef40d9e
8 changed files with 391 additions and 88 deletions

View File

@@ -135,7 +135,9 @@ EIMS_CLIENT_ID=
EIMS_CLIENT_SECRET=
EIMS_API_KEY=
EIMS_TIN=
# MoR-issued source-system identifiers (used once invoice registration lands)
# Source-system identity comes from the access token's systemNumber/systemType claims.
# Setting these turns them into expected-value checks: a mismatch against the token fails
# fast rather than one side silently winning. Leave empty to take the gateway's word.
EIMS_SYSTEM_NUMBER=
EIMS_SYSTEM_TYPE=
# Absolute paths to the INSA-issued credentials. Keep them OUTSIDE the repo; the file

View File

@@ -16,7 +16,14 @@ export interface EimsConfig {
clientSecret: string;
apiKey: string;
tin: string;
/** MoR-issued source-system identifiers; unused until invoice registration lands. */
/**
* Optional *expectations* for the source-system identity, not inputs.
*
* The access token MoR issues carries `systemNumber` and `systemType` claims for the credentials
* that authenticated, and those are what registration uses. When these are set they are compared
* against the token and a mismatch fails fast — neither side silently wins. Leave them empty to
* take whatever the gateway says.
*/
systemNumber: string;
systemType: string;
/** Filesystem path to the INSA-issued RSA private key (PEM). Never leaves the server. */

View File

@@ -2,57 +2,18 @@ import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { AxiosError, AxiosHeaders } from "axios";
import { of, throwError } from "rxjs";
import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config";
import { EimsConfig } from "../../config/eims.config";
import { eimsConfig, eimsToken } from "./eims-test-fixtures";
import { EimsAuthService } from "./eims-auth.service";
import { EimsSignerService } from "./eims-signer.service";
const CLIENT_SECRET = "super-secret-value";
const API_KEY = "super-secret-apikey";
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => ({
enabled: true,
baseUrl: "https://core.mor.gov.et",
clientId: "cid",
clientSecret: CLIENT_SECRET,
apiKey: API_KEY,
tin: "0000034558",
systemNumber: "B0360154BA",
systemType: "SYS",
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
invoice: eimsInvoiceConfig(),
...over,
});
const cfg = (over: Partial<EimsConfig> = {}): EimsConfig => eimsConfig(over);
/** Authentication never reads these; they exist so the fixture satisfies EimsConfig. */
export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsInvoiceConfig => ({
sellerLegalName: "Ethio-Djibouti Railway S.C.",
sellerVatNumber: "0000000000",
sellerPhone: "0911223344",
sellerEmail: "finance@example.et",
sellerRegion: "13",
sellerWereda: "574",
sellerCity: null,
sellerSubCity: null,
sellerHouseNumber: null,
sellerLocality: null,
taxCode: "VAT15",
taxRatePercent: 15,
exciseTaxValue: 0,
incomeWithholdValue: 0,
transactionWithholdValue: 0,
transactionType: "B2B",
natureOfSupplies: "Service",
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
cashierName: null,
salesPersonName: null,
...over,
});
const TOKEN_1 = eimsToken({ jti: "one" });
const TOKEN_2 = eimsToken({ jti: "two" });
const loginBody = (accessToken: string, expiresIn = 3600) => ({
data: { accessToken, refreshToken: "refresh-1", encryptionKey: null, expiresIn },
@@ -82,7 +43,7 @@ const axiosErr = (status: number, data: unknown) =>
describe("EimsAuthService.getValidAccessToken", () => {
it("posts the signed login envelope to /auth/login with no Authorization header", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await build(post).getValidAccessToken();
@@ -101,38 +62,38 @@ describe("EimsAuthService.getValidAccessToken", () => {
});
it("returns the access token from data.accessToken", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
await expect(build(post).getValidAccessToken()).resolves.toBe("token-1");
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await expect(build(post).getValidAccessToken()).resolves.toBe(TOKEN_1);
});
it("reuses a cached token instead of logging in again", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
await auth.getValidAccessToken();
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
expect(post).toHaveBeenCalledTimes(1);
});
it("re-authenticates a skew-window before the token actually expires", async () => {
const post = jest
.fn()
.mockReturnValueOnce(of({ data: loginBody("token-1", 100) })) // 100s ttl, 45s skew ⇒ usable 55s
.mockReturnValueOnce(of({ data: loginBody("token-2") }));
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1, 100) })) // 100s ttl, 45s skew ⇒ usable 55s
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
const auth = build(post);
const start = Date.now();
const clock = jest.spyOn(Date, "now");
try {
clock.mockReturnValue(start);
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
clock.mockReturnValue(start + 50_000); // inside the window: still cached
await expect(auth.getValidAccessToken()).resolves.toBe("token-1");
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_1);
expect(post).toHaveBeenCalledTimes(1);
clock.mockReturnValue(start + 56_000); // past ttl-minus-skew, before the real 100s expiry
await expect(auth.getValidAccessToken()).resolves.toBe("token-2");
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
expect(post).toHaveBeenCalledTimes(2);
} finally {
clock.mockRestore();
@@ -142,24 +103,38 @@ describe("EimsAuthService.getValidAccessToken", () => {
it("logs in again after invalidate()", async () => {
const post = jest
.fn()
.mockReturnValueOnce(of({ data: loginBody("token-1") }))
.mockReturnValueOnce(of({ data: loginBody("token-2") }));
.mockReturnValueOnce(of({ data: loginBody(TOKEN_1) }))
.mockReturnValueOnce(of({ data: loginBody(TOKEN_2) }));
const auth = build(post);
await auth.getValidAccessToken();
auth.invalidate();
await expect(auth.getValidAccessToken()).resolves.toBe("token-2");
await expect(auth.getValidAccessToken()).resolves.toBe(TOKEN_2);
expect(post).toHaveBeenCalledTimes(2);
});
it("performs exactly one login for many concurrent callers", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("token-1") }));
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
const tokens = await Promise.all(Array.from({ length: 20 }, () => auth.getValidAccessToken()));
expect(post).toHaveBeenCalledTimes(1);
expect(new Set(tokens)).toEqual(new Set(["token-1"]));
expect(new Set(tokens)).toEqual(new Set([TOKEN_1]));
});
it("does not put the access token in its own log line", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const logged: string[] = [];
const auth = build(post);
jest
.spyOn(auth["logger"], "log")
.mockImplementation((message: unknown) => void logged.push(String(message)));
await auth.getValidAccessToken();
expect(logged.join("\n")).not.toContain(TOKEN_1);
expect(logged.join("\n")).toContain("B0360154BA");
});
it("refuses to call the gateway when EIMS is disabled", async () => {
@@ -217,3 +192,65 @@ describe("EimsAuthService.getValidAccessToken", () => {
await expect(build(post).getValidAccessToken()).rejects.toThrow(/could not reach the gateway/);
});
});
describe("EimsAuthService.getSessionContext", () => {
it("takes the source system from the token's claims", async () => {
const post = jest
.fn()
.mockReturnValue(
of({ data: loginBody(eimsToken({ systemNumber: "FROM-TOKEN", systemType: "POS" })) }),
);
// Env deliberately left empty: with nothing to check against, the token is simply believed.
await expect(
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
).resolves.toEqual({ systemNumber: "FROM-TOKEN", systemType: "POS" });
});
it("serves the session from the cached login rather than re-authenticating", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
const auth = build(post);
await auth.getSessionContext();
await expect(auth.getSessionContext()).resolves.toEqual({
systemNumber: "B0360154BA",
systemType: "SYS",
});
expect(post).toHaveBeenCalledTimes(1);
});
it.each(["systemNumber", "systemType"])("rejects a token with no %s claim", async (claim) => {
const post = jest
.fn()
.mockReturnValue(of({ data: loginBody(eimsToken({ [claim]: undefined })) }));
await expect(
build(post, cfg({ systemNumber: "", systemType: "" })).getSessionContext(),
).rejects.toThrow(new RegExp(`no ${claim} claim`));
});
it("rejects an access token that is not a decodable JWT", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody("not-a-jwt") }));
await expect(build(post).getSessionContext()).rejects.toThrow(/not a JWT/);
});
it.each([
["systemNumber", { systemNumber: "SOMETHING-ELSE" }, /EIMS_SYSTEM_NUMBER=B0360154BA/],
["systemType", { systemType: "POS" }, /EIMS_SYSTEM_TYPE=SYS/],
])("fails fast when the configured %s disagrees with the token", async (_name, over, pattern) => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(eimsToken(over)) }));
// cfg() sets EIMS_SYSTEM_NUMBER=B0360154BA and EIMS_SYSTEM_TYPE=SYS as expectations.
await expect(build(post).getSessionContext()).rejects.toThrow(pattern);
});
it("accepts a configured value that matches the token", async () => {
const post = jest.fn().mockReturnValue(of({ data: loginBody(TOKEN_1) }));
await expect(build(post).getSessionContext()).resolves.toEqual({
systemNumber: "B0360154BA",
systemType: "SYS",
});
});
});

View File

@@ -11,8 +11,43 @@ interface TokenCache {
accessToken: string;
/** Epoch ms, already reduced by the configured skew. */
expiresAt: number;
session: EimsSessionContext;
}
/**
* Source-system identity, taken from the access token MoR issues us.
*
* The gateway stamps `systemNumber` and `systemType` into the token for the credentials that
* authenticated, which makes the token the authority on them — not our environment file. Anything
* we configured locally can only ever disagree with what MoR believes.
*/
export interface EimsSessionContext {
systemNumber: string;
systemType: string;
}
/** Decode a JWT payload without verifying it: this is MoR's token, signed with MoR's key. */
function decodeTokenClaims(accessToken: string): Record<string, unknown> {
const payload = accessToken.split(".")[1];
if (!payload) {
throw new EimsApiException("UNKNOWN", "EIMS access token is not a JWT (no payload segment)");
}
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
} catch (err) {
// The token itself is never included — only that its payload would not parse.
throw new EimsApiException(
"UNKNOWN",
`EIMS access token payload could not be decoded: ${(err as Error).message}`,
);
}
}
const claimString = (claims: Record<string, unknown>, name: string): string => {
const value = claims[name];
return typeof value === "string" ? value.trim() : "";
};
/** Used when the gateway omits `expiresIn`; the observed value is 3600. */
const FALLBACK_EXPIRES_IN_SECONDS = 3600;
@@ -57,11 +92,64 @@ export class EimsAuthService {
}
}
/**
* The source-system identity MoR issued this session, refreshing the login if needed.
*
* This is the authority for `SourceSystem.SystemNumber` / `SystemType`: the gateway stamps both
* into the access token for the authenticating credentials, so a local env value could only ever
* disagree with it.
*/
async getSessionContext(): Promise<EimsSessionContext> {
await this.getValidAccessToken();
return this.cache!.session;
}
/** Drop the cached token — called after a 401 so the next request re-authenticates. */
invalidate(): void {
this.cache = null;
}
/**
* Read the source-system claims out of the token, and cross-check anything configured locally.
*
* `EIMS_SYSTEM_NUMBER` / `EIMS_SYSTEM_TYPE` are optional expectations, not inputs: when set they
* are compared and a mismatch fails immediately rather than one silently winning. Registering
* under the wrong source system is not something to discover from a rejected invoice.
*/
private readSessionContext(accessToken: string, cfg: EimsConfig): EimsSessionContext {
const claims = decodeTokenClaims(accessToken);
const systemNumber = claimString(claims, "systemNumber");
const systemType = claimString(claims, "systemType");
const missing = [
!systemNumber && "systemNumber",
!systemType && "systemType",
].filter(Boolean);
if (missing.length > 0) {
throw new EimsApiException(
"UNKNOWN",
`EIMS access token carries no ${missing.join(" or ")} claim; cannot identify the source system`,
);
}
const mismatches = [
cfg.systemNumber && cfg.systemNumber !== systemNumber
? `EIMS_SYSTEM_NUMBER=${cfg.systemNumber} but the token says ${systemNumber}`
: null,
cfg.systemType && cfg.systemType !== systemType
? `EIMS_SYSTEM_TYPE=${cfg.systemType} but the token says ${systemType}`
: null,
].filter(Boolean);
if (mismatches.length > 0) {
throw new EimsConfigException(
`EIMS source-system configuration disagrees with the issued token: ${mismatches.join("; ")}. ` +
"Correct the environment or the credentials — neither value is assumed to win.",
);
}
return { systemNumber, systemType };
}
private async login(): Promise<string> {
const cfg = this.cfg;
if (!cfg.enabled) {
@@ -106,11 +194,19 @@ export class EimsAuthService {
// examples of calls that do require signing, so whether refresh must be signed is unconfirmed.
// Until MoR confirms it, an expired token just triggers a fresh login — `expiresIn` is 3600s,
// so that is one extra call an hour.
// Reject the session before caching it: a token we cannot identify a source system from is
// useless for registration, and a configured expectation that disagrees is a deployment fault.
const session = this.readSessionContext(accessToken, cfg);
this.cache = {
accessToken,
expiresAt: Date.now() + Math.max(expiresIn * 1000 - cfg.tokenSkewMs, 1000),
session,
};
this.logger.log(`EIMS login succeeded; token cached for ~${expiresIn}s`);
this.logger.log(
`EIMS login succeeded; token cached for ~${expiresIn}s ` +
`(system ${session.systemNumber}, type ${session.systemType})`,
);
return accessToken;
}
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException } from "@nestjs/common";
import { EimsConfig } from "../../config/eims.config";
import { EimsSessionContext } from "./eims-auth.service";
import {
EimsMapperContext,
EimsMapperLine,
@@ -21,10 +22,10 @@ interface RequiredSpec {
value: string | number | null | undefined;
}
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: string, systemType: string): RequiredSpec[] => [
// `systemNumber` / `systemType` are absent by design: they come from the access token, which is
// MoR's own statement of who we are. See EimsAuthService.getSessionContext.
const REQUIRED = (invoice: EimsConfig["invoice"], tin: string): RequiredSpec[] => [
{ env: "EIMS_TIN", value: tin },
{ env: "EIMS_SYSTEM_NUMBER", value: systemNumber },
{ env: "EIMS_SYSTEM_TYPE", value: systemType },
{ env: "EIMS_SELLER_LEGAL_NAME", value: invoice.sellerLegalName },
{ env: "EIMS_SELLER_VAT_NUMBER", value: invoice.sellerVatNumber },
{ env: "EIMS_SELLER_PHONE", value: invoice.sellerPhone },
@@ -44,7 +45,7 @@ const REQUIRED = (invoice: EimsConfig["invoice"], tin: string, systemNumber: str
/** Throws naming every unset variable at once, so one round trip fixes the whole configuration. */
export function assertEimsInvoiceConfig(config: EimsConfig): void {
const missing = REQUIRED(config.invoice, config.tin, config.systemNumber, config.systemType)
const missing = REQUIRED(config.invoice, config.tin)
.filter(({ value }) => value === null || value === undefined || value === "")
.map(({ env }) => env);
@@ -80,6 +81,8 @@ export interface EimsContextInput {
documentNumber: string;
invoiceCounter: number;
previousIrn: string | null;
/** Source-system identity from the access token, never from configuration. */
session: EimsSessionContext;
/** Required when the invoice currency is not ETB. */
exchangeRate?: number | null;
}
@@ -92,8 +95,8 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
const exciseTaxValue = invoice.exciseTaxValue ?? 0;
return {
systemNumber: config.systemNumber,
systemType: config.systemType,
systemNumber: input.session.systemNumber,
systemType: input.session.systemType,
documentNumber: input.documentNumber,
invoiceCounter: input.invoiceCounter,
previousIrn: input.previousIrn,

View File

@@ -5,7 +5,8 @@ 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-auth.service.spec";
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";
@@ -138,25 +139,35 @@ class FakeDb {
}
}
/** 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 uses `irn`, and
* the collection's own fixture uses a *different* example value on each side — so nothing here
* assumes the two match.
* `/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,
@@ -213,6 +224,43 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
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 }),
@@ -377,17 +425,6 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
expect(result.body).toMatchObject({ Irn: IRN });
});
it("accepts a response whose Irn differs from the one sent", async () => {
// The supplied collection's own fixture does exactly this; equality would assert a property
// of the mock, not of the gateway.
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postBearer = jest.fn().mockResolvedValue(verifyResponse({ Irn: "a-different-irn" }));
await expect(
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).resolves.toMatchObject({ body: { Irn: "a-different-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: " " } });
@@ -436,6 +473,27 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
});
});
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(

View File

@@ -17,6 +17,7 @@ import {
EimsMapperLine,
toEimsInvoice,
} from "../billing/eims-invoice.mapper";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
import { EimsSystemState } from "./entities/eims-system-state.entity";
@@ -70,6 +71,7 @@ export class EimsInvoiceRegistrationService {
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
) {}
private get cfg(): EimsConfig {
@@ -84,7 +86,11 @@ export class EimsInvoiceRegistrationService {
const invoice = await this.loadInvoiceForMapping(invoiceId);
if (invoice.eimsIrn) return this.toView(invoice);
const reservation = await this.reserve(invoiceId, cfg.systemNumber);
// Authenticate before reserving: the source system comes from the token, and the state row is
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
const session = await this.auth.getSessionContext();
const reservation = await this.reserve(invoiceId, session.systemNumber);
if (!reservation) return this.getEimsStatus(invoiceId);
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
@@ -96,6 +102,7 @@ export class EimsInvoiceRegistrationService {
documentNumber: invoice.invoiceNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
}),
);
@@ -164,18 +171,33 @@ export class EimsInvoiceRegistrationService {
}
/**
* Refuse a manual resolution unless the gateway agrees the IRN belongs to this invoice.
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The check is on `DocumentDetails.DocumentNumber`, which registration set from our own
* `invoiceNumber`. That is the only field tying an IRN back to a row in this database.
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` the only field tying an IRN back to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
* than warnings.
*/
private async assertIrnBelongsToInvoice(
irn: string,
expectedDocumentNumber: string,
): Promise<void> {
const response = await this.queryVerify(irn);
const returnedIrn = response.body?.Irn?.trim();
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
if (returnedIrn !== irn) {
throw new ConflictException({
code: "EIMS_RESOLVE_IRN_MISMATCH",
message:
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
"Refusing to record it — recheck the IRN in the MoR portal.",
});
}
if (documentNumber !== expectedDocumentNumber) {
throw new ConflictException({
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
@@ -216,8 +238,11 @@ export class EimsInvoiceRegistrationService {
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
const session = await this.auth.getSessionContext();
await this.dataSource.transaction(async (manager) => {
const state = await this.lockSystemState(manager, this.cfg.systemNumber);
const state = await this.lockSystemState(manager, session.systemNumber);
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",

View File

@@ -0,0 +1,75 @@
import { EimsConfig, EimsInvoiceConfig } from "../../config/eims.config";
/**
* Fixtures shared by the EIMS specs.
*
* Deliberately not a `.spec.ts`: importing fixtures from a spec file makes jest execute that
* file's `describe` blocks inside every importing suite, so the same tests run — and report —
* twice.
*/
export const EIMS_SYSTEM_NUMBER = "B0360154BA";
export const EIMS_SYSTEM_TYPE = "SYS";
export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsInvoiceConfig => ({
sellerLegalName: "Ethio-Djibouti Railway S.C.",
sellerVatNumber: "0000000000",
sellerPhone: "0911223344",
sellerEmail: "finance@example.et",
sellerRegion: "13",
sellerWereda: "574",
sellerCity: null,
sellerSubCity: null,
sellerHouseNumber: null,
sellerLocality: null,
taxCode: "VAT15",
taxRatePercent: 15,
exciseTaxValue: 0,
incomeWithholdValue: 0,
transactionWithholdValue: 0,
transactionType: "B2B",
natureOfSupplies: "Service",
paymentMode: "CASH",
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
cashierName: null,
salesPersonName: null,
...over,
});
export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
enabled: true,
baseUrl: "https://core.mor.gov.et",
clientId: "cid",
clientSecret: "super-secret-value",
apiKey: "super-secret-apikey",
tin: "0000034558",
systemNumber: EIMS_SYSTEM_NUMBER,
systemType: EIMS_SYSTEM_TYPE,
privateKeyPath: "/dev/null",
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
invoice: eimsInvoiceConfig(),
...over,
});
/**
* A structurally real access token. MoR stamps the source-system identity into the JWT payload and
* `EimsAuthService` reads it from there; only the payload segment is meaningful, since the token is
* never verified locally — it is MoR's, signed with MoR's key.
*
* Pass a claim as `undefined` to omit it (spreading beats `delete`, which the defaults would undo).
*/
export const eimsToken = (claims: Record<string, unknown> = {}): string => {
const payload = { systemNumber: EIMS_SYSTEM_NUMBER, systemType: EIMS_SYSTEM_TYPE, ...claims };
for (const [key, value] of Object.entries(payload)) {
if (value === undefined) delete (payload as Record<string, unknown>)[key];
}
return [
"eyJhbGciOiJSUzI1NiJ9",
Buffer.from(JSON.stringify(payload)).toString("base64url"),
"signature",
].join(".");
};