fix(eims): a config error before signing must not block the whole system

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.
This commit is contained in:
Hagernesh
2026-08-17 11:21:39 +00:00
parent bb86c2365a
commit 0464a44de4
3 changed files with 44 additions and 7 deletions

View File

@@ -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: "" } });

View File

@@ -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<void> {
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" },

View File

@@ -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 {