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("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; 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 { 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); 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 { 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; } }