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 | 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; }