mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
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:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user