Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { AuditsModule } from './audits/audits.module';
import { NotificationsModule } from './notifications/notifications.module';
import { GatewayModule } from './gateway/gateway.module';
import { AuditLogsModule } from './audit-logs/audit-logs.module';
import { DepreciationModule } from './depreciation/depreciation.module';
import { ReservationsModule } from './reservations/reservations.module';

@Module({
imports: [
Expand All @@ -50,6 +52,8 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module';
NotificationsModule,
GatewayModule,
AuditLogsModule,
DepreciationModule,
ReservationsModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
15 changes: 15 additions & 0 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '@nestjs/swagger';
import { AssetsService } from './assets.service';
import { AssetHistoryService } from './asset-history.service';
import { ReservationsService } from '../reservations/reservations.service';
import { AssetHistoryAction } from './entities/asset-history-event.entity';
import { BulkStatusDto } from './dto/bulk-status.dto';
import { BulkAssignDto } from './dto/bulk-assign.dto';
Expand All @@ -37,6 +38,7 @@ export class AssetsController {
constructor(
private readonly assetsService: AssetsService,
private readonly assetHistoryService: AssetHistoryService,
private readonly reservationsService: ReservationsService,
) {}

@Get()
Expand Down Expand Up @@ -162,4 +164,17 @@ export class AssetsController {
bulkDelete(@Body() dto: BulkDeleteDto, @GetUser() user: User) {
return this.assetsService.bulkDelete(dto, user.id);
}

@Get(':id/availability')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get asset availability (free/busy windows)' })
@ApiResponse({ status: 200, description: 'Availability data' })
getAvailability(
@Param('id') id: string,
@Query('from') from: string,
@Query('to') to: string,
) {
return this.reservationsService.getAvailability(id, from, to);
}
}
2 changes: 2 additions & 0 deletions backend/src/assets/assets.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AssetsController } from './assets.controller';
import { AssetsLifecycleModule } from './assets-lifecycle.module';
import { AssetHistoryModule } from './asset-history.module';
import { AuditLogsModule } from '../audit-logs/audit-logs.module';
import { ReservationsModule } from '../reservations/reservations.module';
import { Department } from '../departments/entities/department.entity';
import { User } from '../users/entities/user.entity';

Expand All @@ -15,6 +16,7 @@ import { User } from '../users/entities/user.entity';
AssetsLifecycleModule,
AssetHistoryModule,
AuditLogsModule,
ReservationsModule,
],
providers: [AssetsService],
controllers: [AssetsController],
Expand Down
12 changes: 12 additions & 0 deletions backend/src/assets/entities/asset.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ export class Asset {
@Column({ default: false })
isDigital: boolean;

@Column({ nullable: true })
depreciationMethod?: string;

@Column({ nullable: true })
usefulLifeMonths?: number;

@Column({ nullable: true })
salvageValue?: number;

@Column({ nullable: true, type: 'decimal', precision: 14, scale: 2 })
currentValue?: number;

@CreateDateColumn()
createdAt: Date;

Expand Down
35 changes: 35 additions & 0 deletions backend/src/depreciation/depreciation.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {

Check failure on line 1 in backend/src/depreciation/depreciation.controller.ts

View workflow job for this annotation

GitHub Actions / Backend Lint, Test & Build

'Query' is defined but never used

Check failure on line 1 in backend/src/depreciation/depreciation.controller.ts

View workflow job for this annotation

GitHub Actions / Backend (NestJS)

'Query' is defined but never used
Controller,
Get,
Param,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
} from '@nestjs/swagger';
import { DepreciationService } from './depreciation.service';

@ApiTags('depreciation')
@ApiBearerAuth('JWT-auth')
@Controller()
export class DepreciationController {
constructor(private readonly depreciationService: DepreciationService) {}

@Get('assets/:id/depreciation')
@ApiOperation({ summary: 'Get depreciation schedule and book value for an asset' })
@ApiResponse({ status: 200, description: 'Asset depreciation details' })
@ApiResponse({ status: 404, description: 'Asset not found' })
getAssetDepreciation(@Param('id') id: string) {
return this.depreciationService.getAssetDepreciation(id);
}

@Get('reports/depreciation')
@ApiOperation({ summary: 'Get org-wide depreciation report' })
@ApiResponse({ status: 200, description: 'Depreciation report' })
getDepreciationReport() {
return this.depreciationService.getDepreciationReport();
}
}
13 changes: 13 additions & 0 deletions backend/src/depreciation/depreciation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Asset } from '../assets/entities/asset.entity';
import { DepreciationService } from './depreciation.service';
import { DepreciationController } from './depreciation.controller';

@Module({
imports: [TypeOrmModule.forFeature([Asset])],
providers: [DepreciationService],
controllers: [DepreciationController],
exports: [DepreciationService],
})
export class DepreciationModule {}
195 changes: 195 additions & 0 deletions backend/src/depreciation/depreciation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Asset } from '../assets/entities/asset.entity';
import { DepreciationScheduleEntryDto } from './dto/depreciation-schedule-entry.dto';
import { AssetDepreciationResponseDto } from './dto/asset-depreciation-response.dto';
import { DepreciationReportDto } from './dto/depreciation-report.dto';

@Injectable()
export class DepreciationService {
constructor(
@InjectRepository(Asset)
private readonly assetRepo: Repository<Asset>,
) {}

private async findAssetOrThrow(id: string): Promise<Asset> {
const asset = await this.assetRepo.findOne({
where: { id },
relations: ['category'],
});
if (!asset) throw new NotFoundException(`Asset ${id} not found`);
return asset;
}

getSchedule(asset: Asset): DepreciationScheduleEntryDto[] {
const method = asset.depreciationMethod;
if (!method || method === 'NONE' || !asset.usefulLifeMonths || !asset.purchaseDate) {
return [];
}

const cost = Number(asset.purchaseCost) || 0;
const salvage = Number(asset.salvageValue) || 0;
const usefulLife = asset.usefulLifeMonths;
const purchaseDate = new Date(asset.purchaseDate);

const depreciableAmount = cost - salvage;
if (depreciableAmount <= 0 || usefulLife <= 0) {
return [];
}

const purchaseDay = purchaseDate.getDate();
const daysInMonth = new Date(
purchaseDate.getFullYear(),
purchaseDate.getMonth() + 1,
0,
).getDate();
const firstMonthFraction = 1 - (purchaseDay - 1) / daysInMonth;

const schedule: DepreciationScheduleEntryDto[] = [];
let openingValue = cost;

if (method === 'STRAIGHT_LINE') {
const monthlyDepreciation = depreciableAmount / usefulLife;
for (let period = 1; period <= usefulLife; period++) {
const isPartialFirst = period === 1 && firstMonthFraction < 1;
const depreciation = isPartialFirst
? monthlyDepreciation * firstMonthFraction
: monthlyDepreciation;

const closingValue = Math.max(openingValue - depreciation, salvage);
const actualDepreciation = openingValue - closingValue;

schedule.push({
period,
openingValue: round(openingValue),
depreciation: round(actualDepreciation),
closingValue: round(closingValue),
});

openingValue = closingValue;
if (openingValue <= salvage) break;
}
} else if (method === 'DECLINING_BALANCE') {
const rate = 2 / usefulLife;
for (let period = 1; period <= usefulLife; period++) {
const isPartialFirst = period === 1 && firstMonthFraction < 1;
let depreciation = openingValue * rate;
if (isPartialFirst) {
depreciation *= firstMonthFraction;
}

const closingValue = Math.max(openingValue - depreciation, salvage);
const actualDepreciation = openingValue - closingValue;

schedule.push({
period,
openingValue: round(openingValue),
depreciation: round(actualDepreciation),
closingValue: round(closingValue),
});

openingValue = closingValue;
if (openingValue <= salvage) break;
}
}

return schedule;
}

getCurrentBookValue(asset: Asset): number {
if (!asset.depreciationMethod || asset.depreciationMethod === 'NONE') {
return Number(asset.purchaseCost) || 0;
}

const schedule = this.getSchedule(asset);
if (schedule.length === 0) {
return Number(asset.purchaseCost) || 0;
}

return schedule[schedule.length - 1].closingValue;
}

async getAssetDepreciation(id: string): Promise<AssetDepreciationResponseDto> {
const asset = await this.findAssetOrThrow(id);
return {
assetId: asset.id,
assetTag: asset.assetTag,
name: asset.name,
purchaseCost: Number(asset.purchaseCost) || 0,
purchaseDate: asset.purchaseDate ?? null,
depreciationMethod: asset.depreciationMethod ?? null,
usefulLifeMonths: asset.usefulLifeMonths ?? null,
salvageValue: asset.salvageValue != null ? Number(asset.salvageValue) : null,
currentBookValue: this.getCurrentBookValue(asset),
schedule: this.getSchedule(asset),
};
}

async getDepreciationReport(): Promise<DepreciationReportDto> {
const assets = await this.assetRepo.find({
where: { depreciationMethod: 'STRAIGHT_LINE' },
});
const assetsDB = await this.assetRepo.find({
where: { depreciationMethod: 'DECLINING_BALANCE' },
});

const allDepreciatingAssets = [...assets, ...assetsDB];

let totalBookValue = 0;
let totalOriginalCost = 0;
let totalAccumulatedDepreciation = 0;
let monthlyDepreciationExpense = 0;

const categoryMap = new Map<
string,
{ categoryName: string | null; bookValue: number; assetCount: number }
>();

for (const asset of allDepreciatingAssets) {
const bookValue = this.getCurrentBookValue(asset);
const cost = Number(asset.purchaseCost) || 0;
const accumulated = cost - bookValue;

totalBookValue += bookValue;
totalOriginalCost += cost;
totalAccumulatedDepreciation += accumulated;

const schedule = this.getSchedule(asset);
if (schedule.length > 0) {
monthlyDepreciationExpense += schedule[0].depreciation;
}

const catId = asset.categoryId ?? 'uncategorized';
const existing = categoryMap.get(catId);
if (existing) {
existing.bookValue += bookValue;
existing.assetCount += 1;
} else {
categoryMap.set(catId, {
categoryName: asset.category?.name ?? null,
bookValue,
assetCount: 1,
});
}
}

return {
totalBookValue: round(totalBookValue),
totalOriginalCost: round(totalOriginalCost),
totalAccumulatedDepreciation: round(totalAccumulatedDepreciation),
monthlyDepreciationExpense: round(monthlyDepreciationExpense),
assetCount: allDepreciatingAssets.length,
byCategory: Array.from(categoryMap.entries()).map(([categoryId, data]) => ({
categoryId,
categoryName: data.categoryName,
bookValue: round(data.bookValue),
assetCount: data.assetCount,
})),
};
}
}

function round(value: number): number {
return Math.round(value * 100) / 100;
}
14 changes: 14 additions & 0 deletions backend/src/depreciation/dto/asset-depreciation-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { DepreciationScheduleEntryDto } from './depreciation-schedule-entry.dto';

export class AssetDepreciationResponseDto {
assetId: string;
assetTag: string;
name: string;
purchaseCost: number;
purchaseDate: Date | null;
depreciationMethod: string | null;
usefulLifeMonths: number | null;
salvageValue: number | null;
currentBookValue: number;
schedule: DepreciationScheduleEntryDto[];
}
13 changes: 13 additions & 0 deletions backend/src/depreciation/dto/depreciation-report.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class DepreciationReportDto {
totalBookValue: number;
totalOriginalCost: number;
totalAccumulatedDepreciation: number;
monthlyDepreciationExpense: number;
assetCount: number;
byCategory: Array<{
categoryId: string | null;
categoryName: string | null;
bookValue: number;
assetCount: number;
}>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class DepreciationScheduleEntryDto {
period: number;
openingValue: number;
depreciation: number;
closingValue: number;
}
Loading
Loading