mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
Merge pull request #1174 from Tria-plc/eims-integration
Eims integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
||||
paymentTerm: "IMMIDIATE",
|
||||
unitDefault: "PCS",
|
||||
buyerCountryCode: null,
|
||||
buyerRegionFallback: "13",
|
||||
cashierName: null,
|
||||
salesPersonName: null,
|
||||
...over,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal file
39
apps/edr-freight-web/backoffice/src/services/eims.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal file
44
apps/edr-freight-web/backoffice/src/types/eims.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user