mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
MoR stamps systemNumber and systemType into the access token it issues for the authenticating credentials, which makes the token the authority on them. Registration now reads both from there instead of from configuration, so the SourceSystem block cannot drift from what the gateway believes we are. EimsAuthService decodes the token payload after login, requires both claims to be non-empty, and exposes them through getSessionContext(). The token is decoded but never verified -- it is MoR's, signed with MoR's key -- and is kept out of the log line, which names only the system it identified. EIMS_SYSTEM_NUMBER and EIMS_SYSTEM_TYPE become optional expectations rather than inputs: when set they are compared against the claims and a mismatch fails fast, so neither side silently wins. Neither is required to register any more. Registration and manual resolution both resolve the session before touching the state row, which is keyed by the system number: a login failure now costs nothing because no counter has been reserved yet. Test fixtures move to eims-test-fixtures.ts. They previously lived in eims-auth.service.spec.ts, which made jest execute that suite again inside every importing spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498 lines
19 KiB
TypeScript
498 lines
19 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 {
|
|
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<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);
|
|
|
|
// 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<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 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<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",
|
|
});
|
|
}
|
|
|
|
// 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<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 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<void> {
|
|
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<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
|
|
? { 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<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 };
|
|
}
|
|
|
|
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 },
|
|
});
|
|
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 {
|
|
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,
|
|
};
|
|
}
|
|
}
|