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 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 14:58:32 +00:00
parent 02db3d2e73
commit 67573d0835
6 changed files with 298 additions and 0 deletions

View File

@@ -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<EimsConfig>;
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;
});
});

View File

@@ -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<EimsConfig>("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<void> {
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<string | null> {
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;
}
}

View File

@@ -51,6 +51,9 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
certificatePath: "/dev/null",
httpTimeoutMs: 30_000,
tokenSkewMs: 45_000,
autoSubmit: false,
autoSubmitCron: "0 */5 * * * *",
autoSubmitMaxAgeDays: 3,
invoice: eimsInvoiceConfig(),
...over,
});

View File

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