mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
eims integration master test complete
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user