From 67573d0835db2719fc55daaf7d42daededf62fdb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 7 Aug 2026 14:58:32 +0000 Subject: [PATCH] feat(eims): file issued invoices on a cron sweep, off by default Invoices are produced by the freight workflow rather than by a person, so the production path for filing is a sweep, not the manual endpoint. A @Cron picks the oldest never-submitted invoice and hands it to the existing EimsInvoiceRegistrationService -- no registration logic is duplicated, and the durable reservation still decides whether the submission may proceed. Sweeping rather than hooking the eleven places an invoice can be created or issued keeps the workflow untouched, puts the HTTP call outside the invoice transaction by construction, and lets a crash or restart be picked up on the next tick. invoices.eims_status is the queue; nothing new is persisted. Only NOT_SUBMITTED is eligible: UNKNOWN is never retried automatically because the document may already be filed, and FAILED waits for an explicit retry policy. The tick also refuses to start while eims_system_state holds an in-flight submission or a block, and only one invoice is filed per tick so a misconfiguration costs one rejected document rather than a burst. Requires both EIMS_ENABLED and EIMS_AUTO_SUBMIT; the second defaults to false so authentication can be live long before filing is. Logs carry the invoice number, status and IRN only. Co-Authored-By: Claude Opus 5 --- apps/edr-freight-api/.env.example | 7 + .../edr-freight-api/src/config/eims.config.ts | 21 +++ .../eims/eims-auto-submit.service.spec.ts | 139 ++++++++++++++++++ .../modules/eims/eims-auto-submit.service.ts | 126 ++++++++++++++++ .../src/modules/eims/eims-test-fixtures.ts | 3 + .../src/modules/eims/eims.module.ts | 2 + 6 files changed, 298 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 26641ee70..da0b1ceb9 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -181,3 +181,10 @@ EIMS_UNIT_DEFAULT=PCS EIMS_BUYER_COUNTRY_CODE= EIMS_CASHIER_NAME= EIMS_SALESPERSON_NAME= +# Automatic filing of issued invoices (@Cron sweep, one invoice per tick). +# Independent of EIMS_ENABLED on purpose: authentication can be live long before +# filing is. Both must be true before anything is submitted automatically. +EIMS_AUTO_SUBMIT=false +EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * +# MoR rejects documents older than 3 days; the sweep will not attempt those. +EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 0cadb55fb..6eaaf8007 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,6 +33,18 @@ export interface EimsConfig { httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; + /** + * Automatic submission of issued invoices, off by default. + * + * Invoices are produced by the workflow, so the production path is a sweep rather than a human + * action — but enabling it starts filing real documents with the tax authority, which is + * irreversible from our side. It therefore needs its own deliberate switch, separate from + * `EIMS_ENABLED`, so that authentication can be live long before filing is. + */ + autoSubmit: boolean; + autoSubmitCron: string; + /** MoR rejects a document whose date is more than 3 days old; the sweep will not attempt those. */ + autoSubmitMaxAgeDays: number; /** * Seller identity and tax/business treatment for the invoice document. * @@ -119,6 +131,15 @@ export default registerAs("eims", (): EimsConfig => { certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", httpTimeoutMs, tokenSkewMs, + autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", + // Every 5 minutes by default: filing is not latency-sensitive, and a slow cadence keeps a + // misconfiguration from filing a burst of bad documents before anyone notices. + autoSubmitCron: process.env.EIMS_AUTO_SUBMIT_CRON || "0 */5 * * * *", + autoSubmitMaxAgeDays: positiveInt( + process.env.EIMS_AUTO_SUBMIT_MAX_AGE_DAYS, + 3, + "EIMS_AUTO_SUBMIT_MAX_AGE_DAYS", + ), invoice: { sellerLegalName: process.env.EIMS_SELLER_LEGAL_NAME ?? "", sellerVatNumber: process.env.EIMS_SELLER_VAT_NUMBER ?? "", diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts new file mode 100644 index 000000000..6246d5a89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.spec.ts @@ -0,0 +1,139 @@ +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; +import { eimsConfig } from "./eims-test-fixtures"; + +const INVOICE_ID = "11111111-1111-4111-8111-111111111111"; + +/** + * `query` is answered by shape: the first call is the system-state guard, the second is the + * candidate lookup. Keeps the fake honest about the order the service actually asks in. + */ +const build = ( + opts: { + cfg?: Partial; + state?: { in_flight_invoice_id?: string | null; blocked_reason?: string | null }; + candidate?: { id: string; invoiceNumber: string } | null; + register?: jest.Mock; + } = {}, +) => { + const register = + opts.register ?? + jest.fn().mockResolvedValue({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: "IRN-1" }); + + const query = jest.fn().mockImplementation((sql: string) => { + if (sql.includes("eims_system_state")) { + return Promise.resolve( + opts.state ? [{ in_flight_invoice_id: null, blocked_reason: null, ...opts.state }] : [], + ); + } + return Promise.resolve(opts.candidate === undefined ? [] : opts.candidate ? [opts.candidate] : []); + }); + + const service = new EimsAutoSubmitService( + { query } as unknown as DataSource, + { get: () => eimsConfig({ autoSubmit: true, ...opts.cfg }) } as unknown as ConfigService, + { registerInvoiceWithEims: register } as unknown as EimsInvoiceRegistrationService, + ); + return { service, register, query }; +}; + +const candidate = { id: INVOICE_ID, invoiceNumber: "INV-20260807-00006" }; + +describe("EimsAutoSubmitService.tick", () => { + it("files the oldest eligible invoice through the registration service", async () => { + const { service, register } = build({ candidate }); + + await service.tick(); + + expect(register).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledWith(INVOICE_ID); + }); + + it("files nothing when EIMS_AUTO_SUBMIT is off", async () => { + const { service, register, query } = build({ cfg: { autoSubmit: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("files nothing when EIMS itself is disabled, even with auto-submit on", async () => { + const { service, register, query } = build({ cfg: { enabled: false }, candidate }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + }); + + it("does not submit while another submission is in flight", async () => { + const { service, register } = build({ + state: { in_flight_invoice_id: "22222222-2222-4222-8222-222222222222" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does not submit while the system number is blocked", async () => { + const { service, register } = build({ + state: { blocked_reason: "never acknowledged" }, + candidate, + }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("does nothing when no invoice is eligible", async () => { + const { service, register } = build({ candidate: null }); + + await service.tick(); + + expect(register).not.toHaveBeenCalled(); + }); + + it("asks only for NOT_SUBMITTED invoices, so UNKNOWN and FAILED are never retried", async () => { + const { service, query } = build({ candidate }); + + await service.tick(); + + const [sql, params] = query.mock.calls.find(([s]: [string]) => s.includes("freight.invoices"))!; + expect(sql).toContain("i.eims_status = $1"); + expect(params[0]).toBe(EimsInvoiceStatus.NotSubmitted); + expect(sql).toContain("i.issued_at IS NOT NULL"); + }); + + it("survives a filing failure so the job keeps running", async () => { + const register = jest.fn().mockRejectedValue(new Error("EIMS register failed (406)")); + const { service } = build({ candidate, register }); + + await expect(service.tick()).resolves.toBeUndefined(); + expect(register).toHaveBeenCalledTimes(1); + }); + + it("does not start a second tick while one is still filing", async () => { + let release: () => void = () => {}; + const register = jest.fn().mockImplementation( + () => new Promise((resolve) => (release = () => resolve({ eimsStatus: "REGISTERED" }))), + ); + const { service } = build({ candidate, register }); + + const first = service.tick(); + await new Promise((r) => setImmediate(r)); + await service.tick(); // overlapping tick, must be a no-op + + expect(register).toHaveBeenCalledTimes(1); + release(); + await first; + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts new file mode 100644 index 000000000..fb5a0d1f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-auto-submit.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Cron } from "@nestjs/schedule"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service"; +import { EimsInvoiceStatus } from "./eims-registration.types"; + +/** + * Files issued invoices with MoR EIMS on a timer. + * + * Invoices are produced by the freight workflow rather than by a person, so this — not the manual + * endpoint — is the production path. It is a sweep rather than a hook on the eleven places an + * invoice can be created or issued, which buys three things: the workflow is untouched, the HTTP + * call is by construction outside the invoice's transaction, and an invoice missed through a crash + * or a restart is picked up on the next tick. + * + * `invoices.eims_status` is the queue — nothing new is persisted. Only `NOT_SUBMITTED` is eligible: + * `UNKNOWN` must never be retried automatically (the document may already be filed), and `FAILED` + * waits for an explicit retry policy rather than a timer's guess. + * + * Off unless **both** `EIMS_ENABLED` and `EIMS_AUTO_SUBMIT` are true. Enabling it starts filing + * real documents with the tax authority, and a registration cannot be undone from this side. + */ +@Injectable() +export class EimsAutoSubmitService { + private readonly logger = new Logger(EimsAutoSubmitService.name); + /** Guards against a tick starting while the previous one is still filing. */ + private running = false; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly config: ConfigService, + private readonly registration: EimsInvoiceRegistrationService, + ) {} + + private get cfg(): EimsConfig { + return this.config.get("eims")!; + } + + /** + * One invoice per tick. + * + * Deliberately not a batch: each filing consumes a counter and advances the IRN chain, an + * ambiguous result blocks the system number until a human resolves it, and a misconfiguration + * should cost one rejected document rather than a burst of them. + */ + @Cron(process.env.EIMS_AUTO_SUBMIT_CRON ?? "0 */5 * * * *", { name: "eims-auto-submit" }) + async tick(): Promise { + const cfg = this.cfg; + if (!cfg.enabled || !cfg.autoSubmit) return; + if (this.running) return; + + this.running = true; + try { + // Rule of the chain: nothing may be filed while a submission is in flight or the system is + // blocked. The reservation would refuse anyway — checking first keeps the log quiet and + // avoids burning a tick on a guaranteed conflict. + const blocked = await this.systemBlockReason(); + if (blocked) { + this.logger.warn(`EIMS auto-submit paused: ${blocked}`); + return; + } + + const candidate = await this.nextCandidate(); + if (!candidate) return; + + const view = await this.registration.registerInvoiceWithEims(candidate.id); + this.logger.log( + `EIMS auto-submit: invoice ${candidate.invoiceNumber} -> ${view.eimsStatus}` + + (view.eimsIrn ? ` (IRN ${view.eimsIrn})` : ""), + ); + } catch (err) { + // Never let a filing failure kill the job. The outcome is already persisted on the invoice + // (FAILED or UNKNOWN with the gateway's own message), and a blocked system number stops the + // next tick at the guard above. + this.logger.error(`EIMS auto-submit tick failed: ${(err as Error).message}`); + } finally { + this.running = false; + } + } + + /** Why filing is currently impossible for this system number, or null when it is free. */ + private async systemBlockReason(): Promise { + const rows: { in_flight_invoice_id: string | null; blocked_reason: string | null }[] = + await this.dataSource.query( + `SELECT in_flight_invoice_id, blocked_reason + FROM freight.eims_system_state + WHERE system_number = $1 AND deleted_at IS NULL + LIMIT 1`, + [this.cfg.systemNumber], + ); + const state = rows[0]; + if (!state) return null; + if (state.blocked_reason) return state.blocked_reason; + if (state.in_flight_invoice_id) { + return `a submission for invoice ${state.in_flight_invoice_id} is still in flight`; + } + return null; + } + + /** + * Oldest never-submitted invoice that is issued, still inside MoR's document-age window, and + * carries at least one line. + */ + private async nextCandidate(): Promise<{ id: string; invoiceNumber: string } | null> { + const rows: { id: string; invoiceNumber: string }[] = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber" + FROM freight.invoices i + WHERE i.eims_status = $1 + AND i.issued_at IS NOT NULL + AND i.deleted_at IS NULL + AND i.issued_at > now() - ($2 || ' days')::interval + AND EXISTS ( + SELECT 1 FROM freight.invoice_lines l + WHERE l.invoice_id = i.id AND l.deleted_at IS NULL + ) + ORDER BY i.issued_at ASC + LIMIT 1`, + [EimsInvoiceStatus.NotSubmitted, this.cfg.autoSubmitMaxAgeDays], + ); + return rows[0] ?? 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 f411c8b44..79fe30f96 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 @@ -51,6 +51,9 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, + autoSubmit: false, + autoSubmitCron: "0 */5 * * * *", + autoSubmitMaxAgeDays: 3, invoice: eimsInvoiceConfig(), ...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 c3b50a489..678b21b52 100644 --- a/apps/edr-freight-api/src/modules/eims/eims.module.ts +++ b/apps/edr-freight-api/src/modules/eims/eims.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; import { EimsAuthService } from "./eims-auth.service"; +import { EimsAutoSubmitService } from "./eims-auto-submit.service"; import { EimsClientService } from "./eims-client.service"; import { EimsCredentialsProvider } from "./eims-credentials.provider"; import { EimsInvoiceController } from "./eims-invoice.controller"; @@ -29,6 +30,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity"; EimsAuthService, EimsClientService, EimsInvoiceRegistrationService, + EimsAutoSubmitService, ], exports: [EimsAuthService, EimsClientService, EimsInvoiceRegistrationService], })