From fc55f48371ee8f5cbbaeb58907b9bd3f557ecea2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 19 Aug 2026 18:21:10 +0000 Subject: [PATCH] feat(eims): implement POST /v1/bulkRegister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints: POST invoices/eims/bulk-register { invoiceIds: [...] } — trigger POST eims/webhook/bulk-register — MoR's callback Fundamentally different shape from single register: bulkRegister answers only {conversationId, status:202} immediately: MoR processes the array asynchronously and pushes the real per-invoice results (a mix of accepted/rejected in one array, per the collection's own examples) to a webhook configured out of band. So this ships as two halves that don't share a call stack — EimsBulkRegistrationService. registerBulk() reserves a contiguous block of counters (durable reservation, same doctrine as single register, extended to N items) and submits; handleBulkCallback(), invoked by the new EimsWebhookController whenever MoR gets around to it, settles. New EimsSystemState.inFlightConversationId is the bulk equivalent of inFlightInvoiceId — a whole batch outstanding, not one invoice — and the two markers block each other since they share the same counter sequence. The conversation id isn't known until MoR's 202 arrives, so reservation stamps a locally-generated placeholder first (same commit-before-the-network-call reasoning as single register), then swaps it for MoR's real id right after — the only value the callback can actually use to find the batch again. Only the first invoice in a bulk batch chains via PreviousIrn — every other item gets an empty string, matching the collection's own two-invoice example exactly (MoR doesn't expect a batch to chain to IRNs that don't exist yet at submission time). Webhook has no auth (MoR has no JWT to send) — the conversation id embedded in the payload is what stands between this and a forged callback: an item only ever touches an invoice actually holding that exact id, and an unknown id is logged and ignored, never applied. Migration 3580000000000: eims_system_state.in_flight_conversation_id, invoices.eims_bulk_conversation_id (tags which batch an invoice was submitted in, so a stuck batch — webhook never arrived — can be found and reconciled by conversation id). Applied to dev DB and recorded in freight.migrations directly (idempotent IF NOT EXISTS DDL). 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. Left for the first live bulk attempt to confirm, same as every other MoR-facing assumption this integration has made. --- .../3580000000000-EimsBulkRegistration.ts | 35 ++ .../billing/entities/invoice.entity.ts | 8 + .../dto/bulk-register-eims-invoice.dto.ts | 11 + .../eims-bulk-registration.service.spec.ts | 359 ++++++++++++ .../eims/eims-bulk-registration.service.ts | 518 ++++++++++++++++++ .../modules/eims/eims-invoice.controller.ts | 15 + .../modules/eims/eims-registration.types.ts | 57 ++ .../modules/eims/eims-webhook.controller.ts | 25 + .../src/modules/eims/eims.module.ts | 6 +- .../eims/entities/eims-system-state.entity.ts | 7 + 10 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts create mode 100644 apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts diff --git a/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts new file mode 100644 index 000000000..140a7ad6a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3580000000000-EimsBulkRegistration.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`. + * + * `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`: + * a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously. + * `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck + * batch (webhook never arrived) can be found and reconciled by conversation id. + */ +export class EimsBulkRegistration3580000000000 implements MigrationInterface { + name = "EimsBulkRegistration3580000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ADD COLUMN IF NOT EXISTS in_flight_conversation_id text + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + DROP COLUMN IF EXISTS in_flight_conversation_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_bulk_conversation_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 43b6e3271..66bf0f452 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -203,4 +203,12 @@ export class Invoice extends BaseEntity { @ManyToOne(() => Invoice) @JoinColumn({ name: "related_invoice_id" }) relatedInvoice?: Invoice | null; + + /** + * Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own + * conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and + * reconciled. Null for every invoice filed through single `/v1/register`. + */ + @Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true }) + eimsBulkConversationId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts b/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts new file mode 100644 index 000000000..2509275e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/dto/bulk-register-eims-invoice.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ArrayMinSize, IsArray, IsUUID } from "class-validator"; + +/** `POST invoices/eims/bulk-register` body — see `EimsBulkRegistrationService.registerBulk`. */ +export class BulkRegisterEimsInvoiceDto { + @ApiProperty({ type: [String], description: "Invoice IDs to register with MoR EIMS in one batch." }) + @IsArray() + @ArrayMinSize(1) + @IsUUID("4", { each: true }) + invoiceIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts new file mode 100644 index 000000000..459ee8f71 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.spec.ts @@ -0,0 +1,359 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { Invoice } from "../billing/entities/invoice.entity"; +import { NotificationsService } from "../notifications/notifications.service"; +import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSellerCacheService } from "./eims-seller-cache.service"; +import { EimsSystemState } from "./entities/eims-system-state.entity"; +import { EimsApiException } from "./eims.errors"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { buildEimsSeller } from "./eims-invoice-context"; + +const SYSTEM_NUMBER = "B0360154BA"; +const INVOICE_A = "11111111-1111-4111-8111-111111111111"; +const INVOICE_B = "22222222-2222-4222-8222-222222222222"; +const CONVERSATION_ID = "2345678901-1735900502800-c04f8dd6-e6e2-4198-b871-c6e504fc14f5"; + +const invoiceRow = (over: Partial = {}): Invoice => + ({ + id: INVOICE_A, + invoiceNumber: "INV-20260807-00001", + currency: "ETB", + companyId: "company-1", + issuedAt: new Date(2026, 7, 7, 9, 5, 3), + totalAmount: "10000.00", + eimsStatus: EimsInvoiceStatus.NotSubmitted, + eimsIrn: null, + eimsDocumentType: "INV", + eimsBulkConversationId: null, + company: { + name: "ABC Trading PLC", + tin: "0999930000", + vatNumber: "123475885858", + phone: "0912345678", + region: "13", + zone: "SHA", + woreda: "574", + kebele: "03", + houseNo: "NEW", + country: "Ethiopia", + }, + ...over, + }) as unknown as Invoice; + +const LINES = (id: string) => [ + { + invoiceId: id, + chargeType: "RAIL_FREIGHT", + description: "Addis to Djibouti", + quantity: "1.00", + unitRate: "10000.00", + amount: "10000.00", + }, +]; + +/** In-memory stand-in covering the query/manager surface this service actually calls. */ +class FakeDb { + invoices = new Map(); + state: EimsSystemState; + companyContact: { phone: string | null; email: string | null } | null = null; + + constructor(invoices: Invoice[], state: Partial = {}) { + for (const inv of invoices) this.invoices.set(inv.id, inv); + this.state = { + id: "state-1", + systemNumber: SYSTEM_NUMBER, + nextInvoiceCounter: 1, + nextDocumentNumber: 1, + previousIrn: null, + inFlightInvoiceId: null, + inFlightCounter: null, + inFlightDocumentNumber: null, + inFlightConversationId: null, + blockedReason: null, + ...state, + } as EimsSystemState; + } + + private matches(entity: Invoice | EimsSystemState, where: Record): boolean { + return Object.entries(where).every(([key, value]) => (entity as never)[key] === value); + } + + private queryBuilder(entityCtor: unknown) { + let where: Record = {}; + const builder = { + setLock: () => builder, + where: (_clause: string, params: Record) => { + where = { ...where, ...this.normalizeParams(params) }; + return builder; + }, + andWhere: (_clause: string, params: Record) => { + where = { ...where, ...this.normalizeParams(params) }; + return builder; + }, + getOne: async () => this.find(entityCtor, where)[0] ?? null, + getMany: async () => this.find(entityCtor, where), + }; + return builder; + } + + private normalizeParams(params: Record): Record { + // Test-only mapping from the SQL param names used in the service's own queries to entity fields. + const map: Record = { + invoiceId: "id", + systemNumber: "systemNumber", + id: "eimsBulkConversationId", + }; + const out: Record = {}; + for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v; + return out; + } + + private find(entityCtor: unknown, where: Record): Array { + const isState = entityCtor === EimsSystemState; + const pool: Array = isState ? [this.state] : [...this.invoices.values()]; + return pool.filter((e) => this.matches(e, where)); + } + + private manager = { + createQueryBuilder: (entityCtor: unknown) => this.queryBuilder(entityCtor), + query: async () => [], + findOne: async (entityCtor: unknown, options: { where: Record }) => + this.find(entityCtor, options.where)[0] ?? null, + update: async (entityCtor: unknown, idOrWhere: string | Record, patch: Record) => { + const targets = + typeof idOrWhere === "string" + ? this.find(entityCtor, { id: idOrWhere }) + : this.find(entityCtor, idOrWhere); + for (const t of targets) Object.assign(t, patch); + return { affected: targets.length }; + }, + getRepository: (entityCtor: unknown) => ({ + findOne: async (options: { where: { id: string } }) => this.find(entityCtor, { id: options.where.id })[0] ?? null, + }), + }; + + asDataSource(): DataSource { + return { + manager: this.manager, + // Routed by SQL text: the lines lookup and sendCompanyChannels' contact lookup share this + // one entry point in the real DataSource. + query: async (sql: string) => { + if (sql.includes("invoice_lines")) { + return [...this.invoices.keys()].flatMap((id) => LINES(id)); + } + return this.companyContact ? [this.companyContact] : []; + }, + transaction: async (body: (m: unknown) => Promise) => body(this.manager), + getRepository: () => ({ + find: async (options: { where: { id: { value: string[] } } }) => { + const ids = options.where.id.value ?? []; + return ids.map((id: string) => this.invoices.get(id)).filter(Boolean); + }, + createQueryBuilder: (alias: string) => { + void alias; + return this.queryBuilder(Invoice); + }, + count: async (options: { where: Record }) => this.find(Invoice, options.where).length, + }), + } as unknown as DataSource; + } +} + +const build = (db: FakeDb, postSigned: jest.Mock, directSend: jest.Mock = jest.fn().mockResolvedValue(undefined)) => + new EimsBulkRegistrationService( + db.asDataSource(), + { get: () => eimsConfig({ invoice: eimsInvoiceConfig() }) } as unknown as ConfigService, + { postSigned } as unknown as EimsClientService, + { getSessionContext: async () => ({ systemNumber: SYSTEM_NUMBER, systemType: "SYS" }) } as unknown as EimsAuthService, + { directSend } as unknown as NotificationsService, + { getSellerDetails: (c: unknown) => buildEimsSeller(c as never) } as unknown as EimsSellerCacheService, + ); + +const accepted = (conversationId = CONVERSATION_ID) => ({ conversationId, status: 202 }); + +describe("EimsBulkRegistrationService.registerBulk", () => { + it("reserves sequential counters, sends one signed array, and claims MoR's real conversation id", async () => { + const db = new FakeDb( + [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], + { nextInvoiceCounter: 5, nextDocumentNumber: 5, previousIrn: "prev-irn" }, + ); + const postSigned = jest.fn().mockResolvedValue(accepted()); + + const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); + + expect(result).toEqual({ conversationId: CONVERSATION_ID, accepted: [INVOICE_A, INVOICE_B], alreadyRegistered: [] }); + const [, request] = postSigned.mock.calls[0]; + expect(request).toHaveLength(2); + expect(request[0].SourceSystem.InvoiceCounter).toBe(5); + expect(request[0].DocumentDetails.DocumentNumber).toBe("5"); + expect(request[0].ReferenceDetails.PreviousIrn).toBe("prev-irn"); + expect(request[1].SourceSystem.InvoiceCounter).toBe(6); + // Only the first item in a bulk batch chains — the rest have no IRN to reference yet. + expect(request[1].ReferenceDetails.PreviousIrn).toBe(""); + + expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); + expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Submitting, eimsBulkConversationId: CONVERSATION_ID }); + expect(db.state.nextInvoiceCounter).toBe(7); + expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); + }); + + it("skips an already-registered invoice, without consuming a counter for it", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: "already-irn", eimsStatus: EimsInvoiceStatus.Registered }), + invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" }), + ]); + const postSigned = jest.fn().mockResolvedValue(accepted()); + + const result = await build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B]); + + expect(result.alreadyRegistered).toEqual([INVOICE_A]); + expect(result.accepted).toEqual([INVOICE_B]); + const [, request] = postSigned.mock.calls[0]; + expect(request).toHaveLength(1); + }); + + it("refuses the whole batch — no reservation, no HTTP call — when a DEB note has no registered original", async () => { + const db = new FakeDb([ + invoiceRow({ eimsDocumentType: "DEB", relatedInvoice: { eimsIrn: null, invoiceNumber: "INV-orig" } as never }), + ]); + const postSigned = jest.fn(); + + await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(BadRequestException); + expect(postSigned).not.toHaveBeenCalled(); + expect(db.state.inFlightConversationId).toBeNull(); + }); + + it("refuses when a single-invoice submission is already in flight", async () => { + const db = new FakeDb([invoiceRow()], { inFlightInvoiceId: "some-other-invoice" }); + await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses when another bulk batch is already in flight", async () => { + const db = new FakeDb([invoiceRow()], { inFlightConversationId: "other-conversation" }); + await expect(build(db, jest.fn()).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(ConflictException); + }); + + it("a deterministic rejection rolls back the whole block and clears the in-flight marker", async () => { + const db = new FakeDb( + [invoiceRow(), invoiceRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002" })], + { nextInvoiceCounter: 5, nextDocumentNumber: 5 }, + ); + const postSigned = jest.fn().mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "bad", 400)); + + await expect(build(db, postSigned).registerBulk([INVOICE_A, INVOICE_B])).rejects.toBeInstanceOf(EimsApiException); + + expect(db.state.nextInvoiceCounter).toBe(5); + expect(db.state.nextDocumentNumber).toBe(5); + expect(db.state.inFlightConversationId).toBeNull(); + expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Failed); + expect(db.invoices.get(INVOICE_A)?.eimsBulkConversationId).toBeNull(); + }); + + it("an ambiguous failure blocks the system number and leaves counters consumed", async () => { + const db = new FakeDb([invoiceRow()], { nextInvoiceCounter: 5, nextDocumentNumber: 5 }); + const postSigned = jest.fn().mockRejectedValue(new EimsApiException("TIMEOUT", "timed out")); + + await expect(build(db, postSigned).registerBulk([INVOICE_A])).rejects.toBeInstanceOf(EimsApiException); + + expect(db.state.nextInvoiceCounter).toBe(6); + expect(db.state.blockedReason).toMatch(/never acknowledged/); + expect(db.invoices.get(INVOICE_A)?.eimsStatus).toBe(EimsInvoiceStatus.Unknown); + }); + + it("refuses an empty invoice list", async () => { + const db = new FakeDb([invoiceRow()]); + await expect(build(db, jest.fn()).registerBulk([])).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe("EimsBulkRegistrationService.handleBulkCallback", () => { + const submittingRow = (over: Partial) => + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Submitting, + eimsBulkConversationId: CONVERSATION_ID, + ...over, + }); + + it("settles a mixed success/error callback, advancing previousIrn to the last accepted item", async () => { + const db = new FakeDb( + [ + submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), + submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "5" }, + { ruleError: [{ portion: "DocumentDetails", errorMessage: ["bad date"] }], status: "ERROR", docNo: "6" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(db.invoices.get(INVOICE_A)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "irn-a" }); + expect(db.invoices.get(INVOICE_B)).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed }); + expect(db.state.previousIrn).toBe("irn-a"); + expect(db.state.inFlightConversationId).toBeNull(); + expect(results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ invoiceId: INVOICE_A, success: true, irn: "irn-a" }), + expect.objectContaining({ invoiceId: INVOICE_B, success: false }), + ]), + ); + }); + + it("ignores a callback for an unknown or already-settled conversation", async () => { + const db = new FakeDb([invoiceRow()]); + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-x", status: "A", documentNumber: "1" }, + { conversionId: "no-such-conversation" }, + ]); + expect(results).toEqual([]); + }); + + it("does not clear the in-flight marker while another invoice in the batch is still submitting", async () => { + const db = new FakeDb( + [ + submittingRow({ eimsInvoiceCounter: 5, eimsDocumentNumber: "5" }), + submittingRow({ id: INVOICE_B, invoiceNumber: "INV-20260807-00002", eimsInvoiceCounter: 6, eimsDocumentNumber: "6" }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + // Callback only reports on one of the two invoices in this batch. + await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "5" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(db.state.inFlightConversationId).toBe(CONVERSATION_ID); + }); + + it("reports the current state without re-settling an invoice that already resolved", async () => { + const db = new FakeDb( + [ + invoiceRow({ + eimsStatus: EimsInvoiceStatus.Registered, + eimsIrn: "irn-a", + eimsDocumentNumber: "1", + eimsBulkConversationId: CONVERSATION_ID, + }), + ], + { inFlightConversationId: CONVERSATION_ID }, + ); + + const results = await build(db, jest.fn()).handleBulkCallback([ + { irn: "irn-a", status: "A", documentNumber: "1" }, + { conversionId: CONVERSATION_ID }, + ]); + + expect(results).toEqual([ + expect.objectContaining({ invoiceId: INVOICE_A, success: true, message: expect.stringContaining("Already settled") }), + ]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts new file mode 100644 index 000000000..4638d3555 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -0,0 +1,518 @@ +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 { 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; + 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; + } + return { invoice, documentType, relatedDocument }; + }); + + 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, { + 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 }>, + 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 } 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, + 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) ?? [] }); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 11eb214dc..db28eeafe 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -6,10 +6,12 @@ import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { sendPdf } from "../billing/billing.controller"; import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto"; +import { BulkRegisterEimsInvoiceDto } from "./dto/bulk-register-eims-invoice.dto"; import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { ResolveEimsRegistrationDto } from "./dto/resolve-eims-registration.dto"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; import { EimsCancellationService } from "./eims-cancellation.service"; import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; import { EimsReceiptService } from "./eims-receipt.service"; @@ -39,6 +41,7 @@ import { EimsReceiptService } from "./eims-receipt.service"; export class EimsInvoiceController { constructor( private readonly registration: EimsInvoiceRegistrationService, + private readonly bulkRegistration: EimsBulkRegistrationService, private readonly cancellation: EimsCancellationService, private readonly receipts: EimsReceiptService, ) {} @@ -53,6 +56,18 @@ export class EimsInvoiceController { return this.registration.registerInvoiceWithEims(id); } + @Post("eims/bulk-register") + @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) + @ApiOperation({ + summary: + "Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR " + + "accepted the batch (a conversation id), not the per-invoice outcome. Real results (IRN or " + + "rejection per invoice) arrive later via MoR's own callback; poll GET :id/eims/status.", + }) + bulkRegister(@Body() dto: BulkRegisterEimsInvoiceDto) { + return this.bulkRegistration.registerBulk(dto.invoiceIds); + } + @Post(":id/eims/verify") @BookingStaff(FREIGHT_PERMS.invoices.eimsRegister) @ApiOperation({ summary: "Verify the invoice's stored IRN against EIMS" }) diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index 60941825c..7cc58c0a9 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -1,3 +1,4 @@ +import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper"; import { EimsErrorResponse } from "./eims.types"; /** @@ -129,6 +130,62 @@ export interface EimsBulkCancelItemResult { message: string; } +/** + * `POST /v1/bulkRegister` — same array-of-full-documents shape as single register (`EimsInvoiceRequest` + * from `eims-invoice.mapper.ts`), one element per invoice, sent as one signed envelope. + */ +export type EimsBulkRegisterRequest = EimsInvoiceRequest[]; + +/** + * Immediate response to `bulkRegister` — unlike single register, this is not the result, just an + * acknowledgement. The real per-invoice outcomes arrive later via `EimsBulkCallbackItem`s pushed to + * a webhook MoR was configured with out of band (see `EimsBulkRegistrationService`). + */ +export interface EimsBulkRegisterAcceptedResponse { + conversationId: string; + status: number; +} + +/** A settled item in the async callback — `irn` present means MoR accepted this document. */ +export interface EimsBulkCallbackSuccessItem { + irn: string; + status: string; + documentNumber: string; + signedQR?: string; + signedInvoice?: string; +} + +/** A rejected item in the async callback — `docNo` echoes what we submitted as `DocumentNumber`. */ +export interface EimsBulkCallbackErrorItem { + ruleError: Array<{ portion: string; errorMessage: string[] }>; + status: string; + docNo: string; +} + +/** + * The callback array's last element, per the collection's own examples — never a settlement result, + * just the batch id echoed back. Spelled two different ways across the collection's own docs + * ("conversationId" on the initial 202, "conversionId" in the callback examples); accept both. + */ +export interface EimsBulkCallbackMarker { + conversationId?: string; + conversionId?: string; +} + +export type EimsBulkCallbackItem = + | EimsBulkCallbackSuccessItem + | EimsBulkCallbackErrorItem + | EimsBulkCallbackMarker; + +/** One invoice's outcome once a bulk batch's callback has been processed. */ +export interface EimsBulkRegisterItemResult { + invoiceId: string; + invoiceNumber: string; + success: boolean; + message: string; + irn?: string; +} + /** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */ export interface EimsInvoiceError { kind: string; diff --git a/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts new file mode 100644 index 000000000..e2b3ab49e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-webhook.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, HttpCode, Post } from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; +import { EimsBulkCallbackItem } from "./eims-registration.types"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; + +/** + * MoR's own callback for `POST /v1/bulkRegister`, not a route a person calls. Public — MoR has no + * JWT to send — so the conversation id embedded in the payload is the only thing standing between + * this and a forged callback: `EimsBulkRegistrationService.handleBulkCallback` only ever touches + * invoices actually holding that exact id, and an unrecognised one is logged and ignored. See the + * "Callback Mechanism" section of the collection's own docs for the payload shape. + */ +@ApiExcludeController() +@Controller("eims/webhook") +export class EimsWebhookController { + constructor(private readonly bulk: EimsBulkRegistrationService) {} + + @Public() + @Post("bulk-register") + @HttpCode(200) + bulkRegisterCallback(@Body() items: EimsBulkCallbackItem[]) { + return this.bulk.handleBulkCallback(items); + } +} diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 5d55a7ee8..739bf7089 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -9,6 +9,7 @@ import { NotificationInboxModule } from "../notification-inbox/notification-inbo import { NotificationsModule } from "../notifications/notifications.module"; import { EimsAuthService } from "./eims-auth.service"; import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsBulkRegistrationService } from "./eims-bulk-registration.service"; import { EimsCancellationService } from "./eims-cancellation.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; @@ -17,6 +18,7 @@ import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.serv import { EimsReceiptService } from "./eims-receipt.service"; import { EimsSellerCacheService } from "./eims-seller-cache.service"; import { EimsSignerService } from "./eims-signer.service"; +import { EimsWebhookController } from "./eims-webhook.controller"; import { EimsReceipt } from "./entities/eims-receipt.entity"; import { EimsSystemState } from "./entities/eims-system-state.entity"; @@ -40,13 +42,14 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; // so this stays a plain one-directional import, not a new cycle. CompaniesModule, ], - controllers: [EimsInvoiceController], + controllers: [EimsInvoiceController, EimsWebhookController], providers: [ EimsCredentialsProvider, EimsSignerService, EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsBulkRegistrationService, EimsAutoSubmitService, EimsCancellationService, EimsReceiptService, @@ -56,6 +59,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsBulkRegistrationService, EimsCancellationService, EimsReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index 328aa8d0f..67b5d74d4 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -54,4 +54,11 @@ export class EimsSystemState extends BaseEntity { */ @Column({ name: "blocked_reason", type: "text", nullable: true }) blockedReason?: string | null; + + /** + * Bulk equivalent of `in_flight_invoice_id` — a whole batch, not one invoice, is outstanding + * while MoR processes `POST /v1/bulkRegister` asynchronously. See `EimsBulkRegistrationService`. + */ + @Column({ name: "in_flight_conversation_id", type: "text", nullable: true }) + inFlightConversationId?: string | null; }