diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 086923df5..53d599de5 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 6eaaf8007..659377c77 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -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, }, 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/migrations/3340000000000-EimsDocumentNumberSequence.ts b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts new file mode 100644 index 000000000..481b7489c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3340000000000-EimsDocumentNumberSequence.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts index 864d9f829..0bd5d5a92 100644 --- a/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts +++ b/apps/edr-freight-api/src/modules/billing/eims-invoice.mapper.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index c5c000943..411d15ebe 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 20ccfdfba..8f81b5f78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -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, }; } 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..f32b78000 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"; @@ -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) => { 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( 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..66131ed01 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"; @@ -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 { + 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, diff --git a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts index ad6a3aa34..c4d9f3842 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-registration.types.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 79fe30f96..d620cfa40 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -33,6 +33,7 @@ export const eimsInvoiceConfig = (over: Partial = {}): EimsIn paymentTerm: "IMMIDIATE", unitDefault: "PCS", buyerCountryCode: null, + buyerRegionFallback: "13", cashierName: null, salesPersonName: null, ...over, 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-api/src/modules/eims/entities/eims-system-state.entity.ts b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts index ac6489c93..a21057042 100644 --- a/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts +++ b/apps/edr-freight-api/src/modules/eims/entities/eims-system-state.entity.ts @@ -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; 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; + }; +}