import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; import { Check, Column, Entity, Index, PrimaryGeneratedColumn, Unique, } from "typeorm"; import { moneyColumn } from "../../../common/money"; /** * DRAFT — being prepared. Editable, and NOT part of any balance. * POSTED — in the ledger. Immutable forever. * REVERSED — was posted, then undone by a linked reversing entry. The original * row is still POSTED-immutable; this status only records that a * reversal exists, so a reader is not surprised by the pair. * * There is deliberately no CANCELLED or DELETED. A posted entry is never * removed — that is the difference between a ledger and a spreadsheet. */ export const JOURNAL_STATUSES = ["DRAFT", "POSTED", "REVERSED"] as const; export type JournalStatus = (typeof JOURNAL_STATUSES)[number]; /** * Where the entry came from. GENERAL is a human-keyed entry; the rest are * written by the automated flows in 4.2–4.5. OPENING carries the cutover * balances and REVERSAL is generated, never hand-picked. */ export const JOURNAL_TYPES = [ "GENERAL", "SALES", "PURCHASE", "CASH_RECEIPT", "CASH_PAYMENT", "PAYROLL", "DEPRECIATION", "OPENING", "REVERSAL", ] as const; export type JournalType = (typeof JOURNAL_TYPES)[number]; /** * The header of one double-entry transaction. * * Invariants the service enforces inside a single transaction, because a ledger * that violates any of them is not repairable after the fact: * 1. `total_debit` = `total_credit`, and both > 0. * 2. At least two lines. * 3. The named period is OPEN at the moment of posting. * 4. Once POSTED, neither the header nor its lines may be modified. * 5. A correction is a REVERSAL entry, never an edit. * * `total_debit`/`total_credit` are stored rather than summed on read: they are * frozen evidence of what balanced at posting time, so a later change to a line * (which cannot happen, but the ledger should not depend on that) could never * silently rewrite history. */ @Entity({ schema: "finance", name: "journal_entries" }) @Unique("uq_journal_entries_org_number", ["organizationId", "entryNumber"]) @Index("idx_journal_entries_organization_id", ["organizationId"]) @Index("idx_journal_entries_period_id", ["fiscalPeriodId"]) @Index("idx_journal_entries_date", ["entryDate"]) @Index("idx_journal_entries_status", ["status"]) @Index("idx_journal_entries_source", ["sourceModule", "sourceId"]) @Check( "ck_journal_entries_status", `"status" IN ('DRAFT','POSTED','REVERSED')`, ) @Check("ck_journal_entries_totals_non_negative", `"total_debit" >= 0 AND "total_credit" >= 0`) // The balance rule, at the database level as well as in the service. Belt and // braces on purpose: this is the one invariant whose violation is unrecoverable. @Check("ck_journal_entries_balanced", `"total_debit" = "total_credit"`) export class JournalEntry extends SoftDeleteAudit { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "organization_id" }) organizationId!: string; /** Human-facing sequence, e.g. "JV-2026-000042". Unique per organization. */ @Column({ type: "varchar", length: 32, name: "entry_number" }) entryNumber!: string; /** * The date the transaction is recognised on — NOT when the row was created. * It decides which period the entry falls in, so a receipt banked late is * still recognised in the month it belongs to. */ @Column({ type: "date", name: "entry_date" }) entryDate!: string; @Column({ type: "uuid", name: "fiscal_period_id" }) fiscalPeriodId!: string; @Column({ type: "varchar", length: 24, name: "journal_type", default: "GENERAL" }) journalType!: JournalType; @Column({ type: "varchar", length: 16, name: "status", default: "DRAFT" }) status!: JournalStatus; @Column({ type: "text", name: "memo" }) memo!: string; /** Free-text pointer to the paper/source document (invoice no., receipt no.). */ @Column({ type: "varchar", length: 128, name: "reference", nullable: true }) reference?: string | null; /** * Which upstream system produced this entry ("freight", "passenger", "hr", * "payment"), and its id there. Soft reference, never an FK — Finance must * stay readable when a source row is gone. Together they are also the * idempotency key for automated posting: one source document posts once. */ @Column({ type: "varchar", length: 32, name: "source_module", nullable: true }) sourceModule?: string | null; @Column({ type: "varchar", length: 128, name: "source_id", nullable: true }) sourceId?: string | null; @Column(moneyColumn({ name: "total_debit", default: 0 })) totalDebit!: number; @Column(moneyColumn({ name: "total_credit", default: 0 })) totalCredit!: number; /** ETB unless an entry deliberately records another currency's conversion. */ @Column({ type: "varchar", length: 8, name: "currency", default: "ETB" }) currency!: string; /** `iam.employees.id` of whoever prepared it. */ @Column({ type: "uuid", name: "prepared_by", nullable: true }) preparedBy?: string | null; /** * `iam.employees.id` of whoever posted it. Separate from `preparedBy` because * preparing and posting are separate permissions — see the role matrix. */ @Column({ type: "uuid", name: "posted_by", nullable: true }) postedBy?: string | null; @Column({ type: "timestamptz", name: "posted_at", nullable: true }) postedAt?: Date | null; /** Set on the ORIGINAL, pointing at the entry that reverses it. */ @Column({ type: "uuid", name: "reversed_by_entry_id", nullable: true }) reversedByEntryId?: string | null; /** Set on the REVERSAL, pointing back at what it undoes. */ @Column({ type: "uuid", name: "reverses_entry_id", nullable: true }) reversesEntryId?: string | null; @Column({ type: "text", name: "reversal_reason", nullable: true }) reversalReason?: string | null; }