mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 13:10:56 +00:00
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:
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user