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], })