Files
edr-platform/apps/finance-api/src/modules/assets/entities/fixed-asset.entity.ts
2026-08-25 00:11:39 +03:00

330 lines
10 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 { 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;
}