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; + }; +}