mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
692 lines
29 KiB
TypeScript
692 lines
29 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource, EntityManager } from "typeorm";
|
|
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
|
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { Invoice } from "../billing/entities/invoice.entity";
|
|
import {
|
|
EimsDocumentType,
|
|
EimsInvoiceRequest,
|
|
EimsMapperLine,
|
|
toEimsInvoice,
|
|
} 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";
|
|
import { EimsApiException } from "./eims.errors";
|
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|
import {
|
|
assertEimsInvoiceConfig,
|
|
buildEimsContext,
|
|
buildEimsSeller,
|
|
} from "./eims-invoice-context";
|
|
import {
|
|
EimsInvoiceError,
|
|
EimsInvoiceStatus,
|
|
EimsInvoiceStatusView,
|
|
EimsRegisterResponse,
|
|
EimsVerifyRequest,
|
|
EimsVerifyResponse,
|
|
} from "./eims-registration.types";
|
|
|
|
/**
|
|
* Failure kinds where the gateway gave a complete answer: the document was rejected and is
|
|
* definitively not registered. These clear the system-wide block; anything else does not.
|
|
*/
|
|
const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]);
|
|
|
|
interface Reservation {
|
|
stateId: string;
|
|
invoiceCounter: number;
|
|
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
|
|
documentNumber: string;
|
|
previousIrn: string;
|
|
}
|
|
|
|
/**
|
|
* Registers a single invoice with MoR EIMS.
|
|
*
|
|
* Sequencing is a **durable reservation**: the counter is consumed and the holder recorded in a
|
|
* committed transaction *before* the request leaves the process, and the network call happens
|
|
* outside any transaction. That gives three properties the naive design could not:
|
|
*
|
|
* - a counter is never reused once an attempt has begun, even across a crash;
|
|
* - a crash mid-flight leaves the reservation standing, so nothing blindly resubmits a document
|
|
* that may already have reached MoR;
|
|
* - an ambiguous result blocks every invoice for the system number, not just its own, because
|
|
* `PreviousIrn` is unknown and any later document would chain to a stale IRN.
|
|
*
|
|
* Signing, authentication and error normalisation belong to `EimsClientService`. Manual only —
|
|
* nothing in invoice creation calls this.
|
|
*/
|
|
@Injectable()
|
|
export class EimsInvoiceRegistrationService {
|
|
private readonly logger = new Logger(EimsInvoiceRegistrationService.name);
|
|
|
|
constructor(
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
private readonly config: ConfigService,
|
|
private readonly client: EimsClientService,
|
|
private readonly auth: EimsAuthService,
|
|
private readonly inbox: NotificationInboxService,
|
|
private readonly notifications: NotificationsService,
|
|
) {}
|
|
|
|
private get cfg(): EimsConfig {
|
|
return this.config.get<EimsConfig>("eims")!;
|
|
}
|
|
|
|
async registerInvoiceWithEims(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
|
const cfg = this.cfg;
|
|
// Static seller/tax configuration is validated before anything is locked, allocated or sent.
|
|
assertEimsInvoiceConfig(cfg);
|
|
|
|
const invoice = await this.loadInvoiceForMapping(invoiceId);
|
|
if (invoice.eimsIrn) return this.toView(invoice);
|
|
|
|
// Debit/credit notes (confirmed by MoR support: same endpoint, Type DEB/CRE + Reason,
|
|
// ReferenceDetails.RelatedDocument = the original's IRN) must fail here — before a counter is
|
|
// touched — if the original was never actually registered.
|
|
const documentType = (invoice.eimsDocumentType as EimsDocumentType | undefined) ?? "INV";
|
|
let relatedDocument: string | null = null;
|
|
if (documentType !== "INV") {
|
|
if (!invoice.relatedInvoice) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RELATED_INVOICE_REQUIRED",
|
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} but has no related invoice set.`,
|
|
});
|
|
}
|
|
if (!invoice.relatedInvoice.eimsIrn) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RELATED_INVOICE_NOT_REGISTERED",
|
|
message: `Invoice ${invoice.invoiceNumber} is a ${documentType} against invoice ${invoice.relatedInvoice.invoiceNumber}, which was never registered with EIMS — nothing to reference.`,
|
|
});
|
|
}
|
|
relatedDocument = invoice.relatedInvoice.eimsIrn;
|
|
}
|
|
|
|
// Authenticate before reserving: the source system comes from the token, and the state row is
|
|
// keyed by it. A login failure here costs nothing — no counter has been consumed yet.
|
|
const session = await this.auth.getSessionContext();
|
|
|
|
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,
|
|
buildEimsSeller(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 {
|
|
// 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;
|
|
}
|
|
|
|
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})`,
|
|
);
|
|
return this.getEimsStatus(invoiceId);
|
|
}
|
|
|
|
/**
|
|
* Verify a registered invoice at `POST /v1/verify`.
|
|
*
|
|
* Requires a stored IRN. An invoice whose submission was never acknowledged cannot be reconciled
|
|
* here — the gateway offers no lookup by document number — so it must be resolved with MoR and
|
|
* recorded through `resolveEimsRegistration`.
|
|
*/
|
|
async verifyInvoiceWithEims(invoiceId: string): Promise<EimsVerifyResponse> {
|
|
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
|
if (!invoice.eimsIrn) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_NO_IRN",
|
|
message:
|
|
`Invoice ${invoice.invoiceNumber} has no EIMS IRN to verify (status ${invoice.eimsStatus}). ` +
|
|
"EIMS can only be queried by IRN, so an unacknowledged submission must be resolved with MoR first.",
|
|
});
|
|
}
|
|
return this.queryVerify(invoice.eimsIrn);
|
|
}
|
|
|
|
/**
|
|
* `POST /v1/verify` for one IRN, with the one check that always applies: the gateway must echo
|
|
* an `Irn` back. A 200 without it is not a confirmation of anything.
|
|
*
|
|
* The request property is lowercase `irn`; the response spells it `Irn`. The two are never
|
|
* compared — the supplied collection's own fixture uses different example values on each side,
|
|
* so equality there would assert a property of the mock rather than of the gateway.
|
|
*
|
|
* Bearer-authenticated but unsigned, via `postBearer` — see that method for why.
|
|
*/
|
|
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
|
|
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
|
|
"/v1/verify",
|
|
{ irn },
|
|
);
|
|
if (!response?.body?.Irn?.trim()) {
|
|
throw new EimsApiException(
|
|
"SCHEMA_VALIDATION",
|
|
"EIMS verify returned no Irn in its response body",
|
|
response?.statusCode,
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
|
|
* IRN is the one it holds, and that it belongs to this invoice.
|
|
*
|
|
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
|
|
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
|
|
* to a row in this database.
|
|
*
|
|
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
|
|
* chains every later document to a stranger's reference, so both checks are refusals rather
|
|
* than warnings.
|
|
*/
|
|
private async assertIrnBelongsToInvoice(
|
|
irn: string,
|
|
expectedDocumentNumber: string,
|
|
): Promise<void> {
|
|
const response = await this.queryVerify(irn);
|
|
const returnedIrn = response.body?.Irn?.trim();
|
|
const documentNumber = response.body?.DocumentDetails?.DocumentNumber?.trim();
|
|
|
|
if (returnedIrn !== irn) {
|
|
throw new ConflictException({
|
|
code: "EIMS_RESOLVE_IRN_MISMATCH",
|
|
message:
|
|
`EIMS answered the lookup for IRN ${irn} with ${returnedIrn ?? "(none)"}. ` +
|
|
"Refusing to record it — recheck the IRN in the MoR portal.",
|
|
});
|
|
}
|
|
|
|
if (documentNumber !== expectedDocumentNumber) {
|
|
throw new ConflictException({
|
|
code: "EIMS_RESOLVE_DOCUMENT_MISMATCH",
|
|
message:
|
|
`EIMS reports IRN ${irn} against document ${documentNumber ?? "(none)"}, not ` +
|
|
`${expectedDocumentNumber}. Refusing to record it — recheck the IRN in the MoR portal.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manual reconciliation of a blocked system number.
|
|
*
|
|
* With an `irn` (found in the MoR portal) the invoice is recorded as registered and the chain
|
|
* resumes from it. With `discard` the invoice is marked failed and the chain resumes from the
|
|
* previous IRN. Either way the block is cleared — this is the only exit from an ambiguous result.
|
|
*
|
|
* An IRN is never taken on trust: it is verified at the gateway first, and the document it
|
|
* belongs to must be *this* invoice. A transposed digit would otherwise chain every later
|
|
* document to a stranger's IRN and mark this invoice registered when it is not.
|
|
*/
|
|
async resolveEimsRegistration(
|
|
invoiceId: string,
|
|
input: { irn?: string; discard?: boolean },
|
|
): Promise<EimsInvoiceStatusView> {
|
|
const irn = input.irn?.trim();
|
|
if (!irn && !input.discard) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_RESOLVE_INPUT_REQUIRED",
|
|
message: "Provide the IRN confirmed with MoR, or discard: true to abandon the submission",
|
|
});
|
|
}
|
|
|
|
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
|
|
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
|
|
// authoritative re-check happens under lock in the transaction below.
|
|
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
|
|
`SELECT in_flight_invoice_id FROM freight.eims_system_state
|
|
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
|
|
[(await this.auth.getSessionContext()).systemNumber],
|
|
);
|
|
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
|
|
throw new ConflictException({
|
|
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
|
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
|
|
});
|
|
}
|
|
|
|
// Outside the transaction: no lock is held across the wire, and a refused verification must
|
|
// leave the block exactly as it was.
|
|
if (irn) {
|
|
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
|
|
if (!invoice.eimsDocumentNumber) {
|
|
throw new BadRequestException({
|
|
code: "EIMS_NO_DOCUMENT_NUMBER",
|
|
message:
|
|
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
|
|
"returned IRN cannot be tied back to it.",
|
|
});
|
|
}
|
|
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
|
|
}
|
|
|
|
// Same source of truth as registration: the state row is keyed by the token's system number.
|
|
const session = await this.auth.getSessionContext();
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const state = await this.lockSystemState(manager, session.systemNumber);
|
|
if (state.inFlightInvoiceId && state.inFlightInvoiceId !== invoiceId) {
|
|
throw new ConflictException({
|
|
code: "EIMS_RESOLVE_WRONG_INVOICE",
|
|
message: `The in-flight EIMS submission is invoice ${state.inFlightInvoiceId}, not ${invoiceId}`,
|
|
});
|
|
}
|
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
|
if (invoice.eimsIrn) {
|
|
throw new ConflictException({
|
|
code: "EIMS_ALREADY_REGISTERED",
|
|
message: `Invoice ${invoice.invoiceNumber} already has IRN ${invoice.eimsIrn}`,
|
|
});
|
|
}
|
|
|
|
await manager.update(Invoice, invoiceId, {
|
|
eimsStatus: irn ? EimsInvoiceStatus.Registered : EimsInvoiceStatus.Failed,
|
|
eimsIrn: irn ?? null,
|
|
});
|
|
await manager.update(EimsSystemState, state.id, {
|
|
// Only a confirmed IRN may advance the chain; a discard leaves it where it was.
|
|
...(irn ? { previousIrn: irn } : {}),
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: null,
|
|
inFlightDocumentNumber: null,
|
|
blockedReason: null,
|
|
});
|
|
});
|
|
|
|
this.logger.warn(
|
|
`EIMS block on invoice ${invoiceId} resolved manually (${irn ? "IRN recorded" : "discarded"})`,
|
|
);
|
|
return this.getEimsStatus(invoiceId);
|
|
}
|
|
|
|
async getEimsStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
|
return this.toView(await this.loadInvoiceRow(this.dataSource.manager, invoiceId));
|
|
}
|
|
|
|
// ── transactions ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* TX1. Consume a counter and record the holder, committed before any HTTP call. Returns `null`
|
|
* when the invoice turned out to be registered already (checked under the lock).
|
|
*/
|
|
private async reserve(invoiceId: string, systemNumber: string): Promise<Reservation | null> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const state = await this.lockSystemState(manager, systemNumber);
|
|
|
|
if (state.blockedReason) {
|
|
throw new ConflictException({
|
|
code: "EIMS_SYSTEM_BLOCKED",
|
|
message:
|
|
`EIMS registration is blocked for system ${systemNumber}: ${state.blockedReason}. ` +
|
|
"Resolve the affected invoice before registering anything else.",
|
|
});
|
|
}
|
|
if (state.inFlightInvoiceId) {
|
|
throw new ConflictException({
|
|
code: "EIMS_SUBMISSION_IN_FLIGHT",
|
|
message:
|
|
`A submission for invoice ${state.inFlightInvoiceId} is already in flight on system ` +
|
|
`${systemNumber}. Wait for it to settle, or resolve it if the process was interrupted.`,
|
|
});
|
|
}
|
|
|
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
|
if (invoice.eimsIrn) return null;
|
|
|
|
const invoiceCounter = Number(state.nextInvoiceCounter);
|
|
const documentNumber = String(Number(state.nextDocumentNumber));
|
|
const previousIrn = state.previousIrn ?? "";
|
|
|
|
// Counter consumed here, not on success: once an attempt begins it can never be reused,
|
|
// whatever happens next. A gap is harmless at MoR; a collision is not.
|
|
await manager.update(EimsSystemState, state.id, {
|
|
nextInvoiceCounter: invoiceCounter + 1,
|
|
nextDocumentNumber: Number(documentNumber) + 1,
|
|
inFlightInvoiceId: invoiceId,
|
|
inFlightCounter: invoiceCounter,
|
|
inFlightDocumentNumber: Number(documentNumber),
|
|
});
|
|
await manager.update(Invoice, invoiceId, {
|
|
eimsStatus: EimsInvoiceStatus.Submitting,
|
|
eimsInvoiceCounter: invoiceCounter,
|
|
eimsDocumentNumber: documentNumber,
|
|
eimsSubmittedAt: new Date(),
|
|
eimsLastError: null,
|
|
});
|
|
|
|
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
|
|
});
|
|
}
|
|
|
|
/** TX2a. Record the IRN, advance the chain, release the reservation. */
|
|
private async settleSuccess(
|
|
invoiceId: string,
|
|
reservation: Reservation,
|
|
irn: string,
|
|
ackDate?: string,
|
|
signedQR?: string,
|
|
): Promise<void> {
|
|
let companyId: string | undefined;
|
|
let invoiceNumber = invoiceId;
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const invoice = await this.lockInvoice(manager, invoiceId);
|
|
companyId = invoice.companyId ?? undefined;
|
|
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, {
|
|
previousIrn: irn,
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: null,
|
|
inFlightDocumentNumber: null,
|
|
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 both numbers**; an
|
|
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
|
|
* for every later document.
|
|
*
|
|
* 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`: "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.
|
|
*/
|
|
private async settleFailure(
|
|
invoiceId: string,
|
|
reservation: Reservation,
|
|
err: unknown,
|
|
): Promise<void> {
|
|
const api = err instanceof EimsApiException ? err : null;
|
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
|
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
|
const lastError: EimsInvoiceError = {
|
|
kind: api?.kind ?? "UNKNOWN",
|
|
message: (err as Error)?.message ?? "unknown error",
|
|
httpStatus: api?.httpStatus,
|
|
details: api?.details,
|
|
at: new Date().toISOString(),
|
|
};
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.update(Invoice, invoiceId, {
|
|
eimsStatus: status,
|
|
eimsLastError: lastError,
|
|
} as QueryDeepPartialEntity<Invoice>);
|
|
|
|
await manager.update(
|
|
EimsSystemState,
|
|
reservation.stateId,
|
|
deterministic
|
|
? {
|
|
// Both return: MoR never counted a refused document against either sequence.
|
|
nextInvoiceCounter: reservation.invoiceCounter,
|
|
nextDocumentNumber: Number(reservation.documentNumber),
|
|
inFlightInvoiceId: null,
|
|
inFlightCounter: null,
|
|
inFlightDocumentNumber: null,
|
|
blockedReason: null,
|
|
}
|
|
: {
|
|
blockedReason:
|
|
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
|
|
`never acknowledged (${lastError.kind}). Its IRN is unknown, so no further document ` +
|
|
"can be chained until it is resolved with MoR.",
|
|
},
|
|
);
|
|
});
|
|
|
|
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
|
|
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.
|
|
*
|
|
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
|
|
* number until a human resolves it, and nothing else in the system would surface that — the
|
|
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
|
|
* priority. Never throws: an alert that fails must not mask the filing outcome.
|
|
*/
|
|
private async alertStaff(
|
|
invoiceId: string,
|
|
status: EimsInvoiceStatus,
|
|
error: EimsInvoiceError,
|
|
deterministic: boolean,
|
|
): Promise<void> {
|
|
try {
|
|
await this.inbox.notify({
|
|
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.GENERIC,
|
|
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
|
title: deterministic
|
|
? "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.`
|
|
: `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" },
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// ── 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; signedQR?: string }> {
|
|
const response = await this.client.postSigned<EimsInvoiceRequest, EimsRegisterResponse>(
|
|
"/v1/register",
|
|
request,
|
|
);
|
|
const irn = response?.body?.irn;
|
|
if (!irn) {
|
|
// The gateway answered, so this is deterministic: the document is not registered.
|
|
throw new EimsApiException(
|
|
"SCHEMA_VALIDATION",
|
|
`EIMS register returned no IRN${response?.body?.errorMessage ? `: ${response.body.errorMessage}` : ""}`,
|
|
response?.statusCode,
|
|
);
|
|
}
|
|
return { irn, ackDate: response.body?.ackDate, signedQR: response.body?.signedQR };
|
|
}
|
|
|
|
private async lockInvoice(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
|
const invoice = await manager
|
|
.createQueryBuilder(Invoice, "invoice")
|
|
.setLock("pessimistic_write")
|
|
.where("invoice.id = :invoiceId", { invoiceId })
|
|
.getOne();
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
return invoice;
|
|
}
|
|
|
|
/** Locks the system-state row, creating it on first use. */
|
|
private async lockSystemState(
|
|
manager: EntityManager,
|
|
systemNumber: string,
|
|
): Promise<EimsSystemState> {
|
|
const select = () =>
|
|
manager
|
|
.createQueryBuilder(EimsSystemState, "state")
|
|
.setLock("pessimistic_write")
|
|
.where("state.system_number = :systemNumber", { systemNumber })
|
|
.getOne();
|
|
|
|
const existing = await select();
|
|
if (existing) return existing;
|
|
|
|
await manager.query(
|
|
`INSERT INTO freight.eims_system_state (system_number) VALUES ($1)
|
|
ON CONFLICT (system_number) DO NOTHING`,
|
|
[systemNumber],
|
|
);
|
|
const created = await select();
|
|
if (!created) throw new Error(`Could not initialise EIMS system state for ${systemNumber}`);
|
|
return created;
|
|
}
|
|
|
|
/** Header + buyer + lines — everything the mapper needs. */
|
|
private async loadInvoiceForMapping(
|
|
invoiceId: string,
|
|
): Promise<Invoice & { lines: EimsMapperLine[] }> {
|
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
|
where: { id: invoiceId },
|
|
relations: { company: true, companyProfile: true, relatedInvoice: true },
|
|
});
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
|
|
const lines: EimsMapperLine[] = await this.dataSource.query(
|
|
`SELECT charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate",
|
|
amount, currency, metadata
|
|
FROM freight.invoice_lines
|
|
WHERE invoice_id = $1 AND deleted_at IS NULL
|
|
ORDER BY created_at ASC`,
|
|
[invoiceId],
|
|
);
|
|
return Object.assign(invoice, { lines });
|
|
}
|
|
|
|
private async loadInvoiceRow(manager: EntityManager, invoiceId: string): Promise<Invoice> {
|
|
const invoice = await manager.findOne(Invoice, { where: { id: invoiceId } });
|
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
|
return invoice;
|
|
}
|
|
|
|
private toView(invoice: Invoice): EimsInvoiceStatusView {
|
|
return toEimsInvoiceStatusView(invoice);
|
|
}
|
|
}
|