feat(eims): register invoices with MoR EIMS and persist the outcome

Add manual single-invoice registration, verification and reconciliation.
Nothing submits automatically; invoice creation is untouched.

Sequencing uses a durable reservation. The counter is consumed and the
holder recorded in a committed transaction before the request leaves the
process, and the HTTP call runs outside every transaction. A counter is
therefore never reused once an attempt begins, a crash mid-flight leaves the
reservation standing instead of inviting a blind resubmission, and an
ambiguous result blocks the whole system number rather than one invoice --
PreviousIrn is unknown, so any later document would chain to a stale IRN.

Deterministic rejections (400/406/401/403) mark the invoice FAILED and clear
the block. Timeouts and 5xx mark it UNKNOWN and keep it. Since /v1/verify
takes an IRN we never received in that case, POST :id/eims/resolve is the
exit: record the IRN confirmed in the MoR portal, or discard. A recorded IRN
is verified against the gateway first and refused unless EIMS reports it
against this invoice's document number.

Business and tax configuration is validated locally before anything is
locked, allocated or sent, so a missing tax code fails naming the exact
environment variables instead of at the gateway. No tax value is defaulted.

Filing gets its own permission (invoices:eims_register) rather than riding
on invoices:export -- registration is irreversible at MoR and must not
follow from the right to download a PDF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-07 13:22:46 +00:00
parent 2644d5e52d
commit 7573019038
14 changed files with 1555 additions and 8 deletions

View File

@@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* EIMS registration state.
*
* `freight.invoices` gains the per-invoice registration outcome: which EIMS counter the invoice
* consumed, the returned IRN, and the last failure. The partial unique index on `eims_irn` is the
* database-level guarantee that one IRN can never be recorded against two invoices, independent of
* application logic.
*
* `freight.eims_system_state` is a single row per MoR system number holding the sequence the
* gateway expects: the next `SourceSystem.InvoiceCounter` and the `ReferenceDetails.PreviousIrn`
* of the last successful registration. Registration takes `FOR UPDATE` on this row, so the counter
* and the IRN chain stay consistent under concurrent submissions.
*
* The `in_flight_*` columns make a submission a *durable reservation*: the counter is consumed and
* the holder recorded in a committed transaction before the HTTP call, so a crash mid-flight leaves
* evidence instead of silently freeing the slot for a blind resubmission. `blocked_reason` is set
* when a submission ends ambiguously (timeout, network, 5xx) — the IRN is unknown, so every later
* document for this system number would chain to a stale `PreviousIrn` and registration stops until
* a human resolves it.
*
* `eims_ack_date` is varchar, not timestamptz: EIMS returns a Java ZonedDateTime string
* ("2025-03-21T08:33:32.707753413Z[Etc/UTC]") that no JS date parser accepts. It is stored
* verbatim so a compliance value is never mangled by a parse.
*/
export class EimsInvoiceRegistration3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED',
ADD COLUMN IF NOT EXISTS eims_irn varchar(64),
ADD COLUMN IF NOT EXISTS eims_invoice_counter bigint,
ADD COLUMN IF NOT EXISTS eims_submitted_at timestamptz,
ADD COLUMN IF NOT EXISTS eims_ack_date varchar(64),
ADD COLUMN IF NOT EXISTS eims_last_error jsonb
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_invoices_eims_irn
ON freight.invoices (eims_irn) WHERE eims_irn IS NOT NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.eims_system_state (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
system_number varchar(32) NOT NULL UNIQUE,
next_invoice_counter bigint NOT NULL DEFAULT 1,
previous_irn varchar(64),
in_flight_invoice_id uuid,
in_flight_counter bigint,
blocked_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_system_state`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_invoices_eims_irn`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_status,
DROP COLUMN IF EXISTS eims_irn,
DROP COLUMN IF EXISTS eims_invoice_counter,
DROP COLUMN IF EXISTS eims_submitted_at,
DROP COLUMN IF EXISTS eims_ack_date,
DROP COLUMN IF EXISTS eims_last_error
`);
}
}