Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts

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);
}
}