mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 11:57:38 +00:00
A BadRequestException thrown before reserve() (config assertion, DEB/CRE validation) left the invoice NOT_SUBMITTED with nothing persisted, so the same row was retried every tick forever — a permanent head-of-line block on every invoice behind it. Now marked FAILED, guarded by a fresh status re-read so a reservation's own SUBMITTING/UNKNOWN/blocked state is never clobbered. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
7.5 KiB
TypeScript
170 lines
7.5 KiB
TypeScript
import { BadRequestException, 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 type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity.js";
|
|
|
|
import { EimsConfig } from "../../config/eims.config";
|
|
import { Invoice } from "../billing/entities/invoice.entity";
|
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
|
import { EimsInvoiceError, 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;
|
|
|
|
try {
|
|
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) {
|
|
// Every other failure path inside registerInvoiceWithEims persists FAILED/UNKNOWN itself
|
|
// (settleFailure) before throwing. A BadRequestException is the one exception: it is only
|
|
// ever thrown *before* a reservation is taken (config assertion, DEB/CRE validation), so
|
|
// nothing is persisted — left alone, this candidate is picked again next tick forever, a
|
|
// permanent head-of-line block on every invoice behind it. Drain it instead.
|
|
if (err instanceof BadRequestException) {
|
|
await this.failStalledCandidate(candidate, err);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
} 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, or drained by failStalledCandidate
|
|
// above), 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark a pre-reservation rejection as FAILED so the sweep advances past it — but only if the
|
|
* invoice is still exactly where this tick left it. A reservation's own transactions
|
|
* (SUBMITTING/UNKNOWN, or a system-wide block) are authoritative; this must never clobber them,
|
|
* so the status is re-read fresh rather than trusted from the stale `candidate` row.
|
|
*/
|
|
private async failStalledCandidate(
|
|
candidate: { id: string; invoiceNumber: string },
|
|
err: BadRequestException,
|
|
): Promise<void> {
|
|
const current = await this.dataSource.manager.findOne(Invoice, { where: { id: candidate.id } });
|
|
if (current?.eimsStatus !== EimsInvoiceStatus.NotSubmitted) {
|
|
this.logger.warn(
|
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation, but is ` +
|
|
`no longer NOT_SUBMITTED (${current?.eimsStatus ?? "not found"}) — leaving state untouched.`,
|
|
);
|
|
return;
|
|
}
|
|
const lastError: EimsInvoiceError = { kind: "VALIDATION", message: err.message, at: new Date().toISOString() };
|
|
await this.dataSource.manager.update(Invoice, candidate.id, {
|
|
eimsStatus: EimsInvoiceStatus.Failed,
|
|
eimsLastError: lastError,
|
|
} as QueryDeepPartialEntity<Invoice>);
|
|
this.logger.error(
|
|
`EIMS auto-submit: invoice ${candidate.invoiceNumber} rejected before reservation: ${err.message}`,
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|