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 { EimsInvoiceRequest, EimsMapperLine, toEimsInvoice, } from "../billing/eims-invoice.mapper"; 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; 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 get cfg(): EimsConfig { return this.config.get("eims")!; } async registerInvoiceWithEims(invoiceId: string): Promise { 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); // 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, { // Our own invoice number is the document number; EIMS only requires it to be unique. documentNumber: invoice.invoiceNumber, invoiceCounter: reservation.invoiceCounter, previousIrn: reservation.previousIrn, session, }), ); let irn: string; let ackDate: 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; } catch (err) { await this.settleFailure(invoiceId, reservation, err); throw err; } await this.settleSuccess(invoiceId, reservation, irn, ackDate); 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 { 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 { const response = await this.client.postBearer( "/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 set * from our own `invoiceNumber` — 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 { 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 { 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", }); } // 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); await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber); } // 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, 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 { 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 { 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 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, inFlightInvoiceId: invoiceId, inFlightCounter: invoiceCounter, }); await manager.update(Invoice, invoiceId, { eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: invoiceCounter, eimsSubmittedAt: new Date(), eimsLastError: null, }); return { stateId: state.id, invoiceCounter, previousIrn }; }); } /** TX2a. Record the IRN, advance the chain, release the reservation. */ private async settleSuccess( invoiceId: string, reservation: Reservation, irn: string, ackDate?: string, ): Promise { await this.dataSource.transaction(async (manager) => { await this.lockInvoice(manager, invoiceId); await manager.update(Invoice, invoiceId, { eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: irn, eimsAckDate: ackDate ?? null, eimsLastError: null, }); await manager.update(EimsSystemState, reservation.stateId, { previousIrn: irn, inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null, }); }); } /** * TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and * blocks the system number, because `PreviousIrn` is now unknown for every later document. * The counter stays consumed either way. */ private async settleFailure( invoiceId: string, reservation: Reservation, err: unknown, ): Promise { 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); await manager.update( EimsSystemState, reservation.stateId, deterministic ? { inFlightInvoiceId: null, inFlightCounter: 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}`); } // ── 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 }> { const response = await this.client.postSigned( "/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 }; } private async lockInvoice(manager: EntityManager, invoiceId: string): Promise { 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 { 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 { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: invoiceId }, relations: { company: true, companyProfile: 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 { 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 { const counter = invoice.eimsInvoiceCounter; return { invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted, eimsIrn: invoice.eimsIrn ?? null, eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter), eimsSubmittedAt: invoice.eimsSubmittedAt ?? null, eimsAckDate: invoice.eimsAckDate ?? null, eimsLastError: invoice.eimsLastError ?? null, }; } }