Files
edr-platform/apps/finance-api/src/modules/journals/entities/journal-entry.entity.ts
2026-08-25 00:11:39 +03:00

157 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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