mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(eims): implement POST /v1/bulkRegister
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.
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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> = {}): 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<string, Invoice>();
|
||||
state: EimsSystemState;
|
||||
companyContact: { phone: string | null; email: string | null } | null = null;
|
||||
|
||||
constructor(invoices: Invoice[], state: Partial<EimsSystemState> = {}) {
|
||||
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<string, unknown>): boolean {
|
||||
return Object.entries(where).every(([key, value]) => (entity as never)[key] === value);
|
||||
}
|
||||
|
||||
private queryBuilder(entityCtor: unknown) {
|
||||
let where: Record<string, unknown> = {};
|
||||
const builder = {
|
||||
setLock: () => builder,
|
||||
where: (_clause: string, params: Record<string, unknown>) => {
|
||||
where = { ...where, ...this.normalizeParams(params) };
|
||||
return builder;
|
||||
},
|
||||
andWhere: (_clause: string, params: Record<string, unknown>) => {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
// Test-only mapping from the SQL param names used in the service's own queries to entity fields.
|
||||
const map: Record<string, string> = {
|
||||
invoiceId: "id",
|
||||
systemNumber: "systemNumber",
|
||||
id: "eimsBulkConversationId",
|
||||
};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(params)) out[map[k] ?? k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
private find(entityCtor: unknown, where: Record<string, unknown>): Array<Invoice | EimsSystemState> {
|
||||
const isState = entityCtor === EimsSystemState;
|
||||
const pool: Array<Invoice | EimsSystemState> = 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<string, unknown> }) =>
|
||||
this.find(entityCtor, options.where)[0] ?? null,
|
||||
update: async (entityCtor: unknown, idOrWhere: string | Record<string, unknown>, patch: Record<string, unknown>) => {
|
||||
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<unknown>) => 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<string, unknown> }) => 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<Invoice>) =>
|
||||
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") }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<EimsConfig>("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<EimsBulkRegisterRequest, EimsBulkRegisterAcceptedResponse>(
|
||||
"/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<EimsBulkRegisterItemResult[]> {
|
||||
const settlements = items.filter(
|
||||
(item): item is Exclude<EimsBulkCallbackItem, { conversationId?: string; conversionId?: string }> =>
|
||||
"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<BulkReservation[]> {
|
||||
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<Invoice>);
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Invoice>);
|
||||
}
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Invoice>);
|
||||
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<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async loadInvoicesForMapping(invoiceIds: string[]): Promise<Array<Invoice & { lines: EimsMapperLine[] }>> {
|
||||
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<EimsMapperLine & { invoiceId: string }> = 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<string, EimsMapperLine[]>();
|
||||
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) ?? [] });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user