import { Audit, SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; import { Check, Column, Entity, Index, PrimaryGeneratedColumn, } from "typeorm"; import { moneyColumn } from "../../../common/money"; /** * Only straight line is implemented. * * The column is an enum rather than a boolean so reducing-balance can be added * without a migration, but nothing else is supported today and the calculator * rejects anything else rather than silently treating it as straight line. */ export const DEPRECIATION_METHODS = ["STRAIGHT_LINE"] as const; export type DepreciationMethod = (typeof DEPRECIATION_METHODS)[number]; export const ASSET_STATUSES = [ "ACTIVE", "FULLY_DEPRECIATED", "DISPOSED", "WRITTEN_OFF", ] as const; export type AssetStatus = (typeof ASSET_STATUSES)[number]; export const DISPOSAL_TYPES = ["SALE", "SCRAP", "WRITE_OFF"] as const; export type DisposalType = (typeof DISPOSAL_TYPES)[number]; /** * Depreciation defaults for a class of asset, and the three accounts its * postings touch: where the cost sits, where the accumulated depreciation * accrues, and where the charge lands. * * Holding the accounts here rather than resolving them by code per asset means * a chart can name its asset accounts whatever it likes; only the category has * to be set up once. */ @Entity({ schema: "finance", name: "asset_categories" }) @Check("ck_asset_categories_life", `"default_life_months" > 0`) @Check( "ck_asset_categories_salvage", `"default_salvage_rate" >= 0 AND "default_salvage_rate" < 1`, ) export class AssetCategory extends SoftDeleteAudit { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "organization_id" }) organizationId!: string; @Column({ type: "varchar", length: 32, name: "code" }) code!: string; @Column({ type: "jsonb", name: "name" }) name!: { am: string; en: string }; /** Where the asset's cost is carried (1211 Land, 1213 Locomotives…). */ @Column({ type: "uuid", name: "asset_account_id" }) assetAccountId!: string; /** The contra account depreciation accrues in (1290). */ @Column({ type: "uuid", name: "accumulated_account_id" }) accumulatedAccountId!: string; /** Where the monthly charge is expensed (5400). */ @Column({ type: "uuid", name: "expense_account_id" }) expenseAccountId!: string; @Column({ type: "int", name: "default_life_months" }) defaultLifeMonths!: number; /** Fraction of cost expected to remain at the end of life, e.g. 0.05. */ @Column({ type: "numeric", precision: 6, scale: 4, name: "default_salvage_rate", default: 0, }) defaultSalvageRate!: string; @Column({ type: "boolean", name: "is_active", default: true }) isActive!: boolean; @Column({ type: "uuid", name: "created_by", nullable: true }) createdBy?: string | null; } /** * One item in the asset register. * * `accumulatedDepreciation` is a running total kept ON the asset. It duplicates * what the ledger holds in the accumulated-depreciation account, which is * normally the thing to avoid — it is here because depreciation must STOP at * the depreciable base, and that is a per-asset decision made on every run. * Deriving it from the ledger each time would be an aggregate query per asset * per month. The ledger stays authoritative and the run reconciles against it. * * The database enforces the cap directly: `accumulated <= cost - salvage`. An * arithmetic slip cannot over-depreciate an asset, only fail loudly. */ @Entity({ schema: "finance", name: "fixed_assets" }) @Index("idx_fixed_assets_status", ["status"]) @Index("idx_fixed_assets_category", ["assetCategoryId"]) @Index("idx_fixed_assets_cost_center", ["costCenterId"]) @Check( "ck_fixed_assets_status", `"status" IN ('ACTIVE','FULLY_DEPRECIATED','DISPOSED','WRITTEN_OFF')`, ) @Check("ck_fixed_assets_method", `"depreciation_method" IN ('STRAIGHT_LINE')`) @Check("ck_fixed_assets_cost", `"acquisition_cost" > 0`) @Check("ck_fixed_assets_life", `"useful_life_months" > 0`) @Check( "ck_fixed_assets_salvage", `"salvage_value" >= 0 AND "salvage_value" < "acquisition_cost"`, ) @Check( "ck_fixed_assets_accumulated", `"accumulated_depreciation" >= 0 AND "accumulated_depreciation" <= "acquisition_cost" - "salvage_value"`, ) @Check("ck_fixed_assets_in_service", `"in_service_date" >= "acquisition_date"`) export class FixedAsset extends SoftDeleteAudit { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "organization_id" }) organizationId!: string; @Column({ type: "uuid", name: "asset_category_id" }) assetCategoryId!: string; @Column({ type: "varchar", length: 48, name: "asset_code" }) assetCode!: string; @Column({ type: "varchar", length: 200, name: "name" }) name!: string; @Column({ type: "text", name: "description", nullable: true }) description?: string | null; @Column({ type: "varchar", length: 96, name: "serial_number", nullable: true }) serialNumber?: string | null; @Column({ type: "uuid", name: "cost_center_id", nullable: true }) costCenterId?: string | null; @Column({ type: "date", name: "acquisition_date" }) acquisitionDate!: string; /** * When the asset started being used — depreciation runs from HERE, not from * acquisition. An asset bought in March and commissioned in June was not * wearing out in between. */ @Column({ type: "date", name: "in_service_date" }) inServiceDate!: string; @Column(moneyColumn({ name: "acquisition_cost" })) acquisitionCost!: number; @Column(moneyColumn({ name: "salvage_value", default: 0 })) salvageValue!: number; @Column({ type: "int", name: "useful_life_months" }) usefulLifeMonths!: number; @Column({ type: "varchar", length: 24, name: "depreciation_method", default: "STRAIGHT_LINE", }) depreciationMethod!: DepreciationMethod; @Column(moneyColumn({ name: "accumulated_depreciation", default: 0 })) accumulatedDepreciation!: number; /** * Periods already charged BEFORE this asset reached Finance — the cutover * count for an asset migrated mid-life. Zero for anything bought since. * * Depreciation counts periods from `depreciation_entries` rows, which a * migrated asset has none of. Without this the count would read zero, the * cumulative target would land below what is already accumulated, the charge * would compute as negative and be skipped — and a skip writes no row, so the * count could never grow and the asset would silently never depreciate again. */ @Column({ type: "int", name: "opening_periods_charged", default: 0, }) openingPeriodsCharged!: number; @Column({ type: "varchar", length: 16, name: "status", default: "ACTIVE" }) status!: AssetStatus; /** The bill it was bought on, when it came through payables. */ @Column({ type: "uuid", name: "supplier_bill_id", nullable: true }) supplierBillId?: string | null; @Column({ type: "uuid", name: "created_by", nullable: true }) createdBy?: string | null; } /** One month's depreciation across the register, posted as one journal entry. */ @Entity({ schema: "finance", name: "depreciation_runs" }) @Check("ck_depreciation_runs_total", `"total_amount" >= 0`) export class DepreciationRun extends Audit { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "organization_id" }) organizationId!: string; @Column({ type: "uuid", name: "fiscal_period_id" }) fiscalPeriodId!: string; @Column({ type: "date", name: "run_date" }) runDate!: string; @Column({ type: "int", name: "asset_count", default: 0 }) assetCount!: number; @Column(moneyColumn({ name: "total_amount", default: 0 })) totalAmount!: number; @Column({ type: "uuid", name: "journal_entry_id", nullable: true }) journalEntryId?: string | null; @Column({ type: "uuid", name: "posted_by", nullable: true }) postedBy?: string | null; } /** * What one asset was charged in one run. * * `accumulatedAfter` is stored so the register can be replayed: without it, * reconstructing an asset's book value at a past date would mean re-deriving * every prior run's arithmetic. */ @Entity({ schema: "finance", name: "depreciation_entries" }) @Index("idx_depreciation_entries_asset", ["fixedAssetId"]) @Check("ck_depreciation_entries_amount", `"amount" > 0`) export class DepreciationEntry { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "depreciation_run_id" }) depreciationRunId!: string; @Column({ type: "uuid", name: "fixed_asset_id" }) fixedAssetId!: string; @Column(moneyColumn({ name: "amount" })) amount!: number; @Column(moneyColumn({ name: "accumulated_after" })) accumulatedAfter!: number; @Column({ type: "timestamptz", name: "created_at", default: () => "CURRENT_TIMESTAMP", }) createdAt!: Date; } /** * The end of an asset's life on the books. * * `gainLoss` is proceeds − net book value: positive is a gain (the asset was * worth less on paper than it sold for), negative a loss. Stored rather than * derived because the net book value at the moment of disposal is a frozen * fact, and later depreciation runs must not be able to change what a past * disposal reported. */ @Entity({ schema: "finance", name: "asset_disposals" }) @Check( "ck_asset_disposals_type", `"disposal_type" IN ('SALE','SCRAP','WRITE_OFF')`, ) @Check("ck_asset_disposals_proceeds", `"proceeds" >= 0`) export class AssetDisposal extends Audit { @PrimaryGeneratedColumn("uuid") id!: string; @Column({ type: "uuid", name: "organization_id" }) organizationId!: string; @Column({ type: "uuid", name: "fixed_asset_id" }) fixedAssetId!: string; @Column({ type: "date", name: "disposal_date" }) disposalDate!: string; @Column({ type: "varchar", length: 16, name: "disposal_type" }) disposalType!: DisposalType; @Column(moneyColumn({ name: "proceeds", default: 0 })) proceeds!: number; @Column(moneyColumn({ name: "net_book_value" })) netBookValue!: number; @Column(moneyColumn({ name: "gain_loss" })) gainLoss!: number; /** Where the sale proceeds landed. Null for a scrap or write-off. */ @Column({ type: "uuid", name: "proceeds_account_id", nullable: true }) proceedsAccountId?: string | null; @Column({ type: "varchar", length: 128, name: "reference", nullable: true }) reference?: string | null; @Column({ type: "text", name: "notes", nullable: true }) notes?: string | null; @Column({ type: "uuid", name: "journal_entry_id", nullable: true }) journalEntryId?: string | null; @Column({ type: "uuid", name: "recorded_by", nullable: true }) recordedBy?: string | null; }