mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Ten EIMS_SELLER_* env vars were the only source of EDR's own seller identity, duplicating data the platform already has via the same e-Trade lookup used for every customer company at onboarding. EimsSellerCacheService now enriches it — but static config remains the source of truth: MoR validates SellerDetails against its own taxpayer registry (rule 7017, already cleared against the current static values), so e-Trade fills a field only when the static value is blank, never overrides one already confirmed. The static config is therefore the durable fallback, not the cache; an in-memory snapshot lost on restart is harmless. ETradeService has no request timeout of its own and no AbortController, so the cache enforces one locally (stops waiting, doesn't cancel the request) and de-duplicates concurrent refresh() calls into the same in-flight promise. getSellerDetails() is fully synchronous — zero I/O — so live registration never depends on e-Trade being reachable, at boot or per invoice. VatNumber and Email stay on static config permanently — confirmed by reading e-Trade's actual response shapes, neither field exists anywhere in what it returns. Region/Wereda/City reuse the existing EIMS_BUYER_*_CODES maps rather than adding seller-specific ones — the geography is objective, not buyer-specific. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
690 lines
29 KiB
TypeScript
690 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 { EimsSellerCacheService } from "./eims-seller-cache.service";
|
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|
import { assertEimsInvoiceConfig, buildEimsContext } 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 readonly sellerCache: EimsSellerCacheService,
|
|
) {}
|
|
|
|
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,
|
|
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 {
|
|
// 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);
|
|
}
|
|
}
|