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