Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -0,0 +1,156 @@
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;
}