mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
New endpoints:
POST invoices/eims/bulk-register { invoiceIds: [...] } — trigger
POST eims/webhook/bulk-register — MoR's callback
Fundamentally different shape from single register: bulkRegister
answers only {conversationId, status:202} immediately: MoR processes
the array asynchronously and pushes the real per-invoice results
(a mix of accepted/rejected in one array, per the collection's own
examples) to a webhook configured out of band. So this ships as two
halves that don't share a call stack — EimsBulkRegistrationService.
registerBulk() reserves a contiguous block of counters (durable
reservation, same doctrine as single register, extended to N items)
and submits; handleBulkCallback(), invoked by the new
EimsWebhookController whenever MoR gets around to it, settles.
New EimsSystemState.inFlightConversationId is the bulk equivalent of
inFlightInvoiceId — a whole batch outstanding, not one invoice — and
the two markers block each other since they share the same counter
sequence. The conversation id isn't known until MoR's 202 arrives, so
reservation stamps a locally-generated placeholder first (same
commit-before-the-network-call reasoning as single register), then
swaps it for MoR's real id right after — the only value the callback
can actually use to find the batch again.
Only the first invoice in a bulk batch chains via PreviousIrn — every
other item gets an empty string, matching the collection's own
two-invoice example exactly (MoR doesn't expect a batch to chain to
IRNs that don't exist yet at submission time).
Webhook has no auth (MoR has no JWT to send) — the conversation id
embedded in the payload is what stands between this and a forged
callback: an item only ever touches an invoice actually holding that
exact id, and an unknown id is logged and ignored, never applied.
Migration 3580000000000: eims_system_state.in_flight_conversation_id,
invoices.eims_bulk_conversation_id (tags which batch an invoice was
submitted in, so a stuck batch — webhook never arrived — can be found
and reconciled by conversation id). Applied to dev DB and recorded in
freight.migrations directly (idempotent IF NOT EXISTS DDL).
Not live-testable from this sandbox (no route to MoR's real gateway).
Signing the whole array as one envelope, the way single /v1/register
was confirmed live to need despite the collection's raw example
showing no envelope, is the reasonable extension of that confirmed
behavior, not a blind guess — but it has not itself been exercised
against the real gateway. Left for the first live bulk attempt to
confirm, same as every other MoR-facing assumption this integration
has made.
215 lines
9.1 KiB
TypeScript
215 lines
9.1 KiB
TypeScript
import { BaseEntity } from "@edr/api-common";
|
|
import { Freight } from "@edr/types";
|
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
|
import type { EimsInvoiceError, EimsInvoiceStatus } from "../../eims/eims-registration.types";
|
|
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
|
import { Company } from "../../companies/entities/company.entity";
|
|
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
|
|
|
/** A single recorded settlement against an invoice (payment ledger entry). */
|
|
export interface InvoicePayment {
|
|
amount: number;
|
|
method?: string | null;
|
|
reference?: string | null;
|
|
/** ISO timestamp of when the settlement was recorded. */
|
|
paidAt: string;
|
|
metadata?: Record<string, unknown> | null;
|
|
}
|
|
|
|
@Entity({ schema: "freight", name: "invoices" })
|
|
@Index(["companyId"])
|
|
@Index(["companyProfileId"])
|
|
export class Invoice extends BaseEntity {
|
|
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
|
invoiceNumber!: string;
|
|
|
|
/**
|
|
* The customer (company) this invoice is billed to. Null on a shipping-line
|
|
* invoice, which is billed to `shippingLineCompanyId` instead — a shipping
|
|
* line is deliberately not a `companies` row. A DB CHECK
|
|
* (`chk_invoices_single_payer`) guarantees exactly one of the two is set.
|
|
*/
|
|
@Column({ name: "company_id", type: "uuid", nullable: true })
|
|
companyId!: string | null;
|
|
|
|
@ManyToOne(() => Company)
|
|
@JoinColumn({ name: "company_id" })
|
|
company?: Company;
|
|
|
|
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
|
@Column({ name: "company_profile_id", type: "uuid", nullable: true })
|
|
companyProfileId!: string | null;
|
|
|
|
@ManyToOne(() => CompanyProfile)
|
|
@JoinColumn({ name: "company_profile_id" })
|
|
companyProfile?: CompanyProfile;
|
|
|
|
/**
|
|
* The shipping line billed, when this invoice bills batched shipping-line
|
|
* credits rather than a customer booking. Mutually exclusive with
|
|
* `companyId`. No relation is declared: `ShippingLineCredit` already owns
|
|
* that edge, and importing the shipping-lines module here would close an
|
|
* import cycle (shipping-lines already depends on billing).
|
|
*/
|
|
@Column({ name: "shipping_line_company_id", type: "uuid", nullable: true })
|
|
shippingLineCompanyId?: string | null;
|
|
|
|
/** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */
|
|
@Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
|
subtotalAmount!: number;
|
|
|
|
@Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
|
taxAmount!: number;
|
|
|
|
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
|
totalAmount!: number;
|
|
|
|
/** Cumulative amount settled so far (supports partial payment). */
|
|
@Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
|
paidAmount!: number;
|
|
|
|
/** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */
|
|
@Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 })
|
|
balanceAmount!: number;
|
|
|
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
|
currency!: string;
|
|
|
|
@Column({
|
|
name: "status",
|
|
type: "enum",
|
|
enum: Freight.InvoiceStatus,
|
|
default: Freight.InvoiceStatus.Draft,
|
|
})
|
|
status!: Freight.InvoiceStatus;
|
|
|
|
/** The source of the payment (e.g. booking, customer, etc.). */
|
|
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
|
source!: string;
|
|
|
|
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
|
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
|
sourceId!: string;
|
|
|
|
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
|
@Column({
|
|
type: "varchar",
|
|
length: 255,
|
|
nullable: false,
|
|
})
|
|
type!: string;
|
|
|
|
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
|
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
|
issuedAt?: Date | null;
|
|
|
|
/** Set when the invoice is fully settled. */
|
|
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
|
|
paidAt?: Date | null;
|
|
|
|
/** Ledger of individual settlements (manual or gateway), newest last. */
|
|
@Column({ name: "payments", type: "jsonb", default: () => "'[]'" })
|
|
payments!: InvoicePayment[];
|
|
|
|
/** The ID of the payment that generated this invoice. */
|
|
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
|
paymentId?: string | null;
|
|
|
|
@ManyToOne(() => PaymentEntity)
|
|
@JoinColumn({ name: "payment_id" })
|
|
payment?: PaymentEntity;
|
|
|
|
@Column({ name: "due_at", type: "timestamptz" })
|
|
dueAt!: Date;
|
|
|
|
/** MoR EIMS registration state. Set only by the EIMS module; billing never writes these. */
|
|
@Column({ name: "eims_status", type: "varchar", length: 20, default: "NOT_SUBMITTED" })
|
|
eimsStatus!: EimsInvoiceStatus;
|
|
|
|
/**
|
|
* Invoice Reference Number returned by EIMS. Unique across invoices (partial index). `text`,
|
|
* not a fixed varchar — MoR has never documented an IRN format/length, and a real live value
|
|
* (a `test-` prefix + 64 hex chars, 69 chars total) already overflowed a prior varchar(64).
|
|
*/
|
|
@Column({ name: "eims_irn", type: "text", nullable: true })
|
|
eimsIrn?: string | null;
|
|
|
|
/**
|
|
* `signedQR` from the register response — a base64 PNG image, already rendered by MoR (confirmed
|
|
* against the Postman collection's saved response: decodes to a PNG magic-byte header). Stored
|
|
* verbatim; `BillingService.renderEimsQr` only wraps it in a `data:image/png;base64,` URL.
|
|
*/
|
|
@Column({ name: "eims_signed_qr", type: "text", nullable: true })
|
|
eimsSignedQr?: string | null;
|
|
|
|
/** The numeric `DocumentDetails.DocumentNumber` filed for this invoice. */
|
|
@Column({ name: "eims_document_number", type: "varchar", length: 16, nullable: true })
|
|
eimsDocumentNumber?: string | null;
|
|
|
|
/** The `SourceSystem.InvoiceCounter` this invoice consumed. */
|
|
@Column({ name: "eims_invoice_counter", type: "bigint", nullable: true })
|
|
eimsInvoiceCounter?: number | null;
|
|
|
|
@Column({ name: "eims_submitted_at", type: "timestamptz", nullable: true })
|
|
eimsSubmittedAt?: Date | null;
|
|
|
|
/** EIMS acknowledgement timestamp, stored verbatim — it is a Java ZonedDateTime string. */
|
|
@Column({ name: "eims_ack_date", type: "varchar", length: 64, nullable: true })
|
|
eimsAckDate?: string | null;
|
|
|
|
/** Sanitized last failure: the gateway's own error fields only, never our signed envelope. */
|
|
@Column({ name: "eims_last_error", type: "jsonb", nullable: true })
|
|
eimsLastError?: EimsInvoiceError | null;
|
|
|
|
/**
|
|
* `POST /v1/cancel` — set together, only once `eimsStatus` reaches CANCELLED.
|
|
* `eimsCancelledAt` is our own server time (same convention as `eimsSubmittedAt`);
|
|
* `eimsCancellationDate` is MoR's own confirmation string, stored verbatim like `eimsAckDate` —
|
|
* its format (`"Sun Dec 22 21:55:03 EAT 2024"`, a Java `Date#toString()`) is not reliably
|
|
* `Date.parse`-able (the `EAT` zone abbreviation is non-standard), so it is never parsed.
|
|
*/
|
|
@Column({ name: "eims_cancelled_at", type: "timestamptz", nullable: true })
|
|
eimsCancelledAt?: Date | null;
|
|
|
|
@Column({ name: "eims_cancellation_date", type: "varchar", length: 64, nullable: true })
|
|
eimsCancellationDate?: string | null;
|
|
|
|
/** Numeric string per the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
|
@Column({ name: "eims_cancellation_reason_code", type: "varchar", length: 8, nullable: true })
|
|
eimsCancellationReasonCode?: string | null;
|
|
|
|
@Column({ name: "eims_cancellation_remark", type: "text", nullable: true })
|
|
eimsCancellationRemark?: string | null;
|
|
|
|
/**
|
|
* `DocumentDetails.Type` to file this invoice as — "INV" (default), "DEB" or "CRE". Confirmed
|
|
* by MoR support directly (not the collection): debit/credit notes go through this same
|
|
* `/v1/register` endpoint, distinguished only by `Type` + `Reason`, linked via
|
|
* `ReferenceDetails.RelatedDocument` to the original invoice's IRN. This module does not create
|
|
* debit/credit note invoices — that is a freight-workflow decision — it only files one
|
|
* correctly once these columns are set on an existing row.
|
|
*/
|
|
@Column({ name: "eims_document_type", type: "varchar", length: 8, default: "INV" })
|
|
eimsDocumentType!: string;
|
|
|
|
/** Required by MoR when `eimsDocumentType` is DEB/CRE — why the note was issued. */
|
|
@Column({ name: "eims_reason", type: "text", nullable: true })
|
|
eimsReason?: string | null;
|
|
|
|
/** The original registered invoice this debit/credit note adjusts. Required for DEB/CRE. */
|
|
@Column({ name: "related_invoice_id", type: "uuid", nullable: true })
|
|
relatedInvoiceId?: string | null;
|
|
|
|
@ManyToOne(() => Invoice)
|
|
@JoinColumn({ name: "related_invoice_id" })
|
|
relatedInvoice?: Invoice | null;
|
|
|
|
/**
|
|
* Which `POST /v1/bulkRegister` batch this invoice was submitted in, if any — MoR's own
|
|
* conversation id, not one we generate. Lets a stuck batch (webhook never arrived) be found and
|
|
* reconciled. Null for every invoice filed through single `/v1/register`.
|
|
*/
|
|
@Column({ name: "eims_bulk_conversation_id", type: "text", nullable: true })
|
|
eimsBulkConversationId?: string | null;
|
|
}
|