eims integration master test complete

This commit is contained in:
Hagernesh
2026-08-12 14:08:28 +00:00
parent 8d53dc17ce
commit 2d4da8110b
38 changed files with 2270 additions and 173 deletions

View File

@@ -19,6 +19,9 @@ import {
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { NotificationsService } from "../notifications/notifications.service";
import { sendCompanyChannels } from "../notifications/notify-company.util";
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";
@@ -78,6 +81,7 @@ export class EimsInvoiceRegistrationService {
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
private get cfg(): EimsConfig {
@@ -115,17 +119,28 @@ export class EimsInvoiceRegistrationService {
let irn: string;
let ackDate: string | undefined;
let signedQR: string | undefined;
try {
// Deliberately outside every transaction — no DB lock is held across the wire.
const result = await this.submit(request);
irn = result.irn;
ackDate = result.ackDate;
signedQR = result.signedQR;
} catch (err) {
await this.settleFailure(invoiceId, reservation, err);
throw err;
}
await this.settleSuccess(invoiceId, reservation, irn, ackDate);
try {
await this.settleSuccess(invoiceId, reservation, irn, ackDate, signedQR);
} catch (err) {
// MoR already accepted this document — unlike settleFailure's targets, this is not
// ambiguous, it is a *known* IRN we simply failed to persist (confirmed live 2026-08-12: a
// column too narrow for a real IRN). Losing it here would be worse than the persistence
// bug itself, so it goes straight into the block reason and the alert, not just a log line.
await this.blockOnKnownIrnPersistFailure(invoiceId, reservation, irn, ackDate, err);
throw err;
}
this.logger.log(
`Invoice ${invoice.invoiceNumber} registered with EIMS (counter ${reservation.invoiceCounter})`,
);
@@ -373,13 +388,19 @@ export class EimsInvoiceRegistrationService {
reservation: Reservation,
irn: string,
ackDate?: string,
signedQR?: string,
): Promise<void> {
let companyId: string | undefined;
let invoiceNumber = invoiceId;
await this.dataSource.transaction(async (manager) => {
await this.lockInvoice(manager, invoiceId);
const invoice = await this.lockInvoice(manager, invoiceId);
companyId = invoice.companyId;
invoiceNumber = invoice.invoiceNumber;
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Registered,
eimsIrn: irn,
eimsAckDate: ackDate ?? null,
eimsSignedQr: signedQR ?? null,
eimsLastError: null,
});
await manager.update(EimsSystemState, reservation.stateId, {
@@ -390,20 +411,39 @@ export class EimsInvoiceRegistrationService {
blockedReason: null,
});
});
// Best-effort, outside the transaction: MoR checklist ADD-N001 wants the buyer notified of a
// registration event. Never lets a notification failure mask a filing that already succeeded.
if (companyId) {
try {
await sendCompanyChannels(
this.dataSource,
this.notifications,
companyId,
`Invoice ${invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`,
);
} catch (err) {
this.logger.warn(`EIMS buyer notification failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
}
/**
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* TX2b. A deterministic rejection releases the reservation **and returns both numbers**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* The two numbers move differently, because MoR constrains them differently:
* Both `InvoiceCounter` and `DocumentNumber` roll back together on a deterministic rejection —
* MoR's own expected-next-value only advances on acceptance, for both fields:
*
* - `InvoiceCounter` must not **skip** — "Invoice counter is not correct. expected : 1". A
* document MoR definitively refused was never counted there, so ours must not advance either.
* - `DocumentNumber` must not **repeat** — the documented rule is "Document number is not
* unique". It is therefore spent by the attempt itself and never handed back, even for a
* refusal.
* - `InvoiceCounter`: "Invoice counter is not correct. expected : 1".
* - `DocumentNumber`: "Document number error. Document number is not in correct sequence
* expected : 1" (rule 7001) — confirmed live 2026-08-12. An earlier design burned
* `DocumentNumber` forward on every attempt, reasoning from a separate "Document number is
* not unique" rejection; that turned out to describe the same constraint from the other side
* (MoR rejects both reuse *and* skipping ahead of its true next value), and burning forward on
* every rejection permanently drifted past what MoR would ever accept again — confirmed live
* 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.
*/
@@ -434,9 +474,9 @@ export class EimsInvoiceRegistrationService {
reservation.stateId,
deterministic
? {
// Counter returns (MoR never counted a refused document); the document number does
// not (MoR requires it to be unique, so it is burned by the attempt).
// Both return: MoR never counted a refused document against either sequence.
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
@@ -455,6 +495,53 @@ export class EimsInvoiceRegistrationService {
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* MoR accepted the document (a real IRN came back) but recording that locally failed — the
* reservation is still held from TX1, so the system-wide block goes on regardless of *why*
* `settleSuccess` failed. The IRN and ack date are written straight into `blockedReason` so a
* human resolving this never has to dig through logs for the one thing that must not be lost.
*/
private async blockOnKnownIrnPersistFailure(
invoiceId: string,
reservation: Reservation,
irn: string,
ackDate: string | undefined,
err: unknown,
): Promise<void> {
const reason =
`Invoice ${invoiceId} was ACCEPTED by EIMS (IRN ${irn}${ackDate ? `, ack ${ackDate}` : ""}) ` +
`but recording it locally failed: ${(err as Error)?.message ?? "unknown error"}. Resolve with ` +
`POST /invoices/${invoiceId}/eims/resolve using this IRN once the underlying issue is fixed — ` +
"do not resubmit, the document already exists at MoR.";
try {
await this.dataSource.manager.update(EimsSystemState, reservation.stateId, { blockedReason: reason });
} catch (updateErr) {
// Even the block itself failed to write — last resort is the log, since there is nothing
// left to retry into.
this.logger.error(`Could not record EIMS block for invoice ${invoiceId}: ${reason}`, updateErr as Error);
return;
}
this.logger.error(reason);
// Not alertStaff(): its canned copy for a non-deterministic result always says "the IRN is
// unknown", which is false here — the whole point of this path is that the IRN *is* known.
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: NotificationPriority.HIGH,
title: "EIMS accepted an invoice but it was not recorded — all further filing is blocked",
body: reason,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: EimsInvoiceStatus.Unknown, irn, action: "EIMS_PERSIST_FAILED" },
});
} catch (notifyErr) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(notifyErr as Error).message}`);
}
}
/**
* Tell the people who can act about a failed filing.
*
@@ -492,7 +579,9 @@ export class EimsInvoiceRegistrationService {
// ── internals ────────────────────────────────────────────────────────────────────────────────
/** A non-empty IRN is the only success signal; anything else is a failed registration. */
private async submit(request: EimsInvoiceRequest): Promise<{ irn: string; ackDate?: string }> {
private async submit(
request: EimsInvoiceRequest,
): Promise<{ irn: string; ackDate?: string; signedQR?: string }> {
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
"/v1/register",
request,
@@ -506,7 +595,7 @@ export class EimsInvoiceRegistrationService {
response?.statusCode,
);
}
return { irn, ackDate: response.body?.ackDate };
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
}
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
@@ -572,17 +661,6 @@ export class EimsInvoiceRegistrationService {
}
private toView(invoice: Invoice): EimsInvoiceStatusView {
const counter = invoice.eimsInvoiceCounter;
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,
eimsLastError: invoice.eimsLastError ?? null,
};
return toEimsInvoiceStatusView(invoice);
}
}