mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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>
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
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;
|
|
});
|
|
});
|