Merge pull request #1174 from Tria-plc/eims-integration

Eims integration
This commit is contained in:
Hagernesh Tadesse
2026-08-08 07:58:22 +03:00
committed by GitHub
21 changed files with 566 additions and 27 deletions

View File

@@ -172,14 +172,16 @@ EIMS_SELLER_LOCALITY=
# Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all
# (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails
# locally, naming the missing variables, until these are set.
EIMS_TAX_CODE=0
# MoR enum: TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH
EIMS_TAX_CODE=VAT0
EIMS_TAX_RATE_PERCENT=0
EIMS_EXCISE_TAX_VALUE=0
EIMS_INCOME_WITHHOLD_VALUE=0
EIMS_TRANSACTION_WITHHOLD_VALUE=0
# Document classification and payment presentation.
EIMS_TRANSACTION_TYPE=B2B
EIMS_NATURE_OF_SUPPLIES=Service
# Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'.
EIMS_NATURE_OF_SUPPLIES=service
EIMS_PAYMENT_MODE=CASH
EIMS_PAYMENT_TERM=IMMIDIATE
EIMS_UNIT_DEFAULT=PCS

View File

@@ -81,6 +81,8 @@ export interface EimsInvoiceConfig {
paymentTerm: string;
unitDefault: string;
buyerCountryCode: string | null;
/** MoR region code used when a buyer's stored region is a name rather than a code. */
buyerRegionFallback: string | null;
cashierName: string | null;
salesPersonName: string | null;
}
@@ -168,6 +170,7 @@ export default registerAs("eims", (): EimsConfig => {
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
buyerRegionFallback: process.env.EIMS_BUYER_REGION_FALLBACK || null,
cashierName: process.env.EIMS_CASHIER_NAME || null,
salesPersonName: process.env.EIMS_SALESPERSON_NAME || null,
},

View File

@@ -24,7 +24,7 @@ import { MigrationInterface, QueryRunner } from "typeorm";
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
* verbatim so a compliance value is never mangled by a parse.
*/
export class EimsInvoiceRegistration3300000000000 implements MigrationInterface {
export class EimsInvoiceRegistration3330000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS document numbering.
*
* MoR validates `DocumentDetails.DocumentNumber` against `^(0|[1-9][0-9]{0,8})$` — a plain integer
* of at most nine digits. Our own `INV-YYYYMMDD-NNNNN` can therefore never be sent, so EIMS needs
* its own sequence, allocated from the same locked state row as the invoice counter and recorded
* on the invoice so a filed document can be traced back to it.
*/
export class EimsDocumentNumberSequence3340000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS next_document_number bigint NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS in_flight_document_number bigint
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_document_number varchar(16)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices DROP COLUMN IF EXISTS eims_document_number
`);
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS next_document_number,
DROP COLUMN IF EXISTS in_flight_document_number
`);
}
}

View File

@@ -210,6 +210,14 @@ export interface EimsMapperContext {
relatedDocument?: string | null;
/** MoR numeric country code for the buyer; our DB stores the country name. */
buyerCountryCode?: string | null;
/**
* Region code to use when the buyer's stored region is not already one.
*
* MoR validates `BuyerDetails.Region` against `^[0-9]{1,3}$`, but `companies.region` is free
* text ("Addis Ababa"). Rather than ship a name→code table we cannot verify, a stored value that
* already looks like a code is passed through and anything else falls back to this.
*/
buyerRegionFallback?: string | null;
buyerIdType?: string | null;
buyerIdNumber?: string | null;
buyerCity?: string | null;
@@ -220,6 +228,9 @@ export interface EimsMapperContext {
formatDate?: (issuedAt: Date) => string;
}
/** MoR's own constraint on `Region`: one to three digits. */
const REGION_CODE = /^[0-9]{1,3}$/;
const num = (v: number | string): number => {
const n = Number(v);
if (!Number.isFinite(n)) throw new Error(`EIMS mapping: expected a numeric value, got ${String(v)}`);
@@ -329,7 +340,9 @@ export function toEimsInvoice(
Tin: company.tin,
LegalName: company.name,
Phone: company.phone ?? null,
Region: company.region ?? null,
Region: REGION_CODE.test(company.region ?? "")
? (company.region as string)
: (context.buyerRegionFallback ?? null),
Country: context.buyerCountryCode ?? null,
Zone: company.zone ?? null,
Kebele: company.kebele ?? null,

View File

@@ -115,6 +115,10 @@ export class Invoice extends BaseEntity {
@Column({ name: "eims_irn", type: "varchar", length: 64, nullable: true })
eimsIrn?: string | null;
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
eimsDocumentNumber?: string | null;
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
eimsInvoiceCounter?: number | null;

View File

@@ -112,6 +112,9 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
incomeWithholdValue: invoice.incomeWithholdValue!,
transactionWithholdValue: invoice.transactionWithholdValue!,
buyerCountryCode: invoice.buyerCountryCode,
// companies.region is free text ("Addis Ababa"); MoR wants ^[0-9]{1,3}$. A stored value that
// already looks like a code wins, otherwise the seller's own region stands in.
buyerRegionFallback: invoice.buyerRegionFallback || invoice.sellerRegion,
exchangeRate: input.exchangeRate ?? null,
};
}

View File

@@ -6,6 +6,7 @@ import { EimsConfig } from "../../config/eims.config";
import { Invoice } from "../billing/entities/invoice.entity";
import { EimsInvoiceRequest } from "../billing/eims-invoice.mapper";
import { eimsInvoiceConfig } from "./eims-test-fixtures";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -91,9 +92,11 @@ class FakeDb {
id: "state-1",
systemNumber: SYSTEM_NUMBER,
nextInvoiceCounter: 7,
nextDocumentNumber: 5,
previousIrn: null,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
...state,
} as EimsSystemState;
@@ -130,7 +133,10 @@ class FakeDb {
return {
manager: this.manager,
getRepository: this.manager.getRepository,
query: async () => LINES,
query: async (sql: string) =>
sql.includes("eims_system_state")
? [{ in_flight_invoice_id: this.state?.inFlightInvoiceId ?? null }]
: LINES,
transaction: async (body: (m: unknown) => Promise<unknown>) => {
this.onTransaction?.();
return body(this.manager);
@@ -147,17 +153,26 @@ const build = (
postSigned: jest.Mock,
cfg: EimsConfig = config(),
postBearer: jest.Mock = jest.fn(),
getSessionContext: jest.Mock = jest.fn().mockResolvedValue(SESSION),
getSessionContext: jest.Mock | undefined = undefined,
notify: jest.Mock = jest.fn().mockResolvedValue(undefined),
) =>
new EimsInvoiceRegistrationService(
db.asDataSource(),
{ get: () => cfg } as unknown as ConfigService,
{ postSigned, postBearer } as unknown as EimsClientService,
{ getSessionContext } as unknown as EimsAuthService,
{
getSessionContext: getSessionContext ?? jest.fn().mockResolvedValue(SESSION),
} as unknown as EimsAuthService,
{ notify } as unknown as NotificationInboxService,
);
/** Document number the fixtures register under; `/v1/verify` must echo it back. */
const DOCUMENT_NUMBER = "INV-20260807-00042";
/**
* Document number the fixtures register under; `/v1/verify` must echo it back.
*
* A plain integer, not our `invoiceNumber`: MoR validates the field against
* `^(0|[1-9][0-9]{0,8})$`. It is allocated from `nextDocumentNumber` above.
*/
const DOCUMENT_NUMBER = "5";
/**
* `/v1/verify` success. The response spells the reference `Irn` while the request sends lowercase
@@ -220,7 +235,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
const request = postSigned.mock.calls[0][1] as EimsInvoiceRequest;
expect(request.SourceSystem.InvoiceCounter).toBe(42);
expect(request.ReferenceDetails.PreviousIrn).toBe("PRIOR-IRN");
expect(request.DocumentDetails.DocumentNumber).toBe("INV-20260807-00042");
expect(request.DocumentDetails.DocumentNumber).toBe(DOCUMENT_NUMBER);
expect(request.SourceSystem.SystemNumber).toBe(SYSTEM_NUMBER);
});
@@ -345,7 +360,9 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
inFlightInvoiceId: null,
blockedReason: null,
previousIrn: null,
nextInvoiceCounter: 8, // consumed: the attempt reached the gateway
// Returned, not consumed: MoR tracks the sequence and rejects a gap
// ("Invoice counter is not correct. expected : 1").
nextInvoiceCounter: 7,
});
});
@@ -391,7 +408,7 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
expect(postSigned).toHaveBeenCalledTimes(1);
});
it("never reuses a counter once an attempt has begun", async () => {
it("returns the counter after a refusal, but keeps it after an ambiguous result", async () => {
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID })]);
const postSigned = jest
.fn()
@@ -404,8 +421,68 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
);
await service.registerInvoiceWithEims(OTHER_INVOICE_ID);
// A refused document returns its counter, so the next attempt reuses it — MoR expects a
// contiguous sequence of *accepted* documents, not of attempts.
expect((postSigned.mock.calls[0][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(8);
expect((postSigned.mock.calls[1][1] as EimsInvoiceRequest).SourceSystem.InvoiceCounter).toBe(7);
});
});
describe("EimsInvoiceRegistrationService staff alerting", () => {
it("raises a high-priority alert when a result is ambiguous, because all filing is blocked", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("TIMEOUT"));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify).toHaveBeenCalledTimes(1);
const sent = notify.mock.calls[0][0];
expect(sent.priority).toBe("HIGH");
expect(sent.title).toMatch(/blocked/i);
expect(sent.recipients.permissionKeys).toContain("edr_freight_app:invoices:eims_resolve");
});
it("raises a normal-priority alert for a deterministic rejection", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockResolvedValue(undefined);
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toBeInstanceOf(EimsApiException);
expect(notify.mock.calls[0][0].priority).toBe("NORMAL");
});
it("does not alert on a successful filing", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn();
await build(db, jest.fn().mockResolvedValue(okResponse()), config(), jest.fn(), undefined, notify)
.registerInvoiceWithEims(INVOICE_ID);
expect(notify).not.toHaveBeenCalled();
});
it("lets the filing outcome stand even if the alert itself fails", async () => {
const db = new FakeDb([invoiceRow()]);
const notify = jest.fn().mockRejectedValue(new Error("inbox down"));
const postSigned = jest.fn().mockRejectedValue(apiError("RULE_VALIDATION", 406));
await expect(
build(db, postSigned, config(), jest.fn(), undefined, notify).registerInvoiceWithEims(
INVOICE_ID,
),
).rejects.toThrow(/EIMS register failed \(406\)/);
expect(db.invoices.get(INVOICE_ID)!.eimsStatus).toBe(EimsInvoiceStatus.Failed);
});
});
@@ -447,7 +524,15 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const blocked = () =>
new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown, eimsInvoiceCounter: 7 })], {
new FakeDb(
[
invoiceRow({
eimsStatus: EimsInvoiceStatus.Unknown,
eimsInvoiceCounter: 7,
eimsDocumentNumber: DOCUMENT_NUMBER,
}),
],
{
inFlightInvoiceId: INVOICE_ID,
inFlightCounter: 7,
nextInvoiceCounter: 8,
@@ -498,13 +583,13 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
const db = blocked();
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "INV-20260807-99999" },
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not INV-20260807-00042/);
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
eimsStatus: EimsInvoiceStatus.Unknown,
@@ -547,7 +632,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses to resolve an invoice that is not the in-flight one", async () => {
const db = blocked();
db.invoices.set(OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID }));
db.invoices.set(
OTHER_INVOICE_ID,
invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }),
);
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
await expect(

View File

@@ -17,6 +17,9 @@ import {
EimsMapperLine,
toEimsInvoice,
} from "../billing/eims-invoice.mapper";
import { NotificationAudience, NotificationPriority, NotificationType } from "@edr/types";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { EimsAuthService } from "./eims-auth.service";
import { EimsClientService } from "./eims-client.service";
import { EimsApiException } from "./eims.errors";
@@ -44,6 +47,8 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
interface Reservation {
stateId: string;
invoiceCounter: number;
/** MoR requires a plain integer here, so it cannot be our own `invoiceNumber`. */
documentNumber: string;
previousIrn: string;
}
@@ -72,6 +77,7 @@ export class EimsInvoiceRegistrationService {
private readonly config: ConfigService,
private readonly client: EimsClientService,
private readonly auth: EimsAuthService,
private readonly inbox: NotificationInboxService,
) {}
private get cfg(): EimsConfig {
@@ -98,8 +104,9 @@ export class EimsInvoiceRegistrationService {
invoice,
buildEimsSeller(cfg),
buildEimsContext(cfg, {
// Our own invoice number is the document number; EIMS only requires it to be unique.
documentNumber: invoice.invoiceNumber,
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
documentNumber: reservation.documentNumber,
invoiceCounter: reservation.invoiceCounter,
previousIrn: reservation.previousIrn,
session,
@@ -174,8 +181,9 @@ export class EimsInvoiceRegistrationService {
* Refuse a manual resolution unless the gateway confirms *both* halves of the claim: that this
* IRN is the one it holds, and that it belongs to this invoice.
*
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration set
* from our own `invoiceNumber` — the only field tying an IRN back to a row in this database.
* The document-number check is against `DocumentDetails.DocumentNumber`, which registration
* allocated and stored on the invoice as `eimsDocumentNumber` — the only field tying an IRN back
* to a row in this database.
*
* Recording a wrong IRN is not a local mistake: it marks an unregistered invoice as filed and
* chains every later document to a stranger's reference, so both checks are refusals rather
@@ -231,11 +239,34 @@ export class EimsInvoiceRegistrationService {
});
}
// Cheap ownership check before touching the gateway: resolving an invoice that does not hold
// the reservation is a caller mistake, not something to spend a MoR round trip on. The
// authoritative re-check happens under lock in the transaction below.
const [preState]: { in_flight_invoice_id: string | null }[] = await this.dataSource.query(
`SELECT in_flight_invoice_id FROM freight.eims_system_state
WHERE system_number = $1 AND deleted_at IS NULL LIMIT 1`,
[(await this.auth.getSessionContext()).systemNumber],
);
if (preState?.in_flight_invoice_id && preState.in_flight_invoice_id !== invoiceId) {
throw new ConflictException({
code: "EIMS_RESOLVE_WRONG_INVOICE",
message: `The in-flight EIMS submission is invoice ${preState.in_flight_invoice_id}, not ${invoiceId}`,
});
}
// Outside the transaction: no lock is held across the wire, and a refused verification must
// leave the block exactly as it was.
if (irn) {
const invoice = await this.loadInvoiceRow(this.dataSource.manager, invoiceId);
await this.assertIrnBelongsToInvoice(irn, invoice.invoiceNumber);
if (!invoice.eimsDocumentNumber) {
throw new BadRequestException({
code: "EIMS_NO_DOCUMENT_NUMBER",
message:
`Invoice ${invoice.invoiceNumber} was never allocated an EIMS document number, so a ` +
"returned IRN cannot be tied back to it.",
});
}
await this.assertIrnBelongsToInvoice(irn, invoice.eimsDocumentNumber);
}
// Same source of truth as registration: the state row is keyed by the token's system number.
@@ -266,6 +297,7 @@ export class EimsInvoiceRegistrationService {
...(irn ? { previousIrn: irn } : {}),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
@@ -311,23 +343,27 @@ export class EimsInvoiceRegistrationService {
if (invoice.eimsIrn) return null;
const invoiceCounter = Number(state.nextInvoiceCounter);
const documentNumber = String(Number(state.nextDocumentNumber));
const previousIrn = state.previousIrn ?? "";
// Counter consumed here, not on success: once an attempt begins it can never be reused,
// whatever happens next. A gap is harmless at MoR; a collision is not.
await manager.update(EimsSystemState, state.id, {
nextInvoiceCounter: invoiceCounter + 1,
nextDocumentNumber: Number(documentNumber) + 1,
inFlightInvoiceId: invoiceId,
inFlightCounter: invoiceCounter,
inFlightDocumentNumber: Number(documentNumber),
});
await manager.update(Invoice, invoiceId, {
eimsStatus: EimsInvoiceStatus.Submitting,
eimsInvoiceCounter: invoiceCounter,
eimsDocumentNumber: documentNumber,
eimsSubmittedAt: new Date(),
eimsLastError: null,
});
return { stateId: state.id, invoiceCounter, previousIrn };
return { stateId: state.id, invoiceCounter, documentNumber, previousIrn };
});
}
@@ -350,15 +386,21 @@ export class EimsInvoiceRegistrationService {
previousIrn: irn,
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
});
});
}
/**
* TX2b. A deterministic rejection releases the reservation; an ambiguous result keeps it and
* blocks the system number, because `PreviousIrn` is now unknown for every later document.
* The counter stays consumed either way.
* TX2b. A deterministic rejection releases the reservation **and returns the counter**; an
* ambiguous result keeps both and blocks the system number, because `PreviousIrn` is now unknown
* for every later document.
*
* Returning the counter is not an optimisation — MoR tracks the sequence itself and rejects a
* gap: "Invoice counter is not correct. expected : 1". A document it definitively refused was
* never counted on its side, so ours must not advance either. An ambiguous result is the
* opposite case: MoR may have counted it, so the number stays spent until a human resolves it.
*/
private async settleFailure(
invoiceId: string,
@@ -386,7 +428,15 @@ export class EimsInvoiceRegistrationService {
EimsSystemState,
reservation.stateId,
deterministic
? { inFlightInvoiceId: null, inFlightCounter: null, blockedReason: null }
? {
// Hand both numbers back: MoR never counted a document it refused outright.
nextInvoiceCounter: reservation.invoiceCounter,
nextDocumentNumber: Number(reservation.documentNumber),
inFlightInvoiceId: null,
inFlightCounter: null,
inFlightDocumentNumber: null,
blockedReason: null,
}
: {
blockedReason:
`Invoice ${invoiceId} was submitted with counter ${reservation.invoiceCounter} but ` +
@@ -397,6 +447,41 @@ export class EimsInvoiceRegistrationService {
});
this.logger.error(`Invoice ${invoiceId} EIMS registration ${status}: ${lastError.message}`);
await this.alertStaff(invoiceId, status, lastError, deterministic);
}
/**
* Tell the people who can act about a failed filing.
*
* An ambiguous result is the urgent one: it blocks *every* further invoice for this system
* number until a human resolves it, and nothing else in the system would surface that — the
* sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal
* priority. Never throws: an alert that fails must not mask the filing outcome.
*/
private async alertStaff(
invoiceId: string,
status: EimsInvoiceStatus,
error: EimsInvoiceError,
deterministic: boolean,
): Promise<void> {
try {
await this.inbox.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.invoices.eimsResolve] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
title: deterministic
? "EIMS rejected an invoice"
: "EIMS filing unresolved — all further filing is blocked",
body: deterministic
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
link: `/dashboard/invoices/${invoiceId}`,
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
});
} catch (err) {
this.logger.warn(`EIMS staff alert failed for invoice ${invoiceId}: ${(err as Error).message}`);
}
}
// ── internals ────────────────────────────────────────────────────────────────────────────────
@@ -488,6 +573,7 @@ export class EimsInvoiceRegistrationService {
invoiceNumber: invoice.invoiceNumber,
eimsStatus: invoice.eimsStatus ?? EimsInvoiceStatus.NotSubmitted,
eimsIrn: invoice.eimsIrn ?? null,
eimsDocumentNumber: invoice.eimsDocumentNumber ?? null,
eimsInvoiceCounter: counter === null || counter === undefined ? null : Number(counter),
eimsSubmittedAt: invoice.eimsSubmittedAt ?? null,
eimsAckDate: invoice.eimsAckDate ?? null,

View File

@@ -80,6 +80,8 @@ export interface EimsInvoiceStatusView {
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
/** The numeric DocumentNumber filed with MoR; not our own invoiceNumber. */
eimsDocumentNumber: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: Date | null;
eimsAckDate: string | null;

View File

@@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
paymentTerm: "IMMIDIATE",
unitDefault: "PCS",
buyerCountryCode: null,
buyerRegionFallback: "13",
cashierName: null,
salesPersonName: null,
...over,

View File

@@ -3,6 +3,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Invoice } from "../billing/entities/invoice.entity";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { EimsAuthService } from "./eims-auth.service";
import { EimsAutoSubmitService } from "./eims-auto-submit.service";
import { EimsClientService } from "./eims-client.service";
@@ -22,6 +23,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
imports: [
HttpModule.register({ timeout: Number(process.env.EIMS_HTTP_TIMEOUT_MS) || 30_000 }),
TypeOrmModule.forFeature([EimsSystemState, Invoice]),
NotificationInboxModule,
],
controllers: [EimsInvoiceController],
providers: [

View File

@@ -18,6 +18,18 @@ export class EimsSystemState extends BaseEntity {
@Column({ name: "next_invoice_counter", type: "bigint", default: 1 })
nextInvoiceCounter!: number;
/**
* `DocumentDetails.DocumentNumber` for the next registration.
*
* Separate from our own `invoiceNumber`, which MoR cannot accept: it validates the field against
* `^(0|[1-9][0-9]{0,8})$`, a plain integer.
*/
@Column({ name: "next_document_number", type: "bigint", default: 1 })
nextDocumentNumber!: number;
@Column({ name: "in_flight_document_number", type: "bigint", nullable: true })
inFlightDocumentNumber?: number | null;
/** IRN of the last successful registration; null until the first one succeeds. */
@Column({ name: "previous_irn", type: "varchar", length: 64, nullable: true })
previousIrn?: string | null;

View File

@@ -0,0 +1,158 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims";
import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "gray",
SUBMITTING: "yellow",
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
NOT_SUBMITTED: "Not filed",
SUBMITTING: "Filing…",
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
};
function Field({ label, value }: { label: string; value?: string | number | null }) {
return (
<Stack gap={2}>
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{label}
</Text>
<Text size="sm" c="edr-text" style={{ wordBreak: "break-all" }}>
{value === null || value === undefined || value === "" ? "—" : value}
</Text>
</Stack>
);
}
/**
* MoR EIMS filing state for one invoice, with the manual actions.
*
* Filing normally happens on the API's cron sweep, not here — these controls exist for controlled
* testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that
* needs re-filing, and an unacknowledged one that has blocked all further filing.
*/
export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
);
const register = useMutation(
api.invoices.eimsRegister.mutationOptions({
onSuccess: (result) =>
toast({
title: result.eimsIrn ? "Filed with MoR" : "Filing finished",
description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`,
}),
}),
);
const verify = useMutation(
api.invoices.eimsVerify.mutationOptions({
onSuccess: (result) =>
toast({
title: "MoR confirmed the filing",
description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`,
}),
}),
);
if (isLoading || !eims) return null;
const status = eims.eimsStatus;
const busy = register.isPending || verify.isPending;
return (
<Card>
<Stack gap="lg">
<Group justify="space-between">
<Text fw={600} c="edr-text">
MoR e-invoicing
</Text>
<Badge color={STATUS_COLOR[status] ?? "gray"} variant="light" size="sm" radius="md" fw={600}>
{STATUS_LABEL[status] ?? status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<Field label="IRN" value={eims.eimsIrn} />
<Field label="Invoice counter" value={eims.eimsInvoiceCounter} />
<Field
label="Submitted"
value={eims.eimsSubmittedAt ? new Date(eims.eimsSubmittedAt).toLocaleString() : null}
/>
<Field label="Acknowledged" value={eims.eimsAckDate} />
</SimpleGrid>
{status === "UNKNOWN" && (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="All filing is blocked">
This invoice was sent but never acknowledged, so its IRN is unknown and no further
invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN
or discard the attempt.
</Alert>
)}
{eims.eimsLastError && (
<Alert
color={status === "FAILED" ? "red" : "orange"}
icon={<AlertTriangle size={16} />}
title={`MoR reported: ${eims.eimsLastError.kind}`}
>
{eims.eimsLastError.message}
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
)}
</Stack>
</Card>
);
}
export default EimsFilingCard;

View File

@@ -51,6 +51,7 @@ export const QUERY_KEYS = {
list: (filter?: InvoiceListFilter) =>
["invoices", "list", filter ?? {}] as const,
byId: (id: string) => ["invoices", "detail", id] as const,
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
},
BOOKINGS: {

View File

@@ -107,6 +107,14 @@ export const URL_CONSTANTS = {
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
},
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
EIMS: {
STATUS: (id: string) => `/invoices/${id}/eims/status`,
REGISTER: (id: string) => `/invoices/${id}/eims/register`,
VERIFY: (id: string) => `/invoices/${id}/eims/verify`,
RESOLVE: (id: string) => `/invoices/${id}/eims/resolve`,
},
CUSTOMERS_API: {
BASE: "/api/customers",
BY_ID: (id: string) => `/api/customers/${id}`,

View File

@@ -128,6 +128,10 @@ export const FREIGHT_PERMS = {
invoices: {
view: "edr_freight_app:invoices:view",
export: "edr_freight_app:invoices:export",
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
// irreversible at the tax authority, and resolving clears a system-wide filing block.
eimsRegister: "edr_freight_app:invoices:eims_register",
eimsResolve: "edr_freight_app:invoices:eims_resolve",
},
firstMile: {
view: "edr_freight_app:first_mile:view",

View File

@@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -165,6 +166,8 @@ export default function InvoiceDetailPage() {
</Stack>
</Card>
<EimsFilingCard invoiceId={invoice.id} />
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">

View File

@@ -148,6 +148,8 @@ import {
import { containerTypesService } from "./container-types.service";
import { containerService, type Container } from "./containerService";
import { customersService } from "./customers.service";
import { eimsService } from "./eims.service";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
import { invoicesService } from "./invoices.service";
import { dropdownSettingsService } from "./dropdownSettings.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service";
@@ -2914,6 +2916,36 @@ export const api = {
({ id }) => invoicesService.getById(id),
({ id }) => QUERY_KEYS.INVOICES.byId(id),
),
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsStatus",
({ id }) => eimsService.status(id),
({ id }) => QUERY_KEYS.INVOICES.eimsStatus(id),
),
// Both mutations refresh the filing panel; register also moves the invoice's own row.
eimsRegister: endpoint<{ id: string }, EimsInvoiceStatusView>(
"invoices",
"eimsRegister",
({ id }) => eimsService.register(id),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
eimsVerify: endpoint<{ id: string }, EimsVerifyResult>(
"invoices",
"eimsVerify",
({ id }) => eimsService.verify(id),
),
eimsResolve: endpoint<{ id: string; irn?: string; discard?: boolean }, EimsInvoiceStatusView>(
"invoices",
"eimsResolve",
({ id, irn, discard }) => eimsService.resolve(id, { irn, discard }),
undefined,
({ id }) => [QUERY_KEYS.INVOICES.eimsStatus(id), QUERY_KEYS.INVOICES.byId(id)],
),
},
overview: {

View File

@@ -0,0 +1,39 @@
import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { EimsInvoiceStatusView, EimsVerifyResult } from "@/types/eims";
/**
* MoR EIMS filing actions on an invoice.
*
* Registration is irreversible at the tax authority, so these are admin actions rather than part
* of the ordinary invoice screen: the normal production path is the API's cron sweep.
*/
export const eimsService = {
status(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.get<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.STATUS(invoiceId))
.then((r) => r.data);
},
register(invoiceId: string): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.REGISTER(invoiceId))
.then((r) => r.data);
},
verify(invoiceId: string): Promise<EimsVerifyResult> {
return apiClient
.post<EimsVerifyResult>(URL_CONSTANTS.EIMS.VERIFY(invoiceId))
.then((r) => r.data);
},
/** Record an IRN confirmed with MoR, or discard the attempt. Clears the system-wide block. */
resolve(
invoiceId: string,
input: { irn?: string; discard?: boolean },
): Promise<EimsInvoiceStatusView> {
return apiClient
.post<EimsInvoiceStatusView>(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input)
.then((r) => r.data);
},
};

View File

@@ -0,0 +1,44 @@
/**
* MoR EIMS filing state for one invoice.
*
* Mirrors `EimsInvoiceStatusView` in the freight API (`modules/eims/eims-registration.types.ts`).
* Kept local rather than in `@edr/types` because only the backoffice reads it.
*/
export type EimsInvoiceStatus =
| "NOT_SUBMITTED"
| "SUBMITTING"
| "REGISTERED"
| "FAILED"
| "UNKNOWN";
/** Sanitized gateway failure: MoR's own error fields, never our signed envelope. */
export interface EimsInvoiceError {
kind: string;
message: string;
httpStatus?: number;
details?: Record<string, unknown>;
at: string;
}
export interface EimsInvoiceStatusView {
invoiceId: string;
invoiceNumber: string;
eimsStatus: EimsInvoiceStatus;
eimsIrn: string | null;
eimsInvoiceCounter: number | null;
eimsSubmittedAt: string | null;
/** MoR returns a Java ZonedDateTime string, stored verbatim — display as-is. */
eimsAckDate: string | null;
eimsLastError: EimsInvoiceError | null;
}
/** `POST /v1/verify` response, echoed back from the gateway. */
export interface EimsVerifyResult {
statusCode?: number;
message?: string;
body?: {
Irn?: string;
DocumentDetails?: { Type?: string; DocumentNumber?: string; Date?: string };
[section: string]: unknown;
};
}