mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
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") }),
|
|
]);
|
|
});
|
|
});
|