mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +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 { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.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 { buildEimsSeller } from "./eims-invoice-context";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
import { EimsSellerCacheService } from "./eims-seller-cache.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 () => {
|
it("treats a success response with no IRN as a failed registration", async () => {
|
||||||
const db = new FakeDb([invoiceRow()]);
|
const db = new FakeDb([invoiceRow()]);
|
||||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
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 { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.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 { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
@@ -123,27 +123,29 @@ export class EimsInvoiceRegistrationService {
|
|||||||
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
||||||
if (!reservation) return this.getEimsStatus(invoiceId);
|
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 irn: string;
|
||||||
let ackDate: string | undefined;
|
let ackDate: string | undefined;
|
||||||
let signedQR: string | undefined;
|
let signedQR: string | undefined;
|
||||||
try {
|
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.
|
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||||
const result = await this.submit(request);
|
const result = await this.submit(request);
|
||||||
irn = result.irn;
|
irn = result.irn;
|
||||||
@@ -469,6 +471,15 @@ export class EimsInvoiceRegistrationService {
|
|||||||
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
* 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.
|
* 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(
|
private async settleFailure(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
@@ -476,10 +487,12 @@ export class EimsInvoiceRegistrationService {
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const api = err instanceof EimsApiException ? err : null;
|
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 status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||||
|
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||||
const lastError: EimsInvoiceError = {
|
const lastError: EimsInvoiceError = {
|
||||||
kind: api?.kind ?? "UNKNOWN",
|
kind: api?.kind ?? localKind,
|
||||||
message: (err as Error)?.message ?? "unknown error",
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
httpStatus: api?.httpStatus,
|
httpStatus: api?.httpStatus,
|
||||||
details: api?.details,
|
details: api?.details,
|
||||||
@@ -586,10 +599,14 @@ export class EimsInvoiceRegistrationService {
|
|||||||
type: NotificationType.GENERIC,
|
type: NotificationType.GENERIC,
|
||||||
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||||
title: deterministic
|
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",
|
: "EIMS filing unresolved — all further filing is blocked",
|
||||||
body: deterministic
|
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.`,
|
: `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}`,
|
link: `/dashboard/invoices/${invoiceId}`,
|
||||||
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export type EimsFailureKind =
|
|||||||
| "FORBIDDEN"
|
| "FORBIDDEN"
|
||||||
| "RULE_VALIDATION"
|
| "RULE_VALIDATION"
|
||||||
| "SERVER"
|
| "SERVER"
|
||||||
| "UNKNOWN";
|
| "UNKNOWN"
|
||||||
|
| "CONFIG"
|
||||||
|
| "LOCAL";
|
||||||
|
|
||||||
/** Raised when EIMS is disabled or its credential files are unusable. */
|
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||||
export class EimsConfigException extends ServiceUnavailableException {
|
export class EimsConfigException extends ServiceUnavailableException {
|
||||||
|
|||||||
Reference in New Issue
Block a user