mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1321 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -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,59 @@ 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("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
|
||||
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
|
||||
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
|
||||
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
|
||||
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
|
||||
const db = new FakeDb([
|
||||
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
|
||||
]);
|
||||
const postSigned = jest.fn();
|
||||
|
||||
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
|
||||
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
|
||||
// known exception types.
|
||||
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||
/no MoR country code mapping/,
|
||||
);
|
||||
|
||||
expect(postSigned).not.toHaveBeenCalled();
|
||||
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||
eimsStatus: EimsInvoiceStatus.Failed,
|
||||
eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
|
||||
});
|
||||
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: "" } });
|
||||
|
||||
@@ -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";
|
||||
@@ -123,27 +123,29 @@ export class EimsInvoiceRegistrationService {
|
||||
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.
|
||||
const request = toEimsInvoice(
|
||||
invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||
documentNumber: reservation.documentNumber,
|
||||
invoiceCounter: reservation.invoiceCounter,
|
||||
previousIrn: reservation.previousIrn,
|
||||
session,
|
||||
documentType,
|
||||
reason: invoice.eimsReason,
|
||||
relatedDocument,
|
||||
}),
|
||||
);
|
||||
|
||||
let irn: string;
|
||||
let ackDate: string | undefined;
|
||||
let signedQR: string | undefined;
|
||||
try {
|
||||
// The request can only be built now: InvoiceCounter and PreviousIrn come from the
|
||||
// reservation. Building it — and everything after — stays inside this try: a reservation is
|
||||
// held from here on, and *any* failure past this point, mapper or wire, must release it
|
||||
// through settleFailure rather than leave it orphaned as a permanent system-wide block.
|
||||
const request = toEimsInvoice(
|
||||
invoice,
|
||||
this.sellerCache.getSellerDetails(cfg),
|
||||
buildEimsContext(cfg, {
|
||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||
documentNumber: reservation.documentNumber,
|
||||
invoiceCounter: reservation.invoiceCounter,
|
||||
previousIrn: reservation.previousIrn,
|
||||
session,
|
||||
documentType,
|
||||
reason: invoice.eimsReason,
|
||||
relatedDocument,
|
||||
}),
|
||||
);
|
||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||
const result = await this.submit(request);
|
||||
irn = result.irn;
|
||||
@@ -469,6 +471,15 @@ 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.
|
||||
*
|
||||
* Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
|
||||
* every error that actually touches the wire is normalized to `EimsApiException` before it gets
|
||||
* here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
|
||||
* threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
|
||||
* pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
|
||||
* touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
|
||||
* country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
|
||||
* any HTTP call went out, and releasing the reservation is always safe, never a guess.
|
||||
*/
|
||||
private async settleFailure(
|
||||
invoiceId: string,
|
||||
@@ -476,10 +487,12 @@ export class EimsInvoiceRegistrationService {
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
const api = err instanceof EimsApiException ? err : null;
|
||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
||||
// Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
|
||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
||||
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||
const lastError: EimsInvoiceError = {
|
||||
kind: api?.kind ?? "UNKNOWN",
|
||||
kind: api?.kind ?? localKind,
|
||||
message: (err as Error)?.message ?? "unknown error",
|
||||
httpStatus: api?.httpStatus,
|
||||
details: api?.details,
|
||||
@@ -586,10 +599,14 @@ export class EimsInvoiceRegistrationService {
|
||||
type: NotificationType.GENERIC,
|
||||
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||
title: deterministic
|
||||
? "EIMS rejected an invoice"
|
||||
? error.kind === "CONFIG" || error.kind === "LOCAL"
|
||||
? "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" || error.kind === "LOCAL"
|
||||
? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it 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" },
|
||||
|
||||
@@ -10,7 +10,9 @@ export type EimsFailureKind =
|
||||
| "FORBIDDEN"
|
||||
| "RULE_VALIDATION"
|
||||
| "SERVER"
|
||||
| "UNKNOWN";
|
||||
| "UNKNOWN"
|
||||
| "CONFIG"
|
||||
| "LOCAL";
|
||||
|
||||
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||
export class EimsConfigException extends ServiceUnavailableException {
|
||||
|
||||
Reference in New Issue
Block a user