From 0464a44de470923f2dbd9cb1bfa454c379354416 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 11:21:39 +0000 Subject: [PATCH] fix(eims): a config error before signing must not block the whole system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settleFailure() treated any non-EimsApiException error as ambiguous ("might have reached MoR") and permanently blocked all further filing until manually resolved. EimsConfigException (bad/missing key, unparseable cert) is thrown by EimsSignerService before EimsClientService.send()'s try/catch is even entered — by construction it never reached the wire, so there is nothing ambiguous about it. This is exactly what happened live: a private-key parse failure during the key/cert migration work reserved a counter, failed before any HTTP call, and got treated as an unresolved in-flight submission — blocking every other invoice from filing until someone manually POSTs /eims/resolve. Fix: EimsConfigException is now deterministic in settleFailure, same treatment as a clean MoR rejection — both counters roll back, no system-wide block, invoice marked FAILED (not UNKNOWN). Added a CONFIG failure kind so the invoice's eimsLastError and the staff alert both say plainly that the request never reached MoR, instead of implying a MoR rejection. --- .../eims-invoice-registration.service.spec.ts | 25 ++++++++++++++++++- .../eims/eims-invoice-registration.service.ts | 23 +++++++++++++---- .../src/modules/eims/eims.errors.ts | 3 ++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 79d3204dd..5fc74f988 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -10,7 +10,7 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { buildEimsSeller } from "./eims-invoice-context"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; @@ -479,6 +479,29 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); + it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => { + const db = new FakeDb([invoiceRow()]); + const postSigned = jest + .fn() + .mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed")); + + await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf( + EimsConfigException, + ); + + expect(db.invoices.get(INVOICE_ID)).toMatchObject({ + eimsStatus: EimsInvoiceStatus.Failed, + eimsIrn: null, + eimsLastError: expect.objectContaining({ kind: "CONFIG" }), + }); + expect(db.state).toMatchObject({ + inFlightInvoiceId: null, + blockedReason: null, + previousIrn: null, + 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: "" } }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 2ca5f5ffd..d96c64947 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -26,7 +26,7 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; -import { EimsApiException } from "./eims.errors"; +import { EimsApiException, EimsConfigException } from "./eims.errors"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSystemState } from "./entities/eims-system-state.entity"; import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context"; @@ -469,6 +469,14 @@ export class EimsInvoiceRegistrationService { * when two rejected self-test attempts deadlocked the sequence until a manual DB reset. * * An ambiguous result keeps both: MoR may have counted and stored the document. + * + * `EimsConfigException` is also deterministic, for a different reason: it's thrown by + * `EimsSignerService`/`EimsCredentialsProvider` *before* `EimsClientService.send()`'s own + * try/catch is even entered (see `send()` — signing happens above its try block), so by + * construction no HTTP call was ever made. There is nothing to be ambiguous about — a bad key or + * missing config can't have reached MoR. Every error that *did* touch the wire is normalized to + * `EimsApiException` before it gets here (`toEimsApiException`), so this check is exhaustive: + * config errors are the only other kind `submit()` can throw. */ private async settleFailure( invoiceId: string, @@ -476,10 +484,11 @@ export class EimsInvoiceRegistrationService { err: unknown, ): Promise { const api = err instanceof EimsApiException ? err : null; - const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false; + const isConfigError = err instanceof EimsConfigException; + const deterministic = isConfigError || (api ? DETERMINISTIC_KINDS.has(api.kind) : false); const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; const lastError: EimsInvoiceError = { - kind: api?.kind ?? "UNKNOWN", + kind: isConfigError ? "CONFIG" : (api?.kind ?? "UNKNOWN"), message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, @@ -586,10 +595,14 @@ export class EimsInvoiceRegistrationService { type: NotificationType.GENERIC, priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH, title: deterministic - ? "EIMS rejected an invoice" + ? error.kind === "CONFIG" + ? "EIMS filing failed before reaching MoR" + : "EIMS rejected an invoice" : "EIMS filing unresolved — all further filing is blocked", body: deterministic - ? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` + ? error.kind === "CONFIG" + ? `EIMS is misconfigured: ${error.message}. Nothing was sent to MoR; fix the config and file again.` + : `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.` : `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`, link: `/dashboard/invoices/${invoiceId}`, data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" }, diff --git a/apps/edr-freight-api/src/modules/eims/eims.errors.ts b/apps/edr-freight-api/src/modules/eims/eims.errors.ts index 3be21fdd3..75c513824 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.errors.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.errors.ts @@ -10,7 +10,8 @@ export type EimsFailureKind = | "FORBIDDEN" | "RULE_VALIDATION" | "SERVER" - | "UNKNOWN"; + | "UNKNOWN" + | "CONFIG"; /** Raised when EIMS is disabled or its credential files are unusable. */ export class EimsConfigException extends ServiceUnavailableException {