From 6b1ffa831f9f17918de6f2e7b4a2f65960cef94b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 04:05:45 +0000 Subject: [PATCH] feat(eims): surface filing state in backoffice and alert on failures Two gaps that only bite in production: nobody could see an invoice's filing state, and a blocked chain was visible only in the logs. A failed filing now notifies the staff who can act on it. An ambiguous result is HIGH priority because it blocks every further invoice for the system number until someone resolves it, and nothing else would surface that -- the sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal priority. The alert never throws: it must not mask the filing outcome. The backoffice invoice detail page gains an EIMS card showing status, IRN, counter, submitted and acknowledged timestamps, and the gateway's own error message, with actions gated on invoices:eims_register. FAILED offers "File again" -- the reservation model already allows re-registering a rejected invoice, so retry needed no new endpoint. UNKNOWN offers no re-file button at all, since resubmitting risks a duplicate registration, and instead explains that a supervisor must record the IRN or discard the attempt. Also aligns the migration class name with its renamed file. The DDL is idempotent, so re-applying under the new name is a no-op against the columns; it leaves one superseded row in freight.migrations. Co-Authored-By: Claude Opus 5 --- ... 3330000000000-EimsInvoiceRegistration.ts} | 2 +- .../eims-invoice-registration.service.spec.ts | 67 +++++++- .../eims/eims-invoice-registration.service.ts | 39 +++++ .../src/modules/eims/eims.module.ts | 2 + .../components/invoices/EimsFilingCard.tsx | 158 ++++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 8 + .../backoffice/src/lib/permissions.ts | 4 + .../src/pages/invoices/InvoiceDetailPage.tsx | 3 + .../backoffice/src/services/api.ts | 32 ++++ .../backoffice/src/services/eims.service.ts | 39 +++++ .../backoffice/src/types/eims.ts | 44 +++++ 12 files changed, 396 insertions(+), 3 deletions(-) rename apps/edr-freight-api/src/migrations/{3300000000000-EimsInvoiceRegistration.ts => 3330000000000-EimsInvoiceRegistration.ts} (98%) create mode 100644 apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/eims.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/eims.ts diff --git a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts similarity index 98% rename from apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts rename to apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts index c80dfcd1e..1ff9bab35 100644 --- a/apps/edr-freight-api/src/migrations/3300000000000-EimsInvoiceRegistration.ts +++ b/apps/edr-freight-api/src/migrations/3330000000000-EimsInvoiceRegistration.ts @@ -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 { await queryRunner.query(` ALTER TABLE freight.invoices diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 1035c2b33..0cbe76cb5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -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"; @@ -147,13 +148,17 @@ 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. */ @@ -409,6 +414,64 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => { }); }); +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); + }); +}); + describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { it("verifies the stored IRN over the unsigned bearer transport", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index 4ff9ccfb8..bb38a64cf 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -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"; @@ -72,6 +75,7 @@ export class EimsInvoiceRegistrationService { private readonly config: ConfigService, private readonly client: EimsClientService, private readonly auth: EimsAuthService, + private readonly inbox: NotificationInboxService, ) {} private get cfg(): EimsConfig { @@ -397,6 +401,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 { + 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 ──────────────────────────────────────────────────────────────────────────────── diff --git a/apps/edr-freight-api/src/modules/eims/eims.module.ts b/apps/edr-freight-api/src/modules/eims/eims.module.ts index 678b21b52..53d3d4090 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -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: [ diff --git a/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx new file mode 100644 index 000000000..ba507513c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/invoices/EimsFilingCard.tsx @@ -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 = { + NOT_SUBMITTED: "gray", + SUBMITTING: "yellow", + REGISTERED: "edr-green", + FAILED: "red", + UNKNOWN: "orange", +}; + +const STATUS_LABEL: Record = { + NOT_SUBMITTED: "Not filed", + SUBMITTING: "Filing…", + REGISTERED: "Filed", + FAILED: "Rejected", + UNKNOWN: "Unacknowledged", +}; + +function Field({ label, value }: { label: string; value?: string | number | null }) { + return ( + + + {label} + + + {value === null || value === undefined || value === "" ? "—" : value} + + + ); +} + +/** + * 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 ( + + + + + MoR e-invoicing + + + {STATUS_LABEL[status] ?? status} + + + + + + + + + + + {status === "UNKNOWN" && ( + } 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. + + )} + + {eims.eimsLastError && ( + } + title={`MoR reported: ${eims.eimsLastError.kind}`} + > + {eims.eimsLastError.message} + + )} + + {canFile && ( + + {/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */} + {status !== "REGISTERED" && status !== "UNKNOWN" && ( + + )} + + {eims.eimsIrn && ( + + )} + + )} + + + ); +} + +export default EimsFilingCard; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index f34fa6470..6a1135e0d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index cb45a34ae..3f6d05257 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 8e58d642d..88dadb1f7 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 629f8ec7c..3188509c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -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() { + + diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index e3f8dbf15..a6613d368 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -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: { diff --git a/apps/edr-freight-web/backoffice/src/services/eims.service.ts b/apps/edr-freight-web/backoffice/src/services/eims.service.ts new file mode 100644 index 000000000..6f99aaa3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/eims.service.ts @@ -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 { + return apiClient + .get(URL_CONSTANTS.EIMS.STATUS(invoiceId)) + .then((r) => r.data); + }, + + register(invoiceId: string): Promise { + return apiClient + .post(URL_CONSTANTS.EIMS.REGISTER(invoiceId)) + .then((r) => r.data); + }, + + verify(invoiceId: string): Promise { + return apiClient + .post(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 { + return apiClient + .post(URL_CONSTANTS.EIMS.RESOLVE(invoiceId), input) + .then((r) => r.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/eims.ts b/apps/edr-freight-web/backoffice/src/types/eims.ts new file mode 100644 index 000000000..5b5a3e93d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/eims.ts @@ -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; + 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; + }; +}