import { randomUUID } from "node:crypto"; import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntityManager, In } 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 { MorGeoCodes, resolveMorGeo } from "../../config/mor-location.resolver"; import { EimsDocumentType, EimsMapperLine, toEimsInvoice } from "../billing/eims-invoice.mapper"; import { sendCompanyChannels } from "../notifications/notify-company.util"; import { NotificationsService } from "../notifications/notifications.service"; import { EimsAuthService } from "./eims-auth.service"; import { EimsClientService } from "./eims-client.service"; import { EimsApiException, EimsConfigException } 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 { EimsBulkCallbackItem, EimsBulkRegisterAcceptedResponse, EimsBulkRegisterItemResult, EimsBulkRegisterRequest, EimsInvoiceError, EimsInvoiceStatus, } from "./eims-registration.types"; const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AUTH", "FORBIDDEN"]); interface BulkReservation { stateId: string; invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null; /** Resolved before this reservation existed — see the `prepared` pass in `bulkRegister`. */ buyerGeo: MorGeoCodes; invoiceCounter: number; documentNumber: string; previousIrn: string; } /** * Registers many invoices with MoR EIMS in one call — `POST /v1/bulkRegister`. * * Fundamentally different shape from `EimsInvoiceRegistrationService.registerInvoiceWithEims`: * that endpoint answers synchronously (an IRN or a rejection, in the HTTP response itself). Bulk * does not — it returns only `{conversationId, status:202}` immediately, and the real per-invoice * results (a mix of accepted/rejected in one array, per the collection's own examples) arrive later * as a POST to a webhook MoR was configured with out of band. That means this service has two * halves that don't share a call stack: `registerBulk` reserves and submits; `handleBulkCallback` * — invoked by `EimsWebhookController`, whenever MoR gets around to it — settles. * * Reservation follows the same durable-reservation doctrine as the single-invoice service (counters * consumed and the holder recorded, committed, before the HTTP call leaves the process), extended * to a contiguous block of N counters instead of one. The "something is in flight" marker is * `EimsSystemState.inFlightConversationId`, not `inFlightInvoiceId` — a whole batch is outstanding, * not one invoice — and the two markers block each other: a single registration cannot start while * a bulk batch is pending, and vice versa, because they share the same counter sequence. * * The conversation id is not known until MoR's 202 response arrives, so reservation stamps a * locally-generated placeholder token first (same "commit the reservation before the network call" * reasoning as the single flow), then swaps it for MoR's real conversation id right after — the only * value the webhook callback can actually use to find this batch again. * * Not live-testable from this sandbox (no route to MoR's real gateway) — signing the whole array as * one envelope, the way single `/v1/register` was confirmed live to need despite the collection's * raw example showing no envelope, is the reasonable extension of that confirmed behavior, not a * blind guess, but it has not itself been exercised against the real gateway. */ @Injectable() export class EimsBulkRegistrationService { private readonly logger = new Logger(EimsBulkRegistrationService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly config: ConfigService, private readonly client: EimsClientService, private readonly auth: EimsAuthService, private readonly notifications: NotificationsService, private readonly sellerCache: EimsSellerCacheService, ) {} private get cfg(): EimsConfig { return this.config.get("eims")!; } /** * Reserve counters for every eligible invoice and submit them as one `/v1/bulkRegister` call. * An invoice that already has an IRN is silently skipped (idempotent, matching single register); * everything else must pass the same DEB/CRE precondition single register checks, or the whole * call is refused before anything is reserved. */ async registerBulk( invoiceIds: string[], ): Promise<{ conversationId: string | null; accepted: string[]; alreadyRegistered: string[] }> { const cfg = this.cfg; assertEimsInvoiceConfig(cfg); const ids = [...new Set(invoiceIds)]; if (ids.length === 0) { throw new BadRequestException({ code: "EIMS_BULK_EMPTY", message: "No invoice ids given" }); } const invoices = await this.loadInvoicesForMapping(ids); const alreadyRegistered = invoices.filter((inv) => inv.eimsIrn).map((inv) => inv.id); const pending = invoices.filter((inv) => !inv.eimsIrn); // Same DEB/CRE precondition as single register, checked for every pending invoice before any // counter is touched: a bad member must fail the whole batch, not surface mid-submission. const prepared = pending.map((invoice) => { 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; } // Same rule as the single-invoice path: buyer geography is resolved from the MoR location // master before reserveBulk touches a counter, so one bad company address fails the whole // batch locally instead of burning a block of EIMS sequence numbers. const buyerGeo = resolveMorGeo({ country: invoice.company?.country, region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, }); return { invoice, documentType, relatedDocument, buyerGeo }; }); if (prepared.length === 0) { return { conversationId: null, accepted: [], alreadyRegistered }; } const session = await this.auth.getSessionContext(); const placeholder = `local:${randomUUID()}`; const reservations = await this.reserveBulk(prepared, session.systemNumber, placeholder); let conversationId: string; try { const requests: EimsBulkRegisterRequest = reservations.map((r) => toEimsInvoice( r.invoice, this.sellerCache.getSellerDetails(cfg), buildEimsContext(cfg, { buyerGeo: r.buyerGeo, documentNumber: r.documentNumber, invoiceCounter: r.invoiceCounter, previousIrn: r.previousIrn, session, documentType: r.documentType, reason: r.invoice.eimsReason, relatedDocument: r.relatedDocument, }), ), ); const response = await this.client.postSigned( "/v1/bulkRegister", requests, ); if (!response?.conversationId) { throw new EimsApiException( "SCHEMA_VALIDATION", "EIMS bulkRegister returned no conversationId", response?.status, ); } conversationId = response.conversationId; } catch (err) { await this.settleBulkFailure(reservations, err); throw err; } await this.claimConversationId(placeholder, conversationId); this.logger.log( `Bulk-registered ${reservations.length} invoice(s) with EIMS (conversation ${conversationId}), awaiting callback`, ); return { conversationId, accepted: reservations.map((r) => r.invoice.id), alreadyRegistered }; } /** * Settle a batch's callback, whenever MoR gets around to sending it. Called by * `EimsWebhookController` with the raw parsed array body — no auth on that route (MoR calls it, * not a logged-in user), so the only thing standing between this and a forged callback is the * conversation id itself: an item is only ever applied to an invoice actually holding that exact * id, and an unknown id is logged and ignored rather than touching anything. */ async handleBulkCallback(items: EimsBulkCallbackItem[]): Promise { const settlements = items.filter( (item): item is Exclude => "irn" in item || "ruleError" in item, ); const conversationId = this.markerFrom(items); const invoices = await this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") .where("invoice.eims_bulk_conversation_id = :id", { id: conversationId }) .getMany(); if (invoices.length === 0) { this.logger.warn( `EIMS bulk callback for an unknown or already-settled conversation — ignored (${settlements.length} item(s))`, ); return []; } const byDocumentNumber = new Map(invoices.map((inv) => [inv.eimsDocumentNumber, inv])); // Process in invoiceCounter order so `previousIrn` ends up as the last-accepted item's IRN — // the same "advance the chain" semantics as single register's settleSuccess. const ordered = [...settlements].sort((a, b) => { const invA = byDocumentNumber.get("documentNumber" in a ? a.documentNumber : a.docNo); const invB = byDocumentNumber.get("documentNumber" in b ? b.documentNumber : b.docNo); return (invA?.eimsInvoiceCounter ?? 0) - (invB?.eimsInvoiceCounter ?? 0); }); const results: EimsBulkRegisterItemResult[] = []; for (const item of ordered) { const docNumber = "documentNumber" in item ? item.documentNumber : item.docNo; const invoice = byDocumentNumber.get(docNumber); if (!invoice) { this.logger.warn(`EIMS bulk callback item for unknown document number ${docNumber} — ignored`); continue; } if (invoice.eimsStatus !== EimsInvoiceStatus.Submitting) { // Already settled — a duplicate callback delivery. Report the current state, touch nothing. results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: invoice.eimsStatus === EimsInvoiceStatus.Registered, message: `Already settled (${invoice.eimsStatus})`, irn: invoice.eimsIrn ?? undefined, }); continue; } if ("irn" in item) { await this.settleBulkItemSuccess(invoice, item.irn, conversationId, item.signedQR); results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: true, message: `Registered with EIMS (IRN ${item.irn})`, irn: item.irn, }); } else { const message = item.ruleError.flatMap((e) => e.errorMessage).join("; ") || "EIMS bulk rule validation error"; await this.settleBulkItemFailure(invoice, message); results.push({ invoiceId: invoice.id, invoiceNumber: invoice.invoiceNumber, success: false, message }); } } // Clear the batch's in-flight marker only once nothing submitted under this conversation is // still waiting — a partial/incremental callback (not expected per the collection's docs, but // not ruled out either) must not prematurely unblock the system number. const stillPending = await this.dataSource .getRepository(Invoice) .count({ where: { eimsBulkConversationId: conversationId, eimsStatus: EimsInvoiceStatus.Submitting } }); if (stillPending === 0) { await this.dataSource.manager.update( EimsSystemState, { inFlightConversationId: conversationId }, { inFlightConversationId: null }, ); this.logger.log(`EIMS bulk conversation ${conversationId} fully settled (${results.length} item(s))`); } return results; } // ── transactions ───────────────────────────────────────────────────────────────────────────── /** TX1. Reserve a contiguous block of N counters, one per invoice, in the given order. */ private async reserveBulk( prepared: Array<{ invoice: Invoice & { lines: EimsMapperLine[] }; documentType: EimsDocumentType; relatedDocument: string | null; buyerGeo: MorGeoCodes; }>, systemNumber: string, placeholder: 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.`, }); } if (state.inFlightConversationId) { throw new ConflictException({ code: "EIMS_BULK_IN_FLIGHT", message: `A bulk submission (conversation ${state.inFlightConversationId}) is already in flight on system ${systemNumber}. Wait for its callback, or resolve it if the process was interrupted.`, }); } let counter = Number(state.nextInvoiceCounter); let docNumber = Number(state.nextDocumentNumber); let previousIrn = state.previousIrn ?? ""; const reservations: BulkReservation[] = []; // Locked in the caller's given order — stable, avoids two concurrent bulk calls deadlocking // on the opposite lock order. for (const { invoice, documentType, relatedDocument, buyerGeo } of prepared) { const locked = await this.lockInvoice(manager, invoice.id); const thisCounter = counter++; const thisDocNumber = String(docNumber++); const thisPreviousIrn = reservations.length === 0 ? previousIrn : ""; await manager.update(Invoice, invoice.id, { eimsStatus: EimsInvoiceStatus.Submitting, eimsInvoiceCounter: thisCounter, eimsDocumentNumber: thisDocNumber, eimsSubmittedAt: new Date(), eimsLastError: null, eimsBulkConversationId: placeholder, } as QueryDeepPartialEntity); reservations.push({ stateId: state.id, invoice: Object.assign(locked, { lines: invoice.lines }), documentType, relatedDocument, buyerGeo, invoiceCounter: thisCounter, documentNumber: thisDocNumber, previousIrn: thisPreviousIrn, }); } await manager.update(EimsSystemState, state.id, { nextInvoiceCounter: counter, nextDocumentNumber: docNumber, inFlightConversationId: placeholder, }); return reservations; }); } /** Swap the local placeholder for MoR's real conversation id, on both the state row and every invoice. */ private async claimConversationId(placeholder: string, conversationId: string): Promise { await this.dataSource.transaction(async (manager) => { await manager.update(EimsSystemState, { inFlightConversationId: placeholder }, { inFlightConversationId: conversationId }); await manager.update(Invoice, { eimsBulkConversationId: placeholder }, { eimsBulkConversationId: conversationId }); }); } /** * TX2b for the whole batch — the same determinism doctrine as single register's settleFailure, * applied once since `/v1/bulkRegister` either accepts the whole array (202) or fails as one HTTP * call; there is no per-item answer yet at this point, only after the callback. */ private async settleBulkFailure(reservations: BulkReservation[], err: unknown): Promise { const api = err instanceof EimsApiException ? err : null; const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true; const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown; const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL"; const lastError: EimsInvoiceError = { kind: api?.kind ?? localKind, message: (err as Error)?.message ?? "unknown error", httpStatus: api?.httpStatus, details: api?.details, at: new Date().toISOString(), }; const first = reservations[0]; await this.dataSource.transaction(async (manager) => { for (const r of reservations) { await manager.update(Invoice, r.invoice.id, { eimsStatus: status, eimsLastError: lastError, ...(deterministic ? { eimsBulkConversationId: null } : {}), } as QueryDeepPartialEntity); } await manager.update( EimsSystemState, first.stateId, deterministic ? { // The whole block returns: MoR never counted a refused batch against either sequence. nextInvoiceCounter: first.invoiceCounter, nextDocumentNumber: Number(first.documentNumber), inFlightConversationId: null, } : { blockedReason: `A bulk submission of ${reservations.length} invoice(s) (starting counter ${first.invoiceCounter}) ` + `was sent but never acknowledged (${lastError.kind}). No further document can be filed until it is resolved.`, }, ); }); this.logger.error(`EIMS bulk submission ${status}: ${lastError.message}`); } /** One callback item accepted. */ private async settleBulkItemSuccess( invoice: Invoice, irn: string, conversationId: string, signedQR?: string, ): Promise { await this.dataSource.transaction(async (manager) => { await this.lockInvoice(manager, invoice.id); await manager.update(Invoice, invoice.id, { eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: irn, eimsSignedQr: signedQR ?? null, eimsLastError: null, }); // Looked up by conversation id, not system number — this batch's state row is whichever one // is holding this conversation, which is exactly what `inFlightConversationId` already tracks. await manager.update(EimsSystemState, { inFlightConversationId: conversationId }, { previousIrn: irn }); }); this.logger.log(`Invoice ${invoice.invoiceNumber} registered with EIMS via bulk (IRN ${irn})`); if (invoice.companyId) { try { await sendCompanyChannels( this.dataSource, this.notifications, invoice.companyId, `Invoice ${invoice.invoiceNumber} has been registered with MoR EIMS. Reference (IRN): ${irn}`, ); } catch (err) { this.logger.warn(`EIMS buyer notification failed for invoice ${invoice.id}: ${(err as Error).message}`); } } } /** * One callback item rejected. Unlike single register's settleFailure, the counter/document * number are not returned — MoR's own bulk processing already advanced the whole array's * allocation regardless of this item's individual outcome, so there is nothing local to roll back. */ private async settleBulkItemFailure(invoice: Invoice, message: string): Promise { const lastError: EimsInvoiceError = { kind: "RULE_VALIDATION", message, at: new Date().toISOString() }; await this.dataSource.manager.update(Invoice, invoice.id, { eimsStatus: EimsInvoiceStatus.Failed, eimsLastError: lastError, } as QueryDeepPartialEntity); this.logger.error(`Invoice ${invoice.invoiceNumber} EIMS bulk registration FAILED: ${message}`); } // ── internals ──────────────────────────────────────────────────────────────────────────────── private markerFrom(items: EimsBulkCallbackItem[]): string { const marker = items.find((i) => "conversationId" in i || "conversionId" in i) as | { conversationId?: string; conversionId?: string } | undefined; return marker?.conversationId ?? marker?.conversionId ?? ""; } 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; } 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; } private async loadInvoicesForMapping(invoiceIds: string[]): Promise> { const invoices = await this.dataSource.getRepository(Invoice).find({ where: { id: In(invoiceIds) }, relations: { company: true, companyProfile: true, relatedInvoice: true }, }); const found = new Set(invoices.map((inv) => inv.id)); const missing = invoiceIds.filter((id) => !found.has(id)); if (missing.length > 0) { throw new NotFoundException(`Invoice(s) not found: ${missing.join(", ")}`); } const lines: Array = await this.dataSource.query( `SELECT invoice_id AS "invoiceId", charge_type AS "chargeType", description, quantity, unit_rate AS "unitRate", amount, currency, metadata FROM freight.invoice_lines WHERE invoice_id = ANY($1) AND deleted_at IS NULL ORDER BY created_at ASC`, [invoiceIds], ); const linesByInvoice = new Map(); for (const line of lines) { const { invoiceId, ...rest } = line; if (!linesByInvoice.has(invoiceId)) linesByInvoice.set(invoiceId, []); linesByInvoice.get(invoiceId)!.push(rest); } // Preserve the caller's given order — reservation and result ordering both depend on it. return invoiceIds.map((id) => { const invoice = invoices.find((inv) => inv.id === id)!; return Object.assign(invoice, { lines: linesByInvoice.get(id) ?? [] }); }); } }