mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
157 lines
5.8 KiB
TypeScript
157 lines
5.8 KiB
TypeScript
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;
|
||
}
|