mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
Muluhabt ERP modules
This commit is contained in:
126
apps/finance-api/src/modules/assets/assets.controller.ts
Normal file
126
apps/finance-api/src/modules/assets/assets.controller.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { actorFrom } from "../../common/current-actor.util";
|
||||
import { FinanceStaff } from "../../common/finance-guards";
|
||||
import { FINANCE_PERMS } from "../../seed/finance-permissions.registry";
|
||||
import { AssetsService } from "./assets.service";
|
||||
import {
|
||||
AssetQueryDto,
|
||||
CreateAssetCategoryDto,
|
||||
CreateAssetDto,
|
||||
DisposeAssetDto,
|
||||
RunDepreciationDto,
|
||||
} from "./dto/assets.dto";
|
||||
|
||||
@ApiTags("fixed-assets")
|
||||
@ApiBearerAuth()
|
||||
@Controller("assets")
|
||||
@FinanceStaff([
|
||||
FINANCE_PERMS.asset.view,
|
||||
FINANCE_PERMS.asset.manage,
|
||||
FINANCE_PERMS.asset.depreciate,
|
||||
FINANCE_PERMS.asset.dispose,
|
||||
])
|
||||
export class AssetsController {
|
||||
constructor(private readonly assets: AssetsService) {}
|
||||
|
||||
@Get("categories")
|
||||
@ApiOperation({ summary: "Asset categories and their depreciation defaults" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
listCategories(@CurrentUser() user: TCurrentUser) {
|
||||
return this.assets.listCategories(actorFrom(user));
|
||||
}
|
||||
|
||||
@Post("categories")
|
||||
@ApiOperation({ summary: "Define an asset category" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.manage)
|
||||
createCategory(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: CreateAssetCategoryDto,
|
||||
) {
|
||||
return this.assets.createCategory(actorFrom(user), dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "The asset register" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
list(@CurrentUser() user: TCurrentUser, @Query() query: AssetQueryDto) {
|
||||
return this.assets.listAssets(actorFrom(user), query);
|
||||
}
|
||||
|
||||
@Get("depreciation/runs")
|
||||
@ApiOperation({ summary: "Depreciation runs" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
listRuns(@CurrentUser() user: TCurrentUser) {
|
||||
return this.assets.listRuns(actorFrom(user));
|
||||
}
|
||||
|
||||
@Get("depreciation/runs/:id/entries")
|
||||
@ApiOperation({ summary: "What each asset was charged in a run" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
runEntries(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.assets.listRunEntries(id);
|
||||
}
|
||||
|
||||
@Get("disposals")
|
||||
@ApiOperation({ summary: "Assets taken off the books, with gain or loss" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
listDisposals(@CurrentUser() user: TCurrentUser) {
|
||||
return this.assets.listDisposals(actorFrom(user));
|
||||
}
|
||||
|
||||
@Get(":id/schedule")
|
||||
@ApiOperation({ summary: "An asset's month-by-month depreciation schedule" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.view)
|
||||
schedule(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.assets.schedule(actorFrom(user), id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Add an asset. Pass fundingAccountId to post the acquisition; omit it when the asset came through an approved supplier bill.",
|
||||
})
|
||||
@FinanceStaff(FINANCE_PERMS.asset.manage)
|
||||
create(@CurrentUser() user: TCurrentUser, @Body() dto: CreateAssetDto) {
|
||||
return this.assets.createAsset(actorFrom(user), dto);
|
||||
}
|
||||
|
||||
@Post("depreciation/run")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Charge one period's depreciation across the register (once per period)",
|
||||
})
|
||||
@FinanceStaff(FINANCE_PERMS.asset.depreciate)
|
||||
runDepreciation(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: RunDepreciationDto,
|
||||
) {
|
||||
return this.assets.runDepreciation(actorFrom(user), dto);
|
||||
}
|
||||
|
||||
@Post(":id/dispose")
|
||||
@ApiOperation({ summary: "Take an asset off the books, computing gain or loss" })
|
||||
@FinanceStaff(FINANCE_PERMS.asset.dispose)
|
||||
dispose(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DisposeAssetDto,
|
||||
) {
|
||||
return this.assets.dispose(actorFrom(user), id, dto);
|
||||
}
|
||||
}
|
||||
46
apps/finance-api/src/modules/assets/assets.module.ts
Normal file
46
apps/finance-api/src/modules/assets/assets.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import {
|
||||
AssetCategory,
|
||||
AssetDisposal,
|
||||
DepreciationEntry,
|
||||
DepreciationRun,
|
||||
FixedAsset,
|
||||
} from "./entities/fixed-asset.entity";
|
||||
import {
|
||||
AssetCategoriesRepository,
|
||||
AssetDisposalsRepository,
|
||||
DepreciationRunsRepository,
|
||||
FixedAssetsRepository,
|
||||
} from "./assets.repository";
|
||||
import { AssetsService } from "./assets.service";
|
||||
import { AssetsController } from "./assets.controller";
|
||||
import { AccountsModule } from "../accounts/accounts.module";
|
||||
import { PeriodsModule } from "../periods/periods.module";
|
||||
import { JournalsModule } from "../journals/journals.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AssetCategory,
|
||||
FixedAsset,
|
||||
DepreciationRun,
|
||||
DepreciationEntry,
|
||||
AssetDisposal,
|
||||
]),
|
||||
AccountsModule,
|
||||
PeriodsModule,
|
||||
JournalsModule,
|
||||
],
|
||||
controllers: [AssetsController],
|
||||
providers: [
|
||||
AssetCategoriesRepository,
|
||||
FixedAssetsRepository,
|
||||
DepreciationRunsRepository,
|
||||
AssetDisposalsRepository,
|
||||
AssetsService,
|
||||
],
|
||||
exports: [AssetsService],
|
||||
})
|
||||
export class AssetsModule {}
|
||||
275
apps/finance-api/src/modules/assets/assets.repository.ts
Normal file
275
apps/finance-api/src/modules/assets/assets.repository.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
AssetCategory,
|
||||
AssetDisposal,
|
||||
DepreciationEntry,
|
||||
DepreciationRun,
|
||||
FixedAsset,
|
||||
} from "./entities/fixed-asset.entity";
|
||||
|
||||
@Injectable()
|
||||
export class AssetCategoriesRepository extends BaseRepository<AssetCategory> {
|
||||
constructor(
|
||||
@InjectRepository(AssetCategory) repository: Repository<AssetCategory>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(organizationId: string, code: string) {
|
||||
return this.repository.findOne({ where: { organizationId, code } });
|
||||
}
|
||||
|
||||
findAllForOrg(organizationId: string): Promise<AssetCategory[]> {
|
||||
return this.repository.find({
|
||||
where: { organizationId },
|
||||
order: { code: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
async countAssets(assetCategoryId: string): Promise<number> {
|
||||
const rows = await this.repository.manager.query<{ count: string }[]>(
|
||||
`SELECT COUNT(*)::text AS count FROM finance.fixed_assets
|
||||
WHERE asset_category_id = $1 AND deleted_at IS NULL`,
|
||||
[assetCategoryId],
|
||||
);
|
||||
return parseInt(rows[0]?.count ?? "0", 10);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FixedAssetsRepository extends BaseRepository<FixedAsset> {
|
||||
constructor(@InjectRepository(FixedAsset) repository: Repository<FixedAsset>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(organizationId: string, assetCode: string) {
|
||||
return this.repository.findOne({ where: { organizationId, assetCode } });
|
||||
}
|
||||
|
||||
/** The register, with category and cost center attached for display. */
|
||||
findRegister(
|
||||
organizationId: string,
|
||||
filters: { status?: string; search?: string } = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const clauses: string[] = ["a.organization_id = $1", "a.deleted_at IS NULL"];
|
||||
const params: unknown[] = [organizationId];
|
||||
|
||||
if (filters.status) {
|
||||
params.push(filters.status);
|
||||
clauses.push(`a.status = $${params.length}`);
|
||||
}
|
||||
if (filters.search) {
|
||||
params.push(`%${filters.search}%`);
|
||||
clauses.push(
|
||||
`(a.asset_code ILIKE $${params.length} OR a.name ILIKE $${params.length} OR a.serial_number ILIKE $${params.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.repository.manager.query(
|
||||
`SELECT a.id,
|
||||
a.asset_code AS "assetCode",
|
||||
a.name,
|
||||
a.serial_number AS "serialNumber",
|
||||
a.acquisition_date::text AS "acquisitionDate",
|
||||
a.in_service_date::text AS "inServiceDate",
|
||||
a.acquisition_cost AS "acquisitionCost",
|
||||
a.salvage_value AS "salvageValue",
|
||||
a.useful_life_months AS "usefulLifeMonths",
|
||||
a.accumulated_depreciation AS "accumulatedDepreciation",
|
||||
ROUND(a.acquisition_cost - a.accumulated_depreciation, 2) AS "netBookValue",
|
||||
a.status,
|
||||
a.depreciation_method AS "depreciationMethod",
|
||||
c.id AS "categoryId",
|
||||
c.code AS "categoryCode",
|
||||
c.name AS "categoryName",
|
||||
cc.code AS "costCenterCode",
|
||||
cc.name AS "costCenterName"
|
||||
FROM finance.fixed_assets a
|
||||
JOIN finance.asset_categories c ON c.id = a.asset_category_id
|
||||
LEFT JOIN finance.cost_centers cc ON cc.id = a.cost_center_id
|
||||
WHERE ${clauses.join(" AND ")}
|
||||
ORDER BY a.asset_code ASC`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assets eligible for depreciation in a period, with their category's posting
|
||||
* accounts.
|
||||
*
|
||||
* DISPOSED and WRITTEN_OFF assets are excluded here rather than filtered
|
||||
* later, and assets already at their cap are dropped too — so a run over a
|
||||
* mature register does not load thousands of rows only to charge them zero.
|
||||
*/
|
||||
findDepreciable(
|
||||
organizationId: string,
|
||||
periodEnd: string,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
return this.repository.manager.query(
|
||||
`SELECT a.id,
|
||||
a.asset_code AS "assetCode",
|
||||
a.name,
|
||||
a.acquisition_cost AS "acquisitionCost",
|
||||
a.salvage_value AS "salvageValue",
|
||||
a.useful_life_months AS "usefulLifeMonths",
|
||||
a.accumulated_depreciation AS "accumulatedDepreciation",
|
||||
a.in_service_date::text AS "inServiceDate",
|
||||
a.depreciation_method AS "depreciationMethod",
|
||||
a.status,
|
||||
a.cost_center_id AS "costCenterId",
|
||||
c.expense_account_id AS "expenseAccountId",
|
||||
c.accumulated_account_id AS "accumulatedAccountId",
|
||||
-- Counted, not inferred from the accumulated total: the moment a
|
||||
-- charge is rounded, dividing accumulated by the nominal figure
|
||||
-- becomes ambiguous.
|
||||
--
|
||||
-- opening_periods_charged is added because a MIGRATED asset
|
||||
-- arrives with accumulated depreciation and no entry rows at all.
|
||||
-- Counting rows alone would put it at zero, the cumulative target
|
||||
-- would land below what is already accumulated, the charge would
|
||||
-- compute as negative and be skipped — and since a skip writes no
|
||||
-- row, the count could never grow. The asset would silently never
|
||||
-- depreciate again.
|
||||
(a.opening_periods_charged
|
||||
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
|
||||
WHERE d.fixed_asset_id = a.id)) AS "periodsCharged"
|
||||
FROM finance.fixed_assets a
|
||||
JOIN finance.asset_categories c ON c.id = a.asset_category_id
|
||||
WHERE a.organization_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
AND a.status IN ('ACTIVE')
|
||||
AND a.in_service_date <= $2::date
|
||||
AND a.accumulated_depreciation < a.acquisition_cost - a.salvage_value
|
||||
ORDER BY a.asset_code ASC`,
|
||||
[organizationId, periodEnd],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the LEDGER says has accumulated, per accumulated-depreciation account.
|
||||
*
|
||||
* The register's running totals are a cache; this is the authority. The run
|
||||
* compares them and refuses to post if they disagree, because a silent drift
|
||||
* between the two is exactly the error that surfaces a year later as an
|
||||
* unexplainable balance sheet.
|
||||
*
|
||||
* DISPOSED and WRITTEN_OFF assets are excluded from the register side: a
|
||||
* disposal debits their accumulated depreciation back out of the ledger while
|
||||
* the register row keeps its historical total. Counting them would make the
|
||||
* two sides disagree by the whole of every disposal ever made.
|
||||
*/
|
||||
ledgerAccumulated(organizationId: string): Promise<Record<string, unknown>[]> {
|
||||
return this.repository.manager.query(
|
||||
`SELECT c.accumulated_account_id AS "accountId",
|
||||
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2) AS "ledgerAccumulated",
|
||||
ROUND(COALESCE((
|
||||
SELECT SUM(a2.accumulated_depreciation)
|
||||
FROM finance.fixed_assets a2
|
||||
WHERE a2.asset_category_id IN (
|
||||
SELECT c2.id FROM finance.asset_categories c2
|
||||
WHERE c2.accumulated_account_id = c.accumulated_account_id
|
||||
AND c2.organization_id = $1)
|
||||
AND a2.deleted_at IS NULL
|
||||
AND a2.status NOT IN ('DISPOSED','WRITTEN_OFF')
|
||||
), 0), 2) AS "registerAccumulated"
|
||||
FROM finance.asset_categories c
|
||||
LEFT JOIN finance.journal_lines l ON l.account_id = c.accumulated_account_id
|
||||
LEFT JOIN finance.journal_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.status <> 'DRAFT'
|
||||
AND e.deleted_at IS NULL
|
||||
AND e.organization_id = $1
|
||||
WHERE c.organization_id = $1 AND c.deleted_at IS NULL
|
||||
GROUP BY c.accumulated_account_id`,
|
||||
[organizationId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DepreciationRunsRepository extends BaseRepository<DepreciationRun> {
|
||||
constructor(
|
||||
@InjectRepository(DepreciationRun) repository: Repository<DepreciationRun>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findForPeriod(organizationId: string, fiscalPeriodId: string) {
|
||||
return this.repository.findOne({
|
||||
where: { organizationId, fiscalPeriodId },
|
||||
});
|
||||
}
|
||||
|
||||
listWithPeriods(organizationId: string): Promise<Record<string, unknown>[]> {
|
||||
return this.repository.manager.query(
|
||||
`SELECT r.id,
|
||||
r.run_date::text AS "runDate",
|
||||
r.asset_count AS "assetCount",
|
||||
r.total_amount AS "totalAmount",
|
||||
r.journal_entry_id AS "journalEntryId",
|
||||
e.entry_number AS "entryNumber",
|
||||
p.name AS "periodName",
|
||||
p.period_number AS "periodNumber"
|
||||
FROM finance.depreciation_runs r
|
||||
JOIN finance.fiscal_periods p ON p.id = r.fiscal_period_id
|
||||
LEFT JOIN finance.journal_entries e ON e.id = r.journal_entry_id
|
||||
WHERE r.organization_id = $1
|
||||
ORDER BY r.run_date DESC`,
|
||||
[organizationId],
|
||||
);
|
||||
}
|
||||
|
||||
listEntries(runId: string): Promise<Record<string, unknown>[]> {
|
||||
return this.repository.manager.query(
|
||||
`SELECT d.id,
|
||||
d.amount,
|
||||
d.accumulated_after AS "accumulatedAfter",
|
||||
a.asset_code AS "assetCode",
|
||||
a.name AS "assetName"
|
||||
FROM finance.depreciation_entries d
|
||||
JOIN finance.fixed_assets a ON a.id = d.fixed_asset_id
|
||||
WHERE d.depreciation_run_id = $1
|
||||
ORDER BY a.asset_code ASC`,
|
||||
[runId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetDisposalsRepository extends BaseRepository<AssetDisposal> {
|
||||
constructor(
|
||||
@InjectRepository(AssetDisposal) repository: Repository<AssetDisposal>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByAsset(fixedAssetId: string) {
|
||||
return this.repository.findOne({ where: { fixedAssetId } });
|
||||
}
|
||||
|
||||
listWithAssets(organizationId: string): Promise<Record<string, unknown>[]> {
|
||||
return this.repository.manager.query(
|
||||
`SELECT d.id,
|
||||
d.disposal_date::text AS "disposalDate",
|
||||
d.disposal_type AS "disposalType",
|
||||
d.proceeds,
|
||||
d.net_book_value AS "netBookValue",
|
||||
d.gain_loss AS "gainLoss",
|
||||
d.reference,
|
||||
d.journal_entry_id AS "journalEntryId",
|
||||
a.asset_code AS "assetCode",
|
||||
a.name AS "assetName"
|
||||
FROM finance.asset_disposals d
|
||||
JOIN finance.fixed_assets a ON a.id = d.fixed_asset_id
|
||||
WHERE d.organization_id = $1
|
||||
ORDER BY d.disposal_date DESC`,
|
||||
[organizationId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { DepreciationEntry };
|
||||
667
apps/finance-api/src/modules/assets/assets.service.ts
Normal file
667
apps/finance-api/src/modules/assets/assets.service.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
import { roundMoney } from "../../common/money";
|
||||
import { AccountsService } from "../accounts/accounts.service";
|
||||
import { PeriodsService } from "../periods/periods.service";
|
||||
import { JournalsService } from "../journals/journals.service";
|
||||
import {
|
||||
AssetCategoriesRepository,
|
||||
AssetDisposalsRepository,
|
||||
DepreciationRunsRepository,
|
||||
FixedAssetsRepository,
|
||||
} from "./assets.repository";
|
||||
import {
|
||||
AssetCategory,
|
||||
AssetDisposal,
|
||||
DepreciationEntry,
|
||||
DepreciationRun,
|
||||
FixedAsset,
|
||||
} from "./entities/fixed-asset.entity";
|
||||
import {
|
||||
depreciationFor,
|
||||
depreciationSchedule,
|
||||
disposalResult,
|
||||
netBookValue,
|
||||
type DepreciableAsset,
|
||||
} from "./depreciation.calculator";
|
||||
import type {
|
||||
AssetQueryDto,
|
||||
CreateAssetCategoryDto,
|
||||
CreateAssetDto,
|
||||
DisposeAssetDto,
|
||||
RunDepreciationDto,
|
||||
} from "./dto/assets.dto";
|
||||
|
||||
/** Gain and loss on disposal land here. */
|
||||
const GAIN_ACCOUNT_CODE = "4910";
|
||||
const LOSS_ACCOUNT_CODE = "5900";
|
||||
|
||||
const num = (v: unknown): number => Number(v ?? 0);
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
constructor(
|
||||
private readonly categories: AssetCategoriesRepository,
|
||||
private readonly assets: FixedAssetsRepository,
|
||||
private readonly runs: DepreciationRunsRepository,
|
||||
private readonly disposals: AssetDisposalsRepository,
|
||||
private readonly accounts: AccountsService,
|
||||
private readonly periods: PeriodsService,
|
||||
private readonly journals: JournalsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// ── categories ───────────────────────────────────────────────────────────
|
||||
|
||||
listCategories(actor: ActorContext): Promise<AssetCategory[]> {
|
||||
return this.categories.findAllForOrg(this.requireOrganization(actor));
|
||||
}
|
||||
|
||||
async createCategory(
|
||||
actor: ActorContext,
|
||||
dto: CreateAssetCategoryDto,
|
||||
): Promise<AssetCategory> {
|
||||
const organizationId = this.requireOrganization(actor);
|
||||
|
||||
const existing = await this.categories.findByCode(organizationId, dto.code);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Asset category ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
// Each of the three accounts must be the right TYPE, or the postings would
|
||||
// balance while describing something else entirely.
|
||||
const asset = await this.accounts.assertPostable(actor, dto.assetAccountId);
|
||||
this.assertType(asset, "ASSET", "the asset cost account");
|
||||
const accumulated = await this.accounts.assertPostable(
|
||||
actor,
|
||||
dto.accumulatedAccountId,
|
||||
);
|
||||
this.assertType(accumulated, "ASSET", "the accumulated depreciation account");
|
||||
if (!accumulated.isContra) {
|
||||
throw new BadRequestException(
|
||||
`${accumulated.code} is not marked as a contra account. Accumulated depreciation must be contra, or the balance sheet would ADD it to assets instead of subtracting it.`,
|
||||
);
|
||||
}
|
||||
const expense = await this.accounts.assertPostable(
|
||||
actor,
|
||||
dto.expenseAccountId,
|
||||
);
|
||||
this.assertType(expense, "EXPENSE", "the depreciation expense account");
|
||||
|
||||
return this.categories.create({
|
||||
organizationId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
assetAccountId: asset.id,
|
||||
accumulatedAccountId: accumulated.id,
|
||||
expenseAccountId: expense.id,
|
||||
defaultLifeMonths: dto.defaultLifeMonths,
|
||||
defaultSalvageRate: String(dto.defaultSalvageRate ?? 0),
|
||||
isActive: true,
|
||||
createdBy: actor.employeeId,
|
||||
});
|
||||
}
|
||||
|
||||
// ── register ─────────────────────────────────────────────────────────────
|
||||
|
||||
listAssets(actor: ActorContext, query: AssetQueryDto) {
|
||||
return this.assets.findRegister(this.requireOrganization(actor), query);
|
||||
}
|
||||
|
||||
async findAsset(actor: ActorContext, id: string): Promise<FixedAsset> {
|
||||
const asset = await this.assets.findById(id);
|
||||
if (!asset) throw new NotFoundException("Asset not found");
|
||||
if (!actor.isSuperAdmin && asset.organizationId !== actor.organizationId) {
|
||||
throw new NotFoundException("Asset not found");
|
||||
}
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** The full month-by-month schedule for one asset. */
|
||||
async schedule(actor: ActorContext, id: string) {
|
||||
const asset = await this.findAsset(actor, id);
|
||||
// Opening count + charged count — see the note in assets.repository.ts.
|
||||
// A migrated asset has no entry rows, so rows alone understate it.
|
||||
const [counted] = await this.dataSource.query<{ n: string }[]>(
|
||||
`SELECT (a.opening_periods_charged
|
||||
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
|
||||
WHERE d.fixed_asset_id = a.id))::text AS n
|
||||
FROM finance.fixed_assets a
|
||||
WHERE a.id = $1`,
|
||||
[id],
|
||||
);
|
||||
const periodsCharged = parseInt(counted?.n ?? "0", 10);
|
||||
return {
|
||||
periodsCharged,
|
||||
assetCode: asset.assetCode,
|
||||
acquisitionCost: Number(asset.acquisitionCost),
|
||||
salvageValue: Number(asset.salvageValue),
|
||||
usefulLifeMonths: asset.usefulLifeMonths,
|
||||
accumulatedDepreciation: Number(asset.accumulatedDepreciation),
|
||||
netBookValue: netBookValue(this.toDepreciable(asset)),
|
||||
schedule: depreciationSchedule(this.toDepreciable(asset)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an asset to the register, optionally posting its acquisition.
|
||||
*
|
||||
* `fundingAccountId` is omitted when the asset already reached the books
|
||||
* through an approved supplier bill — posting again would double the cost.
|
||||
*/
|
||||
async createAsset(actor: ActorContext, dto: CreateAssetDto) {
|
||||
const organizationId = this.requireOrganization(actor);
|
||||
|
||||
const existing = await this.assets.findByCode(organizationId, dto.assetCode);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Asset code ${dto.assetCode} already exists`);
|
||||
}
|
||||
|
||||
const category = await this.categories.findById(dto.assetCategoryId);
|
||||
if (!category || category.organizationId !== organizationId) {
|
||||
throw new BadRequestException("Asset category not found");
|
||||
}
|
||||
|
||||
const cost = roundMoney(dto.acquisitionCost);
|
||||
const salvage = roundMoney(
|
||||
dto.salvageValue ?? cost * Number(category.defaultSalvageRate),
|
||||
);
|
||||
if (salvage >= cost) {
|
||||
throw new BadRequestException(
|
||||
`Salvage value ${salvage.toFixed(2)} must be below the cost ${cost.toFixed(2)} — otherwise there is nothing to depreciate`,
|
||||
);
|
||||
}
|
||||
|
||||
const inServiceDate = dto.inServiceDate ?? dto.acquisitionDate;
|
||||
if (inServiceDate < dto.acquisitionDate) {
|
||||
throw new BadRequestException(
|
||||
"An asset cannot enter service before it was acquired",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Cutover: an asset migrated mid-life ────────────────────────────────
|
||||
const openingAccumulated = roundMoney(
|
||||
dto.openingAccumulatedDepreciation ?? 0,
|
||||
);
|
||||
const openingPeriods = dto.openingPeriodsCharged ?? 0;
|
||||
const usefulLifeMonths = dto.usefulLifeMonths ?? category.defaultLifeMonths;
|
||||
|
||||
if ((openingAccumulated > 0 || openingPeriods > 0) && dto.fundingAccountId) {
|
||||
throw new BadRequestException(
|
||||
"An opening asset already has its cost and accumulated depreciation in the opening balance entry. Posting an acquisition as well would count it twice — omit fundingAccountId.",
|
||||
);
|
||||
}
|
||||
// The two must travel together. Accumulated depreciation with no period
|
||||
// count leaves the asset unable to depreciate ever again; a period count
|
||||
// with nothing accumulated would immediately over-charge to catch up.
|
||||
if (openingAccumulated > 0 && openingPeriods === 0) {
|
||||
throw new BadRequestException(
|
||||
"openingAccumulatedDepreciation needs openingPeriodsCharged — without a period count this asset would never depreciate again.",
|
||||
);
|
||||
}
|
||||
if (openingPeriods > 0 && openingAccumulated === 0) {
|
||||
throw new BadRequestException(
|
||||
"openingPeriodsCharged needs openingAccumulatedDepreciation — otherwise the next run charges every one of those periods at once.",
|
||||
);
|
||||
}
|
||||
if (openingPeriods > usefulLifeMonths) {
|
||||
throw new BadRequestException(
|
||||
`openingPeriodsCharged ${openingPeriods} exceeds the ${usefulLifeMonths}-month life`,
|
||||
);
|
||||
}
|
||||
if (openingAccumulated > roundMoney(cost - salvage)) {
|
||||
throw new BadRequestException(
|
||||
`openingAccumulatedDepreciation ${openingAccumulated.toFixed(2)} exceeds the depreciable base ${roundMoney(cost - salvage).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await this.assets.create({
|
||||
organizationId,
|
||||
assetCategoryId: category.id,
|
||||
assetCode: dto.assetCode,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
serialNumber: dto.serialNumber ?? null,
|
||||
costCenterId: dto.costCenterId ?? null,
|
||||
acquisitionDate: dto.acquisitionDate,
|
||||
inServiceDate,
|
||||
acquisitionCost: cost,
|
||||
salvageValue: salvage,
|
||||
usefulLifeMonths,
|
||||
depreciationMethod: "STRAIGHT_LINE",
|
||||
accumulatedDepreciation: openingAccumulated,
|
||||
openingPeriodsCharged: openingPeriods,
|
||||
status: "ACTIVE",
|
||||
createdBy: actor.employeeId,
|
||||
});
|
||||
|
||||
if (dto.fundingAccountId) {
|
||||
const funding = await this.accounts.assertPostable(
|
||||
actor,
|
||||
dto.fundingAccountId,
|
||||
);
|
||||
await this.journals.createPosted(actor, {
|
||||
entryDate: dto.acquisitionDate,
|
||||
journalType: "GENERAL",
|
||||
memo: `Acquisition of ${dto.assetCode} — ${dto.name}`,
|
||||
reference: dto.assetCode,
|
||||
sourceModule: "asset-acquisition",
|
||||
sourceId: asset.id,
|
||||
lines: [
|
||||
{
|
||||
accountId: category.assetAccountId,
|
||||
debit: cost,
|
||||
description: dto.name,
|
||||
costCenterId: dto.costCenterId,
|
||||
},
|
||||
{
|
||||
accountId: funding.id,
|
||||
credit: cost,
|
||||
description: `Funding for ${dto.assetCode}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return this.findAsset(actor, asset.id);
|
||||
}
|
||||
|
||||
// ── depreciation ─────────────────────────────────────────────────────────
|
||||
|
||||
listRuns(actor: ActorContext) {
|
||||
return this.runs.listWithPeriods(this.requireOrganization(actor));
|
||||
}
|
||||
|
||||
listRunEntries(runId: string) {
|
||||
return this.runs.listEntries(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges one period's depreciation across the register and posts it as ONE
|
||||
* journal entry.
|
||||
*
|
||||
* Dr 5400 Depreciation expense (per category)
|
||||
* Cr 1290 Accumulated depreciation
|
||||
*
|
||||
* Summarized by category rather than per asset: a register of a thousand
|
||||
* items would otherwise produce a thousand journal lines every month, and the
|
||||
* per-asset detail is already kept in `depreciation_entries`.
|
||||
*
|
||||
* Before charging anything it reconciles the register's running totals
|
||||
* against the ledger. A drift there means an earlier run was posted and then
|
||||
* reversed (or vice versa), and charging on top of it would bury the
|
||||
* discrepancy under another month.
|
||||
*/
|
||||
async runDepreciation(actor: ActorContext, dto: RunDepreciationDto) {
|
||||
const organizationId = this.requireOrganization(actor);
|
||||
const period = await this.periods.findPeriod(actor, dto.fiscalPeriodId);
|
||||
|
||||
const already = await this.runs.findForPeriod(organizationId, period.id);
|
||||
if (already) {
|
||||
throw new ConflictException(
|
||||
`Depreciation for ${period.name?.en ?? "this period"} has already been run. Running it twice would charge the same wear twice.`,
|
||||
);
|
||||
}
|
||||
if (period.status !== "OPEN") {
|
||||
throw new BadRequestException(
|
||||
`${period.name?.en ?? "That period"} is ${period.status} — depreciation must be charged to an open period`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertRegisterReconciles(organizationId);
|
||||
|
||||
const candidates = await this.assets.findDepreciable(
|
||||
organizationId,
|
||||
period.endDate,
|
||||
);
|
||||
|
||||
const charges: {
|
||||
assetId: string;
|
||||
assetCode: string;
|
||||
amount: number;
|
||||
accumulatedAfter: number;
|
||||
expenseAccountId: string;
|
||||
accumulatedAccountId: string;
|
||||
costCenterId: string | null;
|
||||
fullyDepreciated: boolean;
|
||||
}[] = [];
|
||||
|
||||
for (const row of candidates) {
|
||||
const charge = depreciationFor(
|
||||
{
|
||||
acquisitionCost: num(row.acquisitionCost),
|
||||
salvageValue: num(row.salvageValue),
|
||||
usefulLifeMonths: Number(row.usefulLifeMonths),
|
||||
accumulatedDepreciation: num(row.accumulatedDepreciation),
|
||||
inServiceDate: String(row.inServiceDate),
|
||||
depreciationMethod: String(row.depreciationMethod),
|
||||
status: String(row.status),
|
||||
periodsCharged: Number(row.periodsCharged ?? 0),
|
||||
},
|
||||
period.endDate,
|
||||
);
|
||||
if (charge.amount <= 0) continue;
|
||||
|
||||
charges.push({
|
||||
assetId: String(row.id),
|
||||
assetCode: String(row.assetCode),
|
||||
amount: charge.amount,
|
||||
accumulatedAfter: charge.accumulatedAfter,
|
||||
expenseAccountId: String(row.expenseAccountId),
|
||||
accumulatedAccountId: String(row.accumulatedAccountId),
|
||||
costCenterId: row.costCenterId ? String(row.costCenterId) : null,
|
||||
fullyDepreciated: charge.fullyDepreciated,
|
||||
});
|
||||
}
|
||||
|
||||
if (charges.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Nothing to depreciate in this period — every asset is either not yet in service, disposed of, or fully depreciated",
|
||||
);
|
||||
}
|
||||
|
||||
// Summarize by (expense account, cost center) on the debit side and by
|
||||
// accumulated account on the credit side.
|
||||
const debits = new Map<string, { accountId: string; costCenterId: string | null; amount: number }>();
|
||||
const credits = new Map<string, number>();
|
||||
for (const c of charges) {
|
||||
const dKey = `${c.expenseAccountId}|${c.costCenterId ?? "-"}`;
|
||||
const d = debits.get(dKey);
|
||||
if (d) d.amount = roundMoney(d.amount + c.amount);
|
||||
else debits.set(dKey, { accountId: c.expenseAccountId, costCenterId: c.costCenterId, amount: c.amount });
|
||||
|
||||
credits.set(
|
||||
c.accumulatedAccountId,
|
||||
roundMoney((credits.get(c.accumulatedAccountId) ?? 0) + c.amount),
|
||||
);
|
||||
}
|
||||
|
||||
const total = roundMoney(charges.reduce((s, c) => s + c.amount, 0));
|
||||
|
||||
const result = await this.dataSource.transaction(async (manager) => {
|
||||
// The manager is passed through so the journal entry is written on THIS
|
||||
// transaction. Without it the entry committed on its own connection, and
|
||||
// a failure in the writes below left a posted depreciation entry with no
|
||||
// run and no register update to explain it.
|
||||
const entry = await this.journals.createPosted(
|
||||
actor,
|
||||
{
|
||||
entryDate: period.endDate,
|
||||
journalType: "DEPRECIATION",
|
||||
memo: `Depreciation for ${period.name?.en ?? period.endDate} (${charges.length} assets)`,
|
||||
reference: period.name?.en,
|
||||
sourceModule: "depreciation",
|
||||
sourceId: period.id,
|
||||
lines: [
|
||||
...[...debits.values()].map((d) => ({
|
||||
accountId: d.accountId,
|
||||
debit: d.amount,
|
||||
description: "Depreciation",
|
||||
costCenterId: d.costCenterId ?? undefined,
|
||||
})),
|
||||
...[...credits.entries()].map(([accountId, amount]) => ({
|
||||
accountId,
|
||||
credit: amount,
|
||||
description: "Accumulated depreciation",
|
||||
})),
|
||||
],
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
const runRepo = manager.getRepository(DepreciationRun);
|
||||
const run = await runRepo.save(
|
||||
runRepo.create({
|
||||
organizationId,
|
||||
fiscalPeriodId: period.id,
|
||||
runDate: period.endDate,
|
||||
assetCount: charges.length,
|
||||
totalAmount: total,
|
||||
journalEntryId: entry.id,
|
||||
postedBy: actor.employeeId,
|
||||
}),
|
||||
);
|
||||
|
||||
const entryRepo = manager.getRepository(DepreciationEntry);
|
||||
await entryRepo.save(
|
||||
charges.map((c) =>
|
||||
entryRepo.create({
|
||||
depreciationRunId: run.id,
|
||||
fixedAssetId: c.assetId,
|
||||
amount: c.amount,
|
||||
accumulatedAfter: c.accumulatedAfter,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Advance each asset's running total, and retire the ones that have
|
||||
// reached their cap so they are not reconsidered next month.
|
||||
const assetRepo = manager.getRepository(FixedAsset);
|
||||
for (const c of charges) {
|
||||
await assetRepo.update(c.assetId, {
|
||||
accumulatedDepreciation: c.accumulatedAfter,
|
||||
...(c.fullyDepreciated ? { status: "FULLY_DEPRECIATED" as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return { run, entryNumber: entry.entryNumber, journalEntryId: entry.id };
|
||||
});
|
||||
|
||||
return {
|
||||
runId: result.run.id,
|
||||
entryNumber: result.entryNumber,
|
||||
journalEntryId: result.journalEntryId,
|
||||
assetCount: charges.length,
|
||||
total,
|
||||
fullyDepreciated: charges.filter((c) => c.fullyDepreciated).length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── disposal ─────────────────────────────────────────────────────────────
|
||||
|
||||
listDisposals(actor: ActorContext) {
|
||||
return this.disposals.listWithAssets(this.requireOrganization(actor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an asset off the books.
|
||||
*
|
||||
* Dr cash/receivable proceeds, if any
|
||||
* Dr 1290 Accumulated depn everything charged so far, reversed out
|
||||
* Dr 5900 Loss on disposal when the books valued it above what it fetched
|
||||
* Cr 121x Asset cost the original cost leaves
|
||||
* Cr 4910 Gain on disposal when it fetched more than book value
|
||||
*
|
||||
* The two sides balance because accumulated + proceeds + loss always equals
|
||||
* cost + gain — that is what makes gain/loss the plug, and why it is computed
|
||||
* rather than entered.
|
||||
*/
|
||||
async dispose(actor: ActorContext, id: string, dto: DisposeAssetDto) {
|
||||
const asset = await this.findAsset(actor, id);
|
||||
|
||||
if (asset.status === "DISPOSED" || asset.status === "WRITTEN_OFF") {
|
||||
throw new ConflictException(
|
||||
`${asset.assetCode} is already ${asset.status}`,
|
||||
);
|
||||
}
|
||||
if (dto.disposalDate < asset.inServiceDate) {
|
||||
throw new BadRequestException(
|
||||
"An asset cannot be disposed of before it entered service",
|
||||
);
|
||||
}
|
||||
|
||||
const proceeds = roundMoney(dto.proceeds ?? 0);
|
||||
if (proceeds > 0 && !dto.proceedsAccountId) {
|
||||
throw new BadRequestException(
|
||||
"Name the account the proceeds landed in — money received has to go somewhere",
|
||||
);
|
||||
}
|
||||
|
||||
const { netBookValue: nbv, gainLoss } = disposalResult(
|
||||
this.toDepreciable(asset),
|
||||
proceeds,
|
||||
);
|
||||
|
||||
const category = await this.categories.findById(asset.assetCategoryId);
|
||||
if (!category) throw new BadRequestException("Asset category not found");
|
||||
|
||||
const accumulated = roundMoney(Number(asset.accumulatedDepreciation));
|
||||
const cost = roundMoney(Number(asset.acquisitionCost));
|
||||
|
||||
const lines: {
|
||||
accountId: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
description: string;
|
||||
}[] = [];
|
||||
|
||||
if (proceeds > 0) {
|
||||
const proceedsAccount = await this.accounts.assertPostable(
|
||||
actor,
|
||||
dto.proceedsAccountId as string,
|
||||
);
|
||||
lines.push({
|
||||
accountId: proceedsAccount.id,
|
||||
debit: proceeds,
|
||||
description: `Proceeds from ${asset.assetCode}`,
|
||||
});
|
||||
}
|
||||
if (accumulated > 0) {
|
||||
lines.push({
|
||||
accountId: category.accumulatedAccountId,
|
||||
debit: accumulated,
|
||||
description: `Accumulated depreciation removed for ${asset.assetCode}`,
|
||||
});
|
||||
}
|
||||
if (gainLoss < 0) {
|
||||
const loss = await this.requireAccount(actor, LOSS_ACCOUNT_CODE);
|
||||
lines.push({
|
||||
accountId: loss.id,
|
||||
debit: roundMoney(Math.abs(gainLoss)),
|
||||
description: `Loss on disposal of ${asset.assetCode}`,
|
||||
});
|
||||
}
|
||||
|
||||
lines.push({
|
||||
accountId: category.assetAccountId,
|
||||
credit: cost,
|
||||
description: `Cost of ${asset.assetCode} removed`,
|
||||
});
|
||||
|
||||
if (gainLoss > 0) {
|
||||
const gain = await this.requireAccount(actor, GAIN_ACCOUNT_CODE);
|
||||
lines.push({
|
||||
accountId: gain.id,
|
||||
credit: roundMoney(gainLoss),
|
||||
description: `Gain on disposal of ${asset.assetCode}`,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = await this.journals.createPosted(actor, {
|
||||
entryDate: dto.disposalDate,
|
||||
journalType: "GENERAL",
|
||||
memo: `${dto.disposalType} of ${asset.assetCode} — ${asset.name}`,
|
||||
reference: dto.reference ?? asset.assetCode,
|
||||
sourceModule: "asset-disposal",
|
||||
sourceId: asset.id,
|
||||
lines,
|
||||
});
|
||||
|
||||
const disposal = await this.disposals.create({
|
||||
organizationId: asset.organizationId,
|
||||
fixedAssetId: asset.id,
|
||||
disposalDate: dto.disposalDate,
|
||||
disposalType: dto.disposalType,
|
||||
proceeds,
|
||||
netBookValue: nbv,
|
||||
gainLoss,
|
||||
proceedsAccountId: dto.proceedsAccountId ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
journalEntryId: entry.id,
|
||||
recordedBy: actor.employeeId,
|
||||
});
|
||||
|
||||
await this.assets.update(asset.id, {
|
||||
status: dto.disposalType === "WRITE_OFF" ? "WRITTEN_OFF" : "DISPOSED",
|
||||
});
|
||||
|
||||
return { disposal, entryNumber: entry.entryNumber, netBookValue: nbv, gainLoss };
|
||||
}
|
||||
|
||||
// ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The register's running totals must agree with the ledger before another
|
||||
* month is charged on top of them.
|
||||
*/
|
||||
private async assertRegisterReconciles(organizationId: string): Promise<void> {
|
||||
const rows = await this.assets.ledgerAccumulated(organizationId);
|
||||
for (const row of rows) {
|
||||
const ledger = num(row.ledgerAccumulated);
|
||||
const register = num(row.registerAccumulated);
|
||||
if (Math.abs(ledger - register) >= 0.005) {
|
||||
throw new BadRequestException(
|
||||
`The asset register and the ledger disagree: the register shows ${register.toFixed(2)} of accumulated depreciation, the ledger ${ledger.toFixed(2)}. Reconcile them before charging another period — most likely a depreciation entry was reversed without the register being adjusted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toDepreciable(
|
||||
asset: FixedAsset,
|
||||
periodsCharged = 0,
|
||||
): DepreciableAsset {
|
||||
return {
|
||||
acquisitionCost: Number(asset.acquisitionCost),
|
||||
salvageValue: Number(asset.salvageValue),
|
||||
usefulLifeMonths: asset.usefulLifeMonths,
|
||||
accumulatedDepreciation: Number(asset.accumulatedDepreciation),
|
||||
inServiceDate: String(asset.inServiceDate).slice(0, 10),
|
||||
depreciationMethod: asset.depreciationMethod,
|
||||
status: asset.status,
|
||||
periodsCharged,
|
||||
};
|
||||
}
|
||||
|
||||
private assertType(
|
||||
account: { code: string; accountType: string },
|
||||
expected: string,
|
||||
label: string,
|
||||
): void {
|
||||
if (account.accountType !== expected) {
|
||||
throw new BadRequestException(
|
||||
`${account.code} is a ${account.accountType} account, but ${label} must be ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireAccount(actor: ActorContext, code: string) {
|
||||
const organizationId = this.requireOrganization(actor);
|
||||
const account = await this.accounts.findByCode(organizationId, code);
|
||||
if (!account) {
|
||||
throw new BadRequestException(
|
||||
`Account ${code} is missing from this organization's chart`,
|
||||
);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private requireOrganization(actor: ActorContext): string {
|
||||
if (!actor.organizationId) {
|
||||
throw new BadRequestException(
|
||||
"This account has no organization context, so it cannot manage assets",
|
||||
);
|
||||
}
|
||||
return actor.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
export { AssetDisposal };
|
||||
201
apps/finance-api/src/modules/assets/depreciation.calculator.ts
Normal file
201
apps/finance-api/src/modules/assets/depreciation.calculator.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { roundMoney } from "../../common/money";
|
||||
|
||||
/**
|
||||
* Straight-line depreciation, as a PURE function.
|
||||
*
|
||||
* No database, no clock, no injected services — the same shape HR's payroll
|
||||
* calculator settled on, for the same reason: depreciation is arithmetic an
|
||||
* accountant will want to check by hand, and a function that reads nothing can
|
||||
* be checked by hand.
|
||||
*
|
||||
* ── Conventions, stated because they are choices, not laws ──────────────────
|
||||
*
|
||||
* FULL-MONTH: an asset earns a whole month's charge in the month it enters
|
||||
* service, and none in the month it is disposed of. The alternative (pro-rating
|
||||
* by day) is defensible but produces amounts nobody can verify mentally, and
|
||||
* the difference washes out over a life measured in years.
|
||||
*
|
||||
* CUMULATIVE TARGET, not a repeated monthly figure. Each charge is
|
||||
* `round(base × periodsCharged / life) − accumulated`, so rounding error can
|
||||
* never accumulate: whatever a period rounds away, the next one picks up, and
|
||||
* the final period lands exactly on the base because `base × life / life` is
|
||||
* the base.
|
||||
*
|
||||
* Charging a fixed `round(base / life)` every month instead is the obvious
|
||||
* implementation and it is wrong: 1,000 over 3 months gives 333.33 three times,
|
||||
* totalling 999.99, and that last cent never depreciates because the cap stops
|
||||
* the next charge. The cumulative form gives 333.33 / 333.34 / 333.33.
|
||||
*/
|
||||
|
||||
export type DepreciableAsset = {
|
||||
acquisitionCost: number;
|
||||
salvageValue: number;
|
||||
usefulLifeMonths: number;
|
||||
accumulatedDepreciation: number;
|
||||
inServiceDate: string;
|
||||
depreciationMethod: string;
|
||||
status: string;
|
||||
/**
|
||||
* How many periods have already been charged for this asset — counted from
|
||||
* `depreciation_entries`, not inferred from the accumulated total.
|
||||
*
|
||||
* Counting rows rather than dividing `accumulated / nominal` matters: the
|
||||
* division would be ambiguous the moment a charge was rounded, which is
|
||||
* exactly the case this whole approach exists to handle.
|
||||
*/
|
||||
periodsCharged: number;
|
||||
};
|
||||
|
||||
export type DepreciationCharge = {
|
||||
/** What to charge this period. Zero means nothing is due. */
|
||||
amount: number;
|
||||
accumulatedAfter: number;
|
||||
/** True when this charge takes the asset to its cap. */
|
||||
fullyDepreciated: boolean;
|
||||
/** Why nothing is due, when amount is 0. */
|
||||
skipReason?: string;
|
||||
};
|
||||
|
||||
/** The most an asset can ever depreciate: cost less what it will be worth. */
|
||||
export function depreciableBase(asset: DepreciableAsset): number {
|
||||
return roundMoney(asset.acquisitionCost - asset.salvageValue);
|
||||
}
|
||||
|
||||
/** What the books say the asset is worth now. */
|
||||
export function netBookValue(asset: DepreciableAsset): number {
|
||||
return roundMoney(asset.acquisitionCost - asset.accumulatedDepreciation);
|
||||
}
|
||||
|
||||
/**
|
||||
* The charge for one period.
|
||||
*
|
||||
* `periodEnd` is the last day of the period being run. An asset that entered
|
||||
* service after that date is not yet depreciating.
|
||||
*/
|
||||
export function depreciationFor(
|
||||
asset: DepreciableAsset,
|
||||
periodEnd: string,
|
||||
): DepreciationCharge {
|
||||
const accumulated = roundMoney(asset.accumulatedDepreciation);
|
||||
|
||||
if (asset.depreciationMethod !== "STRAIGHT_LINE") {
|
||||
// Refused rather than silently treated as straight line — a wrong method
|
||||
// that quietly produces plausible numbers is worse than one that stops.
|
||||
return {
|
||||
amount: 0,
|
||||
accumulatedAfter: accumulated,
|
||||
fullyDepreciated: false,
|
||||
skipReason: `${asset.depreciationMethod} is not implemented`,
|
||||
};
|
||||
}
|
||||
|
||||
if (asset.status === "DISPOSED" || asset.status === "WRITTEN_OFF") {
|
||||
return {
|
||||
amount: 0,
|
||||
accumulatedAfter: accumulated,
|
||||
fullyDepreciated: false,
|
||||
skipReason: `asset is ${asset.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (asset.inServiceDate > periodEnd) {
|
||||
return {
|
||||
amount: 0,
|
||||
accumulatedAfter: accumulated,
|
||||
fullyDepreciated: false,
|
||||
skipReason: `not in service until ${asset.inServiceDate}`,
|
||||
};
|
||||
}
|
||||
|
||||
const base = depreciableBase(asset);
|
||||
const remaining = roundMoney(base - accumulated);
|
||||
if (remaining <= 0) {
|
||||
return {
|
||||
amount: 0,
|
||||
accumulatedAfter: accumulated,
|
||||
fullyDepreciated: true,
|
||||
skipReason: "fully depreciated",
|
||||
};
|
||||
}
|
||||
|
||||
// Where accumulated depreciation SHOULD stand once this period is charged.
|
||||
// Capped at the base so the final period lands exactly on it.
|
||||
const periodsAfter = Math.min(
|
||||
asset.periodsCharged + 1,
|
||||
asset.usefulLifeMonths,
|
||||
);
|
||||
const target = Math.min(
|
||||
roundMoney((base * periodsAfter) / asset.usefulLifeMonths),
|
||||
base,
|
||||
);
|
||||
|
||||
// The charge is the gap to that target, never more than what is left.
|
||||
const amount = roundMoney(Math.min(roundMoney(target - accumulated), remaining));
|
||||
if (amount <= 0) {
|
||||
return {
|
||||
amount: 0,
|
||||
accumulatedAfter: accumulated,
|
||||
fullyDepreciated: accumulated >= base,
|
||||
skipReason: "nothing further is due this period",
|
||||
};
|
||||
}
|
||||
|
||||
const accumulatedAfter = roundMoney(accumulated + amount);
|
||||
return {
|
||||
amount,
|
||||
accumulatedAfter,
|
||||
fullyDepreciated: accumulatedAfter >= base,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole schedule for an asset, month by month.
|
||||
*
|
||||
* Used by the UI to show what an asset will cost over its life, and by the
|
||||
* tests to assert that the periods sum to exactly the depreciable base.
|
||||
*/
|
||||
export function depreciationSchedule(
|
||||
asset: DepreciableAsset,
|
||||
): { month: number; amount: number; accumulated: number; netBookValue: number }[] {
|
||||
const base = depreciableBase(asset);
|
||||
const schedule: {
|
||||
month: number;
|
||||
amount: number;
|
||||
accumulated: number;
|
||||
netBookValue: number;
|
||||
}[] = [];
|
||||
|
||||
let accumulated = 0;
|
||||
for (let month = 1; month <= asset.usefulLifeMonths; month += 1) {
|
||||
// Same cumulative target the per-period charge uses, so the schedule shown
|
||||
// to a user and the amounts actually posted cannot diverge.
|
||||
const target = Math.min(
|
||||
roundMoney((base * month) / asset.usefulLifeMonths),
|
||||
base,
|
||||
);
|
||||
const amount = roundMoney(target - accumulated);
|
||||
if (amount <= 0) break;
|
||||
accumulated = roundMoney(accumulated + amount);
|
||||
schedule.push({
|
||||
month,
|
||||
amount,
|
||||
accumulated,
|
||||
netBookValue: roundMoney(asset.acquisitionCost - accumulated),
|
||||
});
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain or loss on disposal: what was received less what the books still carried.
|
||||
*
|
||||
* Positive is a gain, negative a loss. A scrap with no proceeds is simply a loss
|
||||
* equal to whatever value was left.
|
||||
*/
|
||||
export function disposalResult(
|
||||
asset: DepreciableAsset,
|
||||
proceeds: number,
|
||||
): { netBookValue: number; gainLoss: number } {
|
||||
const nbv = netBookValue(asset);
|
||||
return { netBookValue: nbv, gainLoss: roundMoney(proceeds - nbv) };
|
||||
}
|
||||
220
apps/finance-api/src/modules/assets/dto/assets.dto.ts
Normal file
220
apps/finance-api/src/modules/assets/dto/assets.dto.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { LocalizedNameDto } from "../../accounts/dto/account.dto";
|
||||
import {
|
||||
ASSET_STATUSES,
|
||||
DISPOSAL_TYPES,
|
||||
type AssetStatus,
|
||||
type DisposalType,
|
||||
} from "../entities/fixed-asset.entity";
|
||||
|
||||
const MAX_AMOUNT = 999_999_999_999.99;
|
||||
|
||||
export class CreateAssetCategoryDto {
|
||||
@ApiProperty({ example: "ROLLING-STOCK" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedNameDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedNameDto)
|
||||
name!: LocalizedNameDto;
|
||||
|
||||
@ApiProperty({ description: "Where the cost is carried (e.g. 1213)" })
|
||||
@IsUUID()
|
||||
assetAccountId!: string;
|
||||
|
||||
@ApiProperty({ description: "The contra account depreciation accrues in (1290)" })
|
||||
@IsUUID()
|
||||
accumulatedAccountId!: string;
|
||||
|
||||
@ApiProperty({ description: "Where the monthly charge is expensed (5400)" })
|
||||
@IsUUID()
|
||||
expenseAccountId!: string;
|
||||
|
||||
@ApiProperty({ example: 120, description: "Useful life in months" })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1200)
|
||||
defaultLifeMonths!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 0.05,
|
||||
description: "Fraction of cost expected to remain at end of life",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 4 })
|
||||
@Min(0)
|
||||
@Max(0.99)
|
||||
defaultSalvageRate?: number;
|
||||
}
|
||||
|
||||
export class CreateAssetDto {
|
||||
@ApiProperty()
|
||||
@IsUUID()
|
||||
assetCategoryId!: string;
|
||||
|
||||
@ApiProperty({ example: "FA-0001" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(48)
|
||||
assetCode!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(96)
|
||||
serialNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
costCenterId?: string;
|
||||
|
||||
@ApiProperty({ example: "2026-08-01" })
|
||||
@IsISO8601()
|
||||
acquisitionDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"When it started being used. Depreciation runs from here, not from acquisition. Defaults to the acquisition date.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
inServiceDate?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0.01)
|
||||
@Max(MAX_AMOUNT)
|
||||
acquisitionCost!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Defaults to the category's salvage rate applied to cost",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
salvageValue?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Defaults to the category's life" })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1200)
|
||||
usefulLifeMonths?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Post the acquisition to the ledger (Dr asset / Cr the funding account). Omit when the asset already reached the books through a supplier bill.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
fundingAccountId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Cutover only: depreciation already accumulated in the system Finance is replacing. The ledger side comes from the opening balance entry, so this must be used WITHOUT fundingAccountId or the cost would be posted twice.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
openingAccumulatedDepreciation?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Cutover only: how many periods that accumulated depreciation represents. Required alongside it — without a period count the asset never depreciates again.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1200)
|
||||
openingPeriodsCharged?: number;
|
||||
}
|
||||
|
||||
export class RunDepreciationDto {
|
||||
@ApiProperty({ description: "The period to charge" })
|
||||
@IsUUID()
|
||||
fiscalPeriodId!: string;
|
||||
}
|
||||
|
||||
export class DisposeAssetDto {
|
||||
@ApiProperty({ enum: DISPOSAL_TYPES })
|
||||
@IsIn(DISPOSAL_TYPES)
|
||||
disposalType!: DisposalType;
|
||||
|
||||
@ApiProperty({ example: "2026-08-31" })
|
||||
@IsISO8601()
|
||||
disposalDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 0, description: "What was received" })
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
@Max(MAX_AMOUNT)
|
||||
proceeds?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Where the proceeds landed. Required when proceeds are non-zero.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
proceedsAccountId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class AssetQueryDto {
|
||||
@ApiPropertyOptional({ enum: ASSET_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn(ASSET_STATUSES)
|
||||
status?: AssetStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user