feat(server): add budgeting CRUD and budget vs actual report
Implement budget management with CRUD operations and a Budget vs Actual financial report that supports both Profit & Loss and Balance Sheet budget types. Budget CRUD: - Budgets and BudgetEntries models with Objection.js - Create, edit, delete, activate, close, bulk-delete operations - Budget validators (date range, account existence, status transitions) - Event-driven architecture with lifecycle hooks - CASL permission guards and Swagger API docs Budget vs Actual Report: - Dual-mode report driven by budget.budgetType (P&L or BS) - Reuses existing P&L and Balance Sheet schemas via import - Computes budget, actual, variance, and variance percentage per account - Supports date period breakdowns (monthly, quarterly, annual) - P&L mode: period activity actuals from accounts_transactions - BS mode: cumulative closing balance actuals - Follows FinancialStatements mixin architecture (FinancialSheet, FinancialSheetStructure, FinancialEvaluateEquation, etc.) - Fully typed with no @ts-nocheck directives - Named exports only (no default exports)
This commit is contained in:
@@ -773,5 +773,20 @@ export const events = {
|
||||
onVendorTransactionsViewed: 'onVendorTransactionsViewed',
|
||||
onSalesByItemViewed: 'onSalesByItemViewed',
|
||||
onPurchasesByItemViewed: 'onPurchasesByItemViewed',
|
||||
onBudgetVsActualViewed: 'onBudgetVsActualViewed',
|
||||
},
|
||||
|
||||
// Budgets
|
||||
budgets: {
|
||||
onCreating: 'onBudgetCreating',
|
||||
onCreated: 'onBudgetCreated',
|
||||
onEditing: 'onBudgetEditing',
|
||||
onEdited: 'onBudgetEdited',
|
||||
onDeleting: 'onBudgetDeleting',
|
||||
onDeleted: 'onBudgetDeleted',
|
||||
onActivating: 'onBudgetActivating',
|
||||
onActivated: 'onBudgetActivated',
|
||||
onClosing: 'onBudgetClosing',
|
||||
onClosed: 'onBudgetClosed',
|
||||
},
|
||||
};
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.createTable('budgets', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').nullable();
|
||||
table.date('start_date').notNullable();
|
||||
table.date('end_date').notNullable();
|
||||
table
|
||||
.enu('budget_type', ['profit_and_loss', 'balance_sheet'])
|
||||
.notNullable()
|
||||
.defaultTo('profit_and_loss');
|
||||
table
|
||||
.enu('period_type', ['monthly', 'quarterly', 'annual'])
|
||||
.notNullable()
|
||||
.defaultTo('monthly');
|
||||
table
|
||||
.enu('status', ['draft', 'active', 'closed'])
|
||||
.notNullable()
|
||||
.defaultTo('draft');
|
||||
table.timestamps();
|
||||
})
|
||||
.createTable('budget_entries', (table) => {
|
||||
table.increments('id').primary();
|
||||
table
|
||||
.integer('budget_id')
|
||||
.unsigned()
|
||||
.references('id')
|
||||
.inTable('budgets')
|
||||
.onDelete('CASCADE');
|
||||
table
|
||||
.integer('account_id')
|
||||
.unsigned()
|
||||
.references('id')
|
||||
.inTable('accounts')
|
||||
.onDelete('CASCADE');
|
||||
table.decimal('amount', 19, 4).notNullable().defaultTo(0);
|
||||
table.date('period_date').notNullable();
|
||||
table.timestamps();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.dropTableIfExists('budget_entries')
|
||||
.dropTableIfExists('budgets');
|
||||
};
|
||||
@@ -46,7 +46,6 @@ export class AccountsApplication {
|
||||
|
||||
/**
|
||||
* Creates a new account.
|
||||
* @param {number} tenantId
|
||||
* @param {IAccountCreateDTO} accountDTO
|
||||
* @returns {Promise<IAccount>}
|
||||
*/
|
||||
@@ -59,7 +58,6 @@ export class AccountsApplication {
|
||||
|
||||
/**
|
||||
* Deletes the given account.
|
||||
* @param {number} tenantId
|
||||
* @param {number} accountId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
@@ -100,6 +100,7 @@ import { ContactsModule } from '../Contacts/Contacts.module';
|
||||
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
|
||||
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
|
||||
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
|
||||
import { BudgetsModule } from '../Budgeting/Budgets.module';
|
||||
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
||||
import { BillLandedCostsModule } from '../BillLandedCosts/BillLandedCosts.module';
|
||||
import { SocketModule } from '../Socket/Socket.module';
|
||||
@@ -258,6 +259,7 @@ import { AppThrottleModule } from './AppThrottle.module';
|
||||
ContactsModule,
|
||||
SocketModule,
|
||||
ExchangeRatesModule,
|
||||
BudgetsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
Get,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
HttpCode,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BudgetsApplication } from './BudgetsApplication.service';
|
||||
import { CreateBudgetDto, EditBudgetDto } from './dtos/CreateBudget.dto';
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { BudgetResponseDto } from './dtos/BudgetResponse.dto';
|
||||
import { GetBudgetsQueryDto } from './dtos/GetBudgetsQuery.dto';
|
||||
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
|
||||
import {
|
||||
BulkDeleteDto,
|
||||
ValidateBulkDeleteResponseDto,
|
||||
} from '@/common/dtos/BulkDelete.dto';
|
||||
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
|
||||
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
|
||||
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
|
||||
import { AbilitySubject } from '@/modules/Roles/Roles.types';
|
||||
import { BudgetAction } from './types/Budgets.types';
|
||||
|
||||
@Controller('budgets')
|
||||
@ApiTags('Budgets')
|
||||
@ApiExtraModels(BudgetResponseDto)
|
||||
@ApiExtraModels(ValidateBulkDeleteResponseDto)
|
||||
@ApiCommonHeaders()
|
||||
@UseGuards(AuthorizationGuard, PermissionGuard)
|
||||
export class BudgetsController {
|
||||
constructor(private readonly budgetsApplication: BudgetsApplication) {}
|
||||
|
||||
@Post('validate-bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Validates which budgets can be deleted and returns counts of deletable and non-deletable budgets.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
'Validation completed. Returns counts and IDs of deletable and non-deletable budgets.',
|
||||
schema: {
|
||||
$ref: getSchemaPath(ValidateBulkDeleteResponseDto),
|
||||
},
|
||||
})
|
||||
async validateBulkDeleteBudgets(
|
||||
@Body() bulkDeleteDto: BulkDeleteDto,
|
||||
): Promise<ValidateBulkDeleteResponseDto> {
|
||||
return this.budgetsApplication.validateBulkDeleteBudgets(
|
||||
bulkDeleteDto.ids,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Deletes multiple budgets in bulk.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budgets have been successfully deleted.',
|
||||
})
|
||||
async bulkDeleteBudgets(@Body() bulkDeleteDto: BulkDeleteDto) {
|
||||
return this.budgetsApplication.bulkDeleteBudgets(bulkDeleteDto.ids, {
|
||||
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(BudgetAction.Create, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Create a new budget.' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'The budget has been successfully created.',
|
||||
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||
})
|
||||
async createBudget(@Body() budgetDTO: CreateBudgetDto) {
|
||||
return this.budgetsApplication.createBudget(budgetDTO);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Edit the given budget.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budget has been successfully updated.',
|
||||
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: Number,
|
||||
description: 'The budget id',
|
||||
})
|
||||
async editBudget(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() budgetDTO: EditBudgetDto,
|
||||
) {
|
||||
return this.budgetsApplication.editBudget(id, budgetDTO);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission(BudgetAction.Delete, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Delete the given budget.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budget has been successfully deleted.',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: Number,
|
||||
description: 'The budget id',
|
||||
})
|
||||
async deleteBudget(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.budgetsApplication.deleteBudget(id);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@HttpCode(200)
|
||||
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Activate the given budget.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budget has been successfully activated.',
|
||||
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: Number,
|
||||
description: 'The budget id',
|
||||
})
|
||||
async activateBudget(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.budgetsApplication.activateBudget(id);
|
||||
}
|
||||
|
||||
@Post(':id/close')
|
||||
@HttpCode(200)
|
||||
@RequirePermission(BudgetAction.Edit, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Close the given budget.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budget has been successfully closed.',
|
||||
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: Number,
|
||||
description: 'The budget id',
|
||||
})
|
||||
async closeBudget(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.budgetsApplication.closeBudget(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission(BudgetAction.View, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Retrieves the budget details.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budget details have been successfully retrieved.',
|
||||
schema: { $ref: getSchemaPath(BudgetResponseDto) },
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'The budget not found.' })
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
required: true,
|
||||
type: Number,
|
||||
description: 'The budget id',
|
||||
})
|
||||
async getBudget(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.budgetsApplication.getBudget(id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(BudgetAction.View, AbilitySubject.Budget)
|
||||
@ApiOperation({ summary: 'Retrieves the budgets list.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The budgets list has been successfully retrieved.',
|
||||
})
|
||||
async getBudgets(@Query() filterDTO: GetBudgetsQueryDto) {
|
||||
return this.budgetsApplication.getBudgets(filterDTO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
|
||||
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
|
||||
import { BudgetsController } from './Budgets.controller';
|
||||
import { BudgetsApplication } from './BudgetsApplication.service';
|
||||
import { CreateBudgetService } from './commands/CreateBudget.service';
|
||||
import { EditBudgetService } from './commands/EditBudget.service';
|
||||
import { DeleteBudgetService } from './commands/DeleteBudget.service';
|
||||
import { ActivateBudgetService } from './commands/ActivateBudget.service';
|
||||
import { CloseBudgetService } from './commands/CloseBudget.service';
|
||||
import { BulkDeleteBudgetsService } from './commands/BulkDeleteBudgets.service';
|
||||
import { ValidateBulkDeleteBudgetsService } from './commands/ValidateBulkDeleteBudgets.service';
|
||||
import { GetBudgetService } from './queries/GetBudget.service';
|
||||
import { GetBudgetsService } from './queries/GetBudgets.service';
|
||||
import { BudgetValidators } from './commands/BudgetValidators.service';
|
||||
import { BudgetRepository } from './repositories/Budget.repository';
|
||||
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
|
||||
|
||||
@Module({
|
||||
imports: [TenancyDatabaseModule, DynamicListModule],
|
||||
controllers: [BudgetsController],
|
||||
providers: [
|
||||
BudgetsApplication,
|
||||
CreateBudgetService,
|
||||
EditBudgetService,
|
||||
DeleteBudgetService,
|
||||
ActivateBudgetService,
|
||||
CloseBudgetService,
|
||||
BulkDeleteBudgetsService,
|
||||
ValidateBulkDeleteBudgetsService,
|
||||
GetBudgetService,
|
||||
GetBudgetsService,
|
||||
BudgetValidators,
|
||||
BudgetRepository,
|
||||
TransformerInjectable,
|
||||
],
|
||||
exports: [BudgetRepository],
|
||||
})
|
||||
export class BudgetsModule {}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateBudgetService } from './commands/CreateBudget.service';
|
||||
import { EditBudgetService } from './commands/EditBudget.service';
|
||||
import { DeleteBudgetService } from './commands/DeleteBudget.service';
|
||||
import { ActivateBudgetService } from './commands/ActivateBudget.service';
|
||||
import { CloseBudgetService } from './commands/CloseBudget.service';
|
||||
import { BulkDeleteBudgetsService } from './commands/BulkDeleteBudgets.service';
|
||||
import { ValidateBulkDeleteBudgetsService } from './commands/ValidateBulkDeleteBudgets.service';
|
||||
import { GetBudgetService } from './queries/GetBudget.service';
|
||||
import { GetBudgetsService } from './queries/GetBudgets.service';
|
||||
import { CreateBudgetDto, EditBudgetDto } from './dtos/CreateBudget.dto';
|
||||
import { GetBudgetsQueryDto } from './dtos/GetBudgetsQuery.dto';
|
||||
import { Budget } from './models/Budget.model';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetsApplication {
|
||||
constructor(
|
||||
private createBudgetService: CreateBudgetService,
|
||||
private editBudgetService: EditBudgetService,
|
||||
private deleteBudgetService: DeleteBudgetService,
|
||||
private activateBudgetService: ActivateBudgetService,
|
||||
private closeBudgetService: CloseBudgetService,
|
||||
private getBudgetService: GetBudgetService,
|
||||
private getBudgetsService: GetBudgetsService,
|
||||
private bulkDeleteBudgetsService: BulkDeleteBudgetsService,
|
||||
private validateBulkDeleteBudgetsService: ValidateBulkDeleteBudgetsService,
|
||||
) {}
|
||||
|
||||
public createBudget = (budgetDTO: CreateBudgetDto): Promise<Budget> => {
|
||||
return this.createBudgetService.createBudget(budgetDTO);
|
||||
};
|
||||
|
||||
public editBudget = (
|
||||
budgetId: number,
|
||||
budgetDTO: EditBudgetDto,
|
||||
): Promise<Budget> => {
|
||||
return this.editBudgetService.editBudget(budgetId, budgetDTO);
|
||||
};
|
||||
|
||||
public deleteBudget = (budgetId: number): Promise<void> => {
|
||||
return this.deleteBudgetService.deleteBudget(budgetId);
|
||||
};
|
||||
|
||||
public activateBudget = (budgetId: number): Promise<Budget> => {
|
||||
return this.activateBudgetService.activateBudget(budgetId);
|
||||
};
|
||||
|
||||
public closeBudget = (budgetId: number): Promise<Budget> => {
|
||||
return this.closeBudgetService.closeBudget(budgetId);
|
||||
};
|
||||
|
||||
public getBudget = (budgetId: number): Promise<Budget> => {
|
||||
return this.getBudgetService.getBudget(budgetId);
|
||||
};
|
||||
|
||||
public getBudgets = (filterDTO: GetBudgetsQueryDto) => {
|
||||
return this.getBudgetsService.getBudgets(filterDTO);
|
||||
};
|
||||
|
||||
public bulkDeleteBudgets = (
|
||||
budgetIds: number[],
|
||||
options?: { skipUndeletable?: boolean },
|
||||
) => {
|
||||
return this.bulkDeleteBudgetsService.bulkDeleteBudgets(
|
||||
budgetIds,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
public validateBulkDeleteBudgets = (budgetIds: number[]) => {
|
||||
return this.validateBulkDeleteBudgetsService.validateBulkDeleteBudgets(
|
||||
budgetIds,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import {
|
||||
IBudgetActivatingPayload,
|
||||
IBudgetActivatedPayload,
|
||||
} from '../types/Budgets.types';
|
||||
import { events } from '@/common/events/events';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class ActivateBudgetService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly validator: BudgetValidators,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public activateBudget = async (budgetId: number): Promise<Budget> => {
|
||||
const budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(budgetId);
|
||||
|
||||
if (!budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
this.validator.validateCanActivate(budget);
|
||||
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(events.budgets.onActivating, {
|
||||
budgetId,
|
||||
trx,
|
||||
} as IBudgetActivatingPayload);
|
||||
|
||||
const updatedBudget = await this.budgetModel()
|
||||
.query(trx)
|
||||
.patchAndFetchById(budgetId, {
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await this.eventPublisher.emitAsync(events.budgets.onActivated, {
|
||||
budget: updatedBudget,
|
||||
trx,
|
||||
} as IBudgetActivatedPayload);
|
||||
|
||||
return updatedBudget;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { difference } from 'lodash';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { CreateBudgetDto, EditBudgetDto } from '../dtos/CreateBudget.dto';
|
||||
import { ERRORS } from '../constants';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetValidators {
|
||||
constructor(
|
||||
@Inject(Account.name)
|
||||
private readonly accountModel: TenantModelProxy<typeof Account>,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public validateStartDateBeforeEndDate(dto: CreateBudgetDto | EditBudgetDto) {
|
||||
if (new Date(dto.startDate) >= new Date(dto.endDate)) {
|
||||
throw new ServiceError(ERRORS.START_DATE_AFTER_END_DATE);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateEntriesAccountsExist(
|
||||
dto: CreateBudgetDto | EditBudgetDto,
|
||||
) {
|
||||
const accountsIds = dto.entries.map((e) => e.accountId);
|
||||
const accounts = await this.accountModel()
|
||||
.query()
|
||||
.whereIn('id', accountsIds);
|
||||
|
||||
const storedAccountsIds = accounts.map((a) => a.id);
|
||||
const missingIds = difference(accountsIds, storedAccountsIds);
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
throw new ServiceError(ERRORS.ENTRIES_ACCOUNTS_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
public validateNotActive(budget: Budget) {
|
||||
if (budget.isActive) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_EDIT);
|
||||
}
|
||||
}
|
||||
|
||||
public validateNotClosed(budget: Budget) {
|
||||
if (budget.isClosed) {
|
||||
throw new ServiceError(ERRORS.BUDGET_CLOSED_CANNOT_EDIT);
|
||||
}
|
||||
}
|
||||
|
||||
public validateCanDelete(budget: Budget) {
|
||||
if (budget.isActive) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_DELETE);
|
||||
}
|
||||
if (budget.isClosed) {
|
||||
throw new ServiceError(ERRORS.BUDGET_CLOSED_CANNOT_DELETE);
|
||||
}
|
||||
}
|
||||
|
||||
public validateCanActivate(budget: Budget) {
|
||||
if (budget.isActive) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ALREADY_ACTIVE);
|
||||
}
|
||||
if (budget.isClosed) {
|
||||
throw new ServiceError(ERRORS.BUDGET_DRAFT_ONLY_ACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
public validateCanClose(budget: Budget) {
|
||||
if (budget.isClosed) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ALREADY_CLOSED);
|
||||
}
|
||||
if (budget.isDraft) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ACTIVE_ONLY_CLOSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class BulkDeleteBudgetsService {
|
||||
constructor(
|
||||
private readonly validator: BudgetValidators,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public bulkDeleteBudgets = async (
|
||||
budgetIds: number[],
|
||||
options?: { skipUndeletable?: boolean },
|
||||
): Promise<void> => {
|
||||
const budgets = await this.budgetModel()
|
||||
.query()
|
||||
.whereIn('id', budgetIds);
|
||||
|
||||
const deletableBudgets = budgets.filter((budget) => {
|
||||
try {
|
||||
this.validator.validateCanDelete(budget);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const deletableIds = deletableBudgets.map((b) => b.id);
|
||||
|
||||
if (!options?.skipUndeletable && deletableIds.length < budgetIds.length) {
|
||||
throw new ServiceError(ERRORS.BUDGET_ACTIVE_CANNOT_DELETE);
|
||||
}
|
||||
|
||||
await this.budgetModel()
|
||||
.query()
|
||||
.whereIn('id', deletableIds)
|
||||
.delete();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import {
|
||||
IBudgetClosingPayload,
|
||||
IBudgetClosedPayload,
|
||||
} from '../types/Budgets.types';
|
||||
import { events } from '@/common/events/events';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class CloseBudgetService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly validator: BudgetValidators,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public closeBudget = async (budgetId: number): Promise<Budget> => {
|
||||
const budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(budgetId);
|
||||
|
||||
if (!budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
this.validator.validateCanClose(budget);
|
||||
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(events.budgets.onClosing, {
|
||||
budgetId,
|
||||
trx,
|
||||
} as IBudgetClosingPayload);
|
||||
|
||||
const updatedBudget = await this.budgetModel()
|
||||
.query(trx)
|
||||
.patchAndFetchById(budgetId, {
|
||||
status: 'closed',
|
||||
});
|
||||
|
||||
await this.eventPublisher.emitAsync(events.budgets.onClosed, {
|
||||
budget: updatedBudget,
|
||||
trx,
|
||||
} as IBudgetClosedPayload);
|
||||
|
||||
return updatedBudget;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { CreateBudgetDto } from '../dtos/CreateBudget.dto';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import { IBudgetCreatedPayload } from '../types/Budgets.types';
|
||||
import { events } from '@/common/events/events';
|
||||
|
||||
@Injectable()
|
||||
export class CreateBudgetService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly validator: BudgetValidators,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public createBudget = async (
|
||||
budgetDTO: CreateBudgetDto,
|
||||
trx?: Knex.Transaction,
|
||||
): Promise<Budget> => {
|
||||
this.validator.validateStartDateBeforeEndDate(budgetDTO);
|
||||
await this.validator.validateEntriesAccountsExist(budgetDTO);
|
||||
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
const budgetObj = {
|
||||
name: budgetDTO.name,
|
||||
description: budgetDTO.description || null,
|
||||
startDate: budgetDTO.startDate,
|
||||
endDate: budgetDTO.endDate,
|
||||
budgetType: budgetDTO.budgetType,
|
||||
periodType: budgetDTO.periodType,
|
||||
status: 'draft',
|
||||
entries: budgetDTO.entries.map((entry) => ({
|
||||
accountId: entry.accountId,
|
||||
amount: entry.amount,
|
||||
periodDate: entry.periodDate,
|
||||
})),
|
||||
};
|
||||
|
||||
const budget = await this.budgetModel()
|
||||
.query(trx)
|
||||
.upsertGraph(budgetObj, { relate: true });
|
||||
|
||||
await this.eventPublisher.emitAsync(events.budgets.onCreated, {
|
||||
budget,
|
||||
trx,
|
||||
} as IBudgetCreatedPayload);
|
||||
|
||||
return budget;
|
||||
}, trx);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import {
|
||||
IBudgetDeletingPayload,
|
||||
IBudgetDeletedPayload,
|
||||
} from '../types/Budgets.types';
|
||||
import { events } from '@/common/events/events';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteBudgetService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly validator: BudgetValidators,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public deleteBudget = async (budgetId: number): Promise<void> => {
|
||||
const budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(budgetId);
|
||||
|
||||
if (!budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
this.validator.validateCanDelete(budget);
|
||||
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(events.budgets.onDeleting, {
|
||||
budgetId,
|
||||
trx,
|
||||
} as IBudgetDeletingPayload);
|
||||
|
||||
await this.budgetModel().query(trx).findById(budgetId).delete();
|
||||
|
||||
await this.eventPublisher.emitAsync(events.budgets.onDeleted, {
|
||||
budgetId,
|
||||
trx,
|
||||
} as IBudgetDeletedPayload);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { EditBudgetDto } from '../dtos/CreateBudget.dto';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
import {
|
||||
IBudgetEditingPayload,
|
||||
IBudgetEditedPayload,
|
||||
} from '../types/Budgets.types';
|
||||
import { events } from '@/common/events/events';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class EditBudgetService {
|
||||
constructor(
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly validator: BudgetValidators,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public editBudget = async (
|
||||
budgetId: number,
|
||||
budgetDTO: EditBudgetDto,
|
||||
): Promise<Budget> => {
|
||||
const budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(budgetId);
|
||||
|
||||
if (!budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
this.validator.validateNotActive(budget);
|
||||
this.validator.validateNotClosed(budget);
|
||||
this.validator.validateStartDateBeforeEndDate(budgetDTO);
|
||||
await this.validator.validateEntriesAccountsExist(budgetDTO);
|
||||
|
||||
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(events.budgets.onEditing, {
|
||||
budgetId,
|
||||
budgetDTO,
|
||||
trx,
|
||||
} as IBudgetEditingPayload);
|
||||
|
||||
const updatedBudget = await this.budgetModel()
|
||||
.query(trx)
|
||||
.upsertGraph(
|
||||
{
|
||||
id: budgetId,
|
||||
name: budgetDTO.name,
|
||||
description: budgetDTO.description || null,
|
||||
startDate: budgetDTO.startDate,
|
||||
endDate: budgetDTO.endDate,
|
||||
budgetType: budgetDTO.budgetType,
|
||||
periodType: budgetDTO.periodType,
|
||||
entries: budgetDTO.entries.map((entry) => ({
|
||||
accountId: entry.accountId,
|
||||
amount: entry.amount,
|
||||
periodDate: entry.periodDate,
|
||||
...(entry['id'] ? { id: entry['id'] } : {}),
|
||||
})),
|
||||
},
|
||||
{ relate: true, unrelate: true },
|
||||
);
|
||||
|
||||
await this.eventPublisher.emitAsync(events.budgets.onEdited, {
|
||||
budget: updatedBudget,
|
||||
budgetDTO,
|
||||
trx,
|
||||
} as IBudgetEditedPayload);
|
||||
|
||||
return updatedBudget;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { BudgetValidators } from './BudgetValidators.service';
|
||||
|
||||
@Injectable()
|
||||
export class ValidateBulkDeleteBudgetsService {
|
||||
constructor(
|
||||
private readonly validator: BudgetValidators,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public validateBulkDeleteBudgets = async (budgetIds: number[]) => {
|
||||
const budgets = await this.budgetModel()
|
||||
.query()
|
||||
.whereIn('id', budgetIds);
|
||||
|
||||
const deletableIds: number[] = [];
|
||||
const nonDeletableIds: number[] = [];
|
||||
|
||||
budgets.forEach((budget) => {
|
||||
try {
|
||||
this.validator.validateCanDelete(budget);
|
||||
deletableIds.push(budget.id);
|
||||
} catch {
|
||||
nonDeletableIds.push(budget.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
deletableIds,
|
||||
nonDeletableIds,
|
||||
deletableCount: deletableIds.length,
|
||||
nonDeletableCount: nonDeletableIds.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const ERRORS = {
|
||||
BUDGET_NOT_FOUND: 'BUDGET_NOT_FOUND',
|
||||
BUDGET_ACTIVE_CANNOT_EDIT: 'BUDGET_ACTIVE_CANNOT_EDIT',
|
||||
BUDGET_CLOSED_CANNOT_EDIT: 'BUDGET_CLOSED_CANNOT_EDIT',
|
||||
BUDGET_ACTIVE_CANNOT_DELETE: 'BUDGET_ACTIVE_CANNOT_DELETE',
|
||||
BUDGET_CLOSED_CANNOT_DELETE: 'BUDGET_CLOSED_CANNOT_DELETE',
|
||||
BUDGET_ALREADY_ACTIVE: 'BUDGET_ALREADY_ACTIVE',
|
||||
BUDGET_ALREADY_CLOSED: 'BUDGET_ALREADY_CLOSED',
|
||||
BUDGET_DRAFT_ONLY_ACTIVATE: 'BUDGET_DRAFT_ONLY_ACTIVATE',
|
||||
BUDGET_ACTIVE_ONLY_CLOSE: 'BUDGET_ACTIVE_ONLY_CLOSE',
|
||||
ENTRIES_ACCOUNTS_NOT_FOUND: 'ENTRIES_ACCOUNTS_NOT_FOUND',
|
||||
START_DATE_AFTER_END_DATE: 'START_DATE_AFTER_END_DATE',
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ToNumber } from '@/common/decorators/Validators';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BudgetEntryDto {
|
||||
@ApiProperty({ description: 'Account ID', example: 1 })
|
||||
@IsNotEmpty()
|
||||
@ToNumber()
|
||||
@IsInt()
|
||||
accountId: number;
|
||||
|
||||
@ApiProperty({ description: 'Budget amount for the period', example: 5000.0 })
|
||||
@ToNumber()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Period date (first day of the period)',
|
||||
example: '2026-01-01',
|
||||
})
|
||||
@IsString()
|
||||
periodDate: string;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
class BudgetEntryResponseDto {
|
||||
@ApiProperty({ description: 'Entry ID', example: 1 })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Account ID', example: 1 })
|
||||
accountId: number;
|
||||
|
||||
@ApiProperty({ description: 'Budget amount', example: 5000.0 })
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: 'Period date', example: '2026-01-01' })
|
||||
periodDate: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Account details' })
|
||||
account?: any;
|
||||
}
|
||||
|
||||
export class BudgetResponseDto {
|
||||
@ApiProperty({ description: 'Budget ID', example: 1 })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: 'Budget name', example: '2026 Annual Budget' })
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Budget description',
|
||||
example: 'Annual operating budget',
|
||||
})
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'Start date', example: '2026-01-01' })
|
||||
startDate: string;
|
||||
|
||||
@ApiProperty({ description: 'End date', example: '2026-12-31' })
|
||||
endDate: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Budget type',
|
||||
enum: ['profit_and_loss', 'balance_sheet'],
|
||||
})
|
||||
budgetType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Period type',
|
||||
enum: ['monthly', 'quarterly', 'annual'],
|
||||
})
|
||||
periodType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Status',
|
||||
enum: ['draft', 'active', 'closed'],
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: 'Is draft', example: true })
|
||||
isDraft: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Is active', example: false })
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Is closed', example: false })
|
||||
isClosed: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Updated at' })
|
||||
updatedAt?: Date;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Budget entries',
|
||||
type: [BudgetEntryResponseDto],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BudgetEntryResponseDto)
|
||||
entries: BudgetEntryResponseDto[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IsOptional, ToNumber } from '@/common/decorators/Validators';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional as IsOpt,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BudgetEntryDto } from './BudgetEntry.dto';
|
||||
|
||||
export class CommandBudgetDto {
|
||||
@ApiProperty({ description: 'Budget name', example: '2026 Annual Budget' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Budget description',
|
||||
example: 'Annual operating budget for 2026',
|
||||
})
|
||||
@IsOpt()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'Budget start date', example: '2026-01-01' })
|
||||
@IsDateString()
|
||||
startDate: string;
|
||||
|
||||
@ApiProperty({ description: 'Budget end date', example: '2026-12-31' })
|
||||
@IsDateString()
|
||||
endDate: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Budget type',
|
||||
enum: ['profit_and_loss', 'balance_sheet'],
|
||||
example: 'profit_and_loss',
|
||||
})
|
||||
@IsEnum(['profit_and_loss', 'balance_sheet'])
|
||||
budgetType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Period type',
|
||||
enum: ['monthly', 'quarterly', 'annual'],
|
||||
example: 'monthly',
|
||||
})
|
||||
@IsEnum(['monthly', 'quarterly', 'annual'])
|
||||
periodType: string;
|
||||
|
||||
@ApiProperty({ description: 'Budget entries', type: [BudgetEntryDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BudgetEntryDto)
|
||||
entries: BudgetEntryDto[];
|
||||
}
|
||||
|
||||
export class CreateBudgetDto extends CommandBudgetDto {}
|
||||
|
||||
export class EditBudgetDto extends CommandBudgetDto {}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto';
|
||||
|
||||
export class GetBudgetsQueryDto extends DynamicFilterQueryDto {}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const BudgetMeta = {
|
||||
defaultFilterField: 'start_date',
|
||||
defaultSort: {
|
||||
sortOrder: 'DESC',
|
||||
sortField: 'created_at',
|
||||
},
|
||||
|
||||
fields: {
|
||||
name: {
|
||||
name: 'budget.field.name',
|
||||
column: 'name',
|
||||
fieldType: 'text',
|
||||
},
|
||||
start_date: {
|
||||
name: 'budget.field.start_date',
|
||||
column: 'start_date',
|
||||
fieldType: 'date',
|
||||
},
|
||||
end_date: {
|
||||
name: 'budget.field.end_date',
|
||||
column: 'end_date',
|
||||
fieldType: 'date',
|
||||
},
|
||||
budget_type: {
|
||||
name: 'budget.field.budget_type',
|
||||
column: 'budget_type',
|
||||
fieldType: 'enumeration',
|
||||
options: [
|
||||
{ key: 'profit_and_loss', label: 'Profit & Loss' },
|
||||
{ key: 'balance_sheet', label: 'Balance Sheet' },
|
||||
],
|
||||
},
|
||||
period_type: {
|
||||
name: 'budget.field.period_type',
|
||||
column: 'period_type',
|
||||
fieldType: 'enumeration',
|
||||
options: [
|
||||
{ key: 'monthly', label: 'Monthly' },
|
||||
{ key: 'quarterly', label: 'Quarterly' },
|
||||
{ key: 'annual', label: 'Annual' },
|
||||
],
|
||||
},
|
||||
status: {
|
||||
name: 'budget.field.status',
|
||||
column: 'status',
|
||||
fieldType: 'enumeration',
|
||||
options: [
|
||||
{ key: 'draft', label: 'Draft' },
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'closed', label: 'Closed' },
|
||||
],
|
||||
filterCustomQuery(query, role) {
|
||||
query.modify('filterByStatus', role.value);
|
||||
},
|
||||
sortCustomQuery(query, role) {
|
||||
query.orderBy('budgets.status', role.order);
|
||||
},
|
||||
},
|
||||
created_at: {
|
||||
name: 'budget.field.created_at',
|
||||
column: 'created_at',
|
||||
fieldType: 'date',
|
||||
},
|
||||
},
|
||||
|
||||
columns: {
|
||||
name: {
|
||||
name: 'budget.field.name',
|
||||
type: 'text',
|
||||
},
|
||||
startDate: {
|
||||
name: 'budget.field.start_date',
|
||||
type: 'date',
|
||||
},
|
||||
endDate: {
|
||||
name: 'budget.field.end_date',
|
||||
type: 'date',
|
||||
},
|
||||
budgetType: {
|
||||
name: 'budget.field.budget_type',
|
||||
type: 'text',
|
||||
},
|
||||
periodType: {
|
||||
name: 'budget.field.period_type',
|
||||
type: 'text',
|
||||
},
|
||||
status: {
|
||||
name: 'budget.field.status',
|
||||
type: 'text',
|
||||
},
|
||||
createdAt: {
|
||||
name: 'budget.field.created_at',
|
||||
type: 'date',
|
||||
printable: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Model } from 'objection';
|
||||
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
|
||||
import { InjectModelDefaultViews } from '@/modules/Views/decorators/InjectModelDefaultViews.decorator';
|
||||
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
|
||||
import { BudgetMeta } from './Budget.meta';
|
||||
|
||||
@InjectModelMeta(BudgetMeta)
|
||||
export class Budget extends TenantBaseModel {
|
||||
name: string;
|
||||
description: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
budgetType: string;
|
||||
periodType: string;
|
||||
status: string;
|
||||
|
||||
entries;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
static get tableName() {
|
||||
return 'budgets';
|
||||
}
|
||||
|
||||
static get timestamps() {
|
||||
return ['createdAt', 'updatedAt'];
|
||||
}
|
||||
|
||||
static get virtualAttributes() {
|
||||
return ['isActive', 'isDraft', 'isClosed'];
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this.status === 'active';
|
||||
}
|
||||
|
||||
get isDraft() {
|
||||
return this.status === 'draft';
|
||||
}
|
||||
|
||||
get isClosed() {
|
||||
return this.status === 'closed';
|
||||
}
|
||||
|
||||
static get resourceable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static get modifiers() {
|
||||
return {
|
||||
filterByStatus(query, status) {
|
||||
if (status) {
|
||||
query.where('budgets.status', status);
|
||||
}
|
||||
},
|
||||
filterByType(query, budgetType) {
|
||||
if (budgetType) {
|
||||
query.where('budgets.budget_type', budgetType);
|
||||
}
|
||||
},
|
||||
filterByPeriod(query, periodType) {
|
||||
if (periodType) {
|
||||
query.where('budgets.period_type', periodType);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { BudgetEntry } = require('./BudgetEntry.model');
|
||||
return {
|
||||
entries: {
|
||||
relation: Model.HasManyRelation,
|
||||
modelClass: BudgetEntry,
|
||||
join: {
|
||||
from: 'budgets.id',
|
||||
to: 'budget_entries.budget_id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get searchRoles() {
|
||||
return [
|
||||
{ condition: 'or', fieldKey: 'name', comparator: 'contains' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Model } from 'objection';
|
||||
import { BaseModel } from '@/models/Model';
|
||||
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||
|
||||
export class BudgetEntry extends BaseModel {
|
||||
budgetId: number;
|
||||
accountId: number;
|
||||
amount: number;
|
||||
periodDate: string;
|
||||
|
||||
account?: Account;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
static get tableName() {
|
||||
return 'budget_entries';
|
||||
}
|
||||
|
||||
get timestamps() {
|
||||
return ['createdAt', 'updatedAt'];
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { Account } = require('@/modules/Accounts/models/Account.model');
|
||||
const { Budget } = require('./Budget.model');
|
||||
|
||||
return {
|
||||
account: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: Account,
|
||||
join: {
|
||||
from: 'budget_entries.account_id',
|
||||
to: 'accounts.id',
|
||||
},
|
||||
},
|
||||
budget: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: Budget,
|
||||
join: {
|
||||
from: 'budget_entries.budget_id',
|
||||
to: 'budgets.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Transformer } from '@/modules/Transformer/Transformer';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
|
||||
export class BudgetTransformer extends Transformer {
|
||||
public includeAttributes = (): string[] => {
|
||||
return [
|
||||
'formattedStartDate',
|
||||
'formattedEndDate',
|
||||
'formattedCreatedAt',
|
||||
];
|
||||
};
|
||||
|
||||
protected formattedStartDate = (budget: Budget): string => {
|
||||
return this.formatDate(budget.startDate);
|
||||
};
|
||||
|
||||
protected formattedEndDate = (budget: Budget): string => {
|
||||
return this.formatDate(budget.endDate);
|
||||
};
|
||||
|
||||
protected formattedCreatedAt = (budget: Budget): string => {
|
||||
return this.formatDate(budget.createdAt);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '../constants';
|
||||
|
||||
@Injectable()
|
||||
export class GetBudgetService {
|
||||
constructor(
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
public getBudget = async (budgetId: number): Promise<Budget> => {
|
||||
const budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(budgetId)
|
||||
.withGraphFetched('entries.account');
|
||||
|
||||
if (!budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
return budget;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as R from 'ramda';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
|
||||
import { DynamicListService } from '@/modules/DynamicListing/DynamicList.service';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model';
|
||||
import { GetBudgetsQueryDto } from '../dtos/GetBudgetsQuery.dto';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { BudgetTransformer } from './BudgetTransformer';
|
||||
|
||||
@Injectable()
|
||||
export class GetBudgetsService {
|
||||
constructor(
|
||||
private readonly dynamicListService: DynamicListService,
|
||||
private readonly transformer: TransformerInjectable,
|
||||
|
||||
@Inject(Budget.name)
|
||||
private readonly budgetModel: TenantModelProxy<typeof Budget>,
|
||||
) {}
|
||||
|
||||
private parseListFilterDTO = (filterDTO) => {
|
||||
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
|
||||
};
|
||||
|
||||
public getBudgets = async (
|
||||
filterDTO: GetBudgetsQueryDto,
|
||||
): Promise<{
|
||||
budgets: Budget[];
|
||||
pagination: IPaginationMeta;
|
||||
filterMeta: IFilterMeta;
|
||||
}> => {
|
||||
const _filterDto = {
|
||||
sortOrder: 'desc',
|
||||
columnSortBy: 'created_at',
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
...filterDTO,
|
||||
};
|
||||
|
||||
const filter = this.parseListFilterDTO(_filterDto);
|
||||
|
||||
const dynamicService = await this.dynamicListService.dynamicList(
|
||||
this.budgetModel(),
|
||||
filter,
|
||||
);
|
||||
|
||||
const { results, pagination } = await this.budgetModel()
|
||||
.query()
|
||||
.onBuild((builder) => {
|
||||
dynamicService.buildQuery()(builder);
|
||||
builder.withGraphFetched('entries.account');
|
||||
})
|
||||
.pagination(filter.page - 1, filter.pageSize);
|
||||
|
||||
const budgets = await this.transformer.transform(
|
||||
results,
|
||||
new BudgetTransformer(),
|
||||
);
|
||||
|
||||
return {
|
||||
budgets,
|
||||
pagination,
|
||||
filterMeta: dynamicService.getResponseMeta(),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { TenantRepository } from '@/common/repository/TenantRepository';
|
||||
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
|
||||
import { Budget } from '../models/Budget.model';
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class BudgetRepository extends TenantRepository {
|
||||
constructor(
|
||||
@Inject(TENANCY_DB_CONNECTION)
|
||||
private readonly tenantDBKnex: () => Knex,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get model(): typeof Budget {
|
||||
return Budget.bindKnex(this.tenantDBKnex());
|
||||
}
|
||||
|
||||
public async findByIdWithEntries(budgetId: number) {
|
||||
return this.model
|
||||
.query()
|
||||
.findById(budgetId)
|
||||
.withGraphFetched('entries.account');
|
||||
}
|
||||
|
||||
public async findById(budgetId: number) {
|
||||
return this.model.query().findById(budgetId);
|
||||
}
|
||||
|
||||
public async deleteByIds(budgetIds: number[]) {
|
||||
await this.model.query().whereIn('id', budgetIds).delete();
|
||||
}
|
||||
|
||||
public async findActiveByIds(budgetIds: number[]) {
|
||||
return this.model
|
||||
.query()
|
||||
.whereIn('id', budgetIds)
|
||||
.where('status', 'active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export enum BudgetStatus {
|
||||
Draft = 'draft',
|
||||
Active = 'active',
|
||||
Closed = 'closed',
|
||||
}
|
||||
|
||||
export enum BudgetType {
|
||||
ProfitAndLoss = 'profit_and_loss',
|
||||
BalanceSheet = 'balance_sheet',
|
||||
}
|
||||
|
||||
export enum PeriodType {
|
||||
Monthly = 'monthly',
|
||||
Quarterly = 'quarterly',
|
||||
Annual = 'annual',
|
||||
}
|
||||
|
||||
export enum BudgetAction {
|
||||
View = 'View',
|
||||
Create = 'Create',
|
||||
Edit = 'Edit',
|
||||
Delete = 'Delete',
|
||||
}
|
||||
|
||||
export interface IBudgetCreatedPayload {
|
||||
budget;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetEditingPayload {
|
||||
budgetId: number;
|
||||
budgetDTO;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetEditedPayload {
|
||||
budget;
|
||||
budgetDTO;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetDeletingPayload {
|
||||
budgetId: number;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetDeletedPayload {
|
||||
budgetId: number;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetActivatingPayload {
|
||||
budgetId: number;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetActivatedPayload {
|
||||
budget;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetClosingPayload {
|
||||
budgetId: number;
|
||||
trx;
|
||||
}
|
||||
|
||||
export interface IBudgetClosedPayload {
|
||||
budget;
|
||||
trx;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { ProfitLossSheetModule } from './modules/ProfitLossSheet/ProfitLossSheet
|
||||
import { CashflowStatementModule } from './modules/CashFlowStatement/CashflowStatement.module';
|
||||
import { VendorBalanceSummaryModule } from './modules/VendorBalanceSummary/VendorBalanceSummary.module';
|
||||
import { BalanceSheetModule } from './modules/BalanceSheet/BalanceSheet.module';
|
||||
import { BudgetVsActualModule } from './modules/BudgetVsActual/BudgetVsActual.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +39,7 @@ import { BalanceSheetModule } from './modules/BalanceSheet/BalanceSheet.module';
|
||||
JournalSheetModule,
|
||||
ProfitLossSheetModule,
|
||||
CashflowStatementModule,
|
||||
BudgetVsActualModule,
|
||||
],
|
||||
})
|
||||
export class FinancialStatementsModule {}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Res,
|
||||
Headers,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BudgetVsActualApplication } from './BudgetVsActualApplication';
|
||||
import { BudgetVsActualQueryDto } from './BudgetVsActualQuery.dto';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { BudgetVsActualResponseDto } from './BudgetVsActualResponse.dto';
|
||||
import { RequirePermission } from '@/modules/Roles/RequirePermission.decorator';
|
||||
import { PermissionGuard } from '@/modules/Roles/Permission.guard';
|
||||
import { AuthorizationGuard } from '@/modules/Roles/Authorization.guard';
|
||||
import { AbilitySubject } from '@/modules/Roles/Roles.types';
|
||||
import { ReportsAction } from '../../types/Report.types';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('/reports/budget-vs-actual')
|
||||
@ApiTags('Reports')
|
||||
@UseGuards(AuthorizationGuard, PermissionGuard)
|
||||
export class BudgetVsActualController {
|
||||
constructor(
|
||||
private readonly budgetVsActualApp: BudgetVsActualApplication,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@RequirePermission(ReportsAction.READ_BUDGET_VS_ACTUAL, AbilitySubject.Report)
|
||||
@ApiOperation({ summary: 'Retrieves the Budget vs Actual report.' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'The Budget vs Actual report has been successfully retrieved.',
|
||||
})
|
||||
async budgetVsActual(
|
||||
@Query() query: BudgetVsActualQueryDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Headers('accept') acceptHeader: string,
|
||||
) {
|
||||
if (acceptHeader === 'application/csv') {
|
||||
return this.budgetVsActualApp.csv(query);
|
||||
}
|
||||
if (acceptHeader === 'application/json+table') {
|
||||
return this.budgetVsActualApp.table(query);
|
||||
}
|
||||
if (
|
||||
acceptHeader ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
) {
|
||||
return this.budgetVsActualApp.xlsx(query);
|
||||
}
|
||||
if (acceptHeader === 'application/pdf') {
|
||||
return this.budgetVsActualApp.pdf(query);
|
||||
}
|
||||
return this.budgetVsActualApp.sheet(query);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
|
||||
import { AccountsModule } from '@/modules/Accounts/Accounts.module';
|
||||
import { BudgetsModule } from '@/modules/Budgeting/Budgets.module';
|
||||
import { BudgetVsActualController } from './BudgetVsActual.controller';
|
||||
import { BudgetVsActualApplication } from './BudgetVsActualApplication';
|
||||
import { BudgetVsActualService } from './BudgetVsActualService';
|
||||
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||
import { BudgetVsActualMeta } from './BudgetVsActualMeta';
|
||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||
|
||||
@Module({
|
||||
imports: [FinancialSheetCommonModule, AccountsModule, BudgetsModule],
|
||||
controllers: [BudgetVsActualController],
|
||||
providers: [
|
||||
BudgetVsActualApplication,
|
||||
BudgetVsActualService,
|
||||
BudgetVsActualRepository,
|
||||
BudgetVsActualMeta,
|
||||
TenancyContext,
|
||||
],
|
||||
})
|
||||
export class BudgetVsActualModule {}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import * as R from 'ramda';
|
||||
import { ModelObject } from 'objection';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
import {
|
||||
IBudgetVsActualQuery,
|
||||
IBudgetVsActualNode,
|
||||
IBudgetVsActualAccountNode,
|
||||
IBudgetVsActualAggregateNode,
|
||||
IBudgetVsActualEquationNode,
|
||||
} from './BudgetVsActual.types';
|
||||
import { BUDGET_VS_ACTUAL_NODE_TYPE } from './constants';
|
||||
import { BudgetVsActualSchema } from './BudgetVsActualSchema';
|
||||
import { BudgetVsActualDatePeriods } from './BudgetVsActualDatePeriods';
|
||||
import { BudgetVsActualBase } from './BudgetVsActualBase';
|
||||
import { BudgetVsActualQuery } from './BudgetVsActualQuery';
|
||||
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||
import { FinancialDateRanges } from '../../common/FinancialDateRanges';
|
||||
import { FinancialEvaluateEquation } from '../../common/FinancialEvaluateEquation';
|
||||
import { FinancialSheetStructure } from '../../common/FinancialSheetStructure';
|
||||
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||
import { flatToNestedArray } from '@/utils/flat-to-nested-array';
|
||||
import { IFinancialReportMeta, DEFAULT_REPORT_META } from '../../types/Report.types';
|
||||
import { INumberFormatQuery } from '../../types/Report.types';
|
||||
|
||||
interface IBudgetVsActualContext {
|
||||
repository: BudgetVsActualRepository;
|
||||
query: BudgetVsActualQuery;
|
||||
}
|
||||
|
||||
export class BudgetVsActualSheet extends R.pipe(
|
||||
BudgetVsActualDatePeriods,
|
||||
BudgetVsActualSchema,
|
||||
BudgetVsActualBase,
|
||||
FinancialDateRanges,
|
||||
FinancialEvaluateEquation,
|
||||
FinancialSheetStructure,
|
||||
)(FinancialSheet) {
|
||||
readonly query: BudgetVsActualQuery;
|
||||
readonly baseCurrency: string;
|
||||
readonly repository: BudgetVsActualRepository;
|
||||
readonly i18n: I18nService;
|
||||
readonly numberFormat: INumberFormatQuery;
|
||||
readonly dateFormat: string;
|
||||
|
||||
constructor(
|
||||
repository: BudgetVsActualRepository,
|
||||
query: IBudgetVsActualQuery,
|
||||
i18n: I18nService,
|
||||
meta: IFinancialReportMeta,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.query = new BudgetVsActualQuery(query);
|
||||
this.repository = repository;
|
||||
this.baseCurrency = meta.baseCurrency;
|
||||
this.numberFormat = this.query.query.numberFormat;
|
||||
this.dateFormat = meta.dateFormat || DEFAULT_REPORT_META.dateFormat;
|
||||
this.i18n = i18n;
|
||||
}
|
||||
|
||||
private get ctx(): IBudgetVsActualContext {
|
||||
return this as unknown as IBudgetVsActualContext;
|
||||
}
|
||||
|
||||
private accountNodeMapper = (
|
||||
account: ModelObject<Account>,
|
||||
): IBudgetVsActualAccountNode => {
|
||||
const childrenAccountIds: number[] = this.repository.accountsGraph.dependenciesOf(
|
||||
account.id,
|
||||
);
|
||||
const accountIds = R.uniq(R.append(account.id, childrenAccountIds));
|
||||
|
||||
const actual = this.repository.getActualClosingBalance(accountIds);
|
||||
const budget = this.repository.getBudgetAmountForAccounts(accountIds);
|
||||
const variance = this.getVariance(actual, budget);
|
||||
const variancePct = this.getVariancePercentage(actual, budget);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
code: account.code,
|
||||
nodeType: BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT,
|
||||
budget: this.getAmountMeta(budget),
|
||||
actual: this.getAmountMeta(actual),
|
||||
variance: this.getAmountMeta(variance),
|
||||
variancePercentage: this.getPercentageAmountMeta(variancePct),
|
||||
};
|
||||
};
|
||||
|
||||
private accountNodeCompose = (account: ModelObject<Account>): IBudgetVsActualAccountNode => {
|
||||
let result: any = this.accountNodeMapper(account);
|
||||
if (this.query.isDatePeriodsColumnsType()) {
|
||||
result = this.assocAccountNodeDatePeriods(result);
|
||||
}
|
||||
return result as IBudgetVsActualAccountNode;
|
||||
};
|
||||
|
||||
private getAccountsNodesByTypes = (types: string[]): IBudgetVsActualAccountNode[] => {
|
||||
const accounts = this.repository.getAccountsByType(types);
|
||||
const accountsTree = flatToNestedArray(accounts, {
|
||||
id: 'id',
|
||||
parentId: 'parentAccountId',
|
||||
});
|
||||
return this.mapNodesDeep(accountsTree, this.accountNodeCompose);
|
||||
};
|
||||
|
||||
private accountsSchemaNodeMapper = (node: any): IBudgetVsActualAggregateNode => {
|
||||
const accountsTypes: string[] = node.accountsTypes || node.accountTypes;
|
||||
const children: IBudgetVsActualNode[] = accountsTypes
|
||||
? this.getAccountsNodesByTypes(accountsTypes)
|
||||
: [];
|
||||
const schemaChildren: IBudgetVsActualNode[] = node.children
|
||||
? this.parseSchemaChildren(node.children)
|
||||
: [];
|
||||
|
||||
const allChildren = [...children, ...schemaChildren];
|
||||
|
||||
const budgetTotal = this.getBudgetOfNodes(allChildren);
|
||||
const actualTotal = this.getActualOfNodes(allChildren);
|
||||
const variance = this.getVariance(actualTotal, budgetTotal);
|
||||
const variancePct = this.getVariancePercentage(actualTotal, budgetTotal);
|
||||
|
||||
const nodeType = node.type === 'AGGREGATE'
|
||||
? BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE
|
||||
: BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: this.i18n.t(node.name),
|
||||
nodeType: nodeType as 'ACCOUNTS' | 'AGGREGATE',
|
||||
budget: this.getTotalAmountMeta(budgetTotal),
|
||||
actual: this.getTotalAmountMeta(actualTotal),
|
||||
variance: this.getTotalAmountMeta(variance),
|
||||
variancePercentage: this.getPercentageTotalAmountMeta(variancePct),
|
||||
children: allChildren,
|
||||
};
|
||||
};
|
||||
|
||||
private accountsSchemaNodeCompose = (node: any): IBudgetVsActualAggregateNode => {
|
||||
let result: any = this.accountsSchemaNodeMapper(node);
|
||||
if (this.query.isDatePeriodsColumnsType()) {
|
||||
result = this.assocAggregateDatePeriod(result);
|
||||
}
|
||||
return result as IBudgetVsActualAggregateNode;
|
||||
};
|
||||
|
||||
private equationSchemaNodeParser = R.curry(
|
||||
(accNodes: any[], node: any): IBudgetVsActualEquationNode => {
|
||||
const equation: string = node.equation;
|
||||
|
||||
const budgetTable = this.getNodesTableForEvaluating(
|
||||
'budget.amount',
|
||||
accNodes,
|
||||
) as Record<string, number>;
|
||||
const budgetTotal = this.evaluateEquation(equation, budgetTable);
|
||||
|
||||
const actualTable = this.getNodesTableForEvaluating(
|
||||
'actual.amount',
|
||||
accNodes,
|
||||
) as Record<string, number>;
|
||||
const actualTotal = this.evaluateEquation(equation, actualTable);
|
||||
|
||||
const variance = this.getVariance(actualTotal, budgetTotal);
|
||||
const variancePct = this.getVariancePercentage(actualTotal, budgetTotal);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: this.i18n.t(node.name),
|
||||
nodeType: BUDGET_VS_ACTUAL_NODE_TYPE.EQUATION,
|
||||
budget: this.getTotalAmountMeta(budgetTotal),
|
||||
actual: this.getTotalAmountMeta(actualTotal),
|
||||
variance: this.getTotalAmountMeta(variance),
|
||||
variancePercentage: this.getPercentageTotalAmountMeta(variancePct),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
private equationSchemaNodeCompose = R.curry(
|
||||
(accNodes: any[], node: any): IBudgetVsActualEquationNode => {
|
||||
let result: any = node;
|
||||
if (this.isEquationNode(node)) {
|
||||
result = this.equationSchemaNodeParser(accNodes)(node);
|
||||
}
|
||||
if (this.isEquationNode(result) && this.query.isDatePeriodsColumnsType()) {
|
||||
result = this.assocEquationNodeDatePeriod(accNodes, (node as any).equation, result);
|
||||
}
|
||||
return result as IBudgetVsActualEquationNode;
|
||||
},
|
||||
);
|
||||
|
||||
private parseSchemaChildren = (
|
||||
children: any[],
|
||||
): IBudgetVsActualNode[] => {
|
||||
return this.mapNodesDeep(children, this.schemaNodeMapper);
|
||||
};
|
||||
|
||||
private schemaNodeMapper = (node: any): IBudgetVsActualNode => {
|
||||
let result: any = node;
|
||||
if (this.isAccountsNode(node)) {
|
||||
result = this.accountsSchemaNodeCompose(node);
|
||||
}
|
||||
return result as IBudgetVsActualNode;
|
||||
};
|
||||
|
||||
private reportSchemaAccountsNodesCompose = (
|
||||
schemaNodes: any[],
|
||||
): IBudgetVsActualNode[] => {
|
||||
return this.mapNodesDeep(schemaNodes, (node: any) => {
|
||||
let result: any = node;
|
||||
if (this.isAccountsNode(node)) {
|
||||
result = this.accountsSchemaNodeCompose(node);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
private reportSchemaEquationNodesCompose = (
|
||||
nodes: any[],
|
||||
): IBudgetVsActualNode[] => {
|
||||
return this.mapAccNodesDeep(
|
||||
nodes,
|
||||
(node: any, key: number, parentValue: any, accNodes: any[], context: any) => {
|
||||
let result: any = node;
|
||||
if (this.isEquationNode(node)) {
|
||||
result = this.equationSchemaNodeCompose(accNodes, node);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private reportFilterPlugin = (nodes: IBudgetVsActualNode[]): IBudgetVsActualNode[] => {
|
||||
if (!this.query.query.noneZero && !this.query.query.noneTransactions) {
|
||||
return nodes;
|
||||
}
|
||||
return this.filterNodesDeep(nodes, (node: IBudgetVsActualNode) => {
|
||||
if (node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT) {
|
||||
const accountNode = node as IBudgetVsActualAccountNode;
|
||||
if (this.query.query.noneZero) {
|
||||
return (
|
||||
accountNode.budget.amount !== 0 ||
|
||||
accountNode.actual.amount !== 0
|
||||
);
|
||||
}
|
||||
if (this.query.query.noneTransactions) {
|
||||
return accountNode.actual.amount !== 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
public reportData = (): IBudgetVsActualNode[] => {
|
||||
const schema = this.getSchema();
|
||||
|
||||
return R.compose(
|
||||
this.reportFilterPlugin,
|
||||
this.reportSchemaEquationNodesCompose,
|
||||
this.reportSchemaAccountsNodesCompose,
|
||||
)(schema);
|
||||
};
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
IFinancialCommonHorizDatePeriodNode,
|
||||
} from '../../types/Report.types';
|
||||
|
||||
export interface IBudgetVsActualTotal {
|
||||
amount: number;
|
||||
formattedAmount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualPercentage {
|
||||
amount: number;
|
||||
formattedAmount: string;
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualAccountNode {
|
||||
id: number;
|
||||
name: string;
|
||||
code?: string;
|
||||
nodeType: 'ACCOUNT';
|
||||
budget: IBudgetVsActualTotal;
|
||||
actual: IBudgetVsActualTotal;
|
||||
variance: IBudgetVsActualTotal;
|
||||
variancePercentage: IBudgetVsActualPercentage;
|
||||
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualAggregateNode {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeType: 'ACCOUNTS' | 'AGGREGATE';
|
||||
budget: IBudgetVsActualTotal;
|
||||
actual: IBudgetVsActualTotal;
|
||||
variance: IBudgetVsActualTotal;
|
||||
variancePercentage: IBudgetVsActualPercentage;
|
||||
children: IBudgetVsActualNode[];
|
||||
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualEquationNode {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeType: 'EQUATION';
|
||||
budget: IBudgetVsActualTotal;
|
||||
actual: IBudgetVsActualTotal;
|
||||
variance: IBudgetVsActualTotal;
|
||||
variancePercentage: IBudgetVsActualPercentage;
|
||||
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualNetIncomeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeType: 'NET_INCOME';
|
||||
budget: IBudgetVsActualTotal;
|
||||
actual: IBudgetVsActualTotal;
|
||||
variance: IBudgetVsActualTotal;
|
||||
variancePercentage: IBudgetVsActualPercentage;
|
||||
horizontalBudgets?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalActuals?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariances?: IBudgetVsActualDatePeriodTotal[];
|
||||
horizontalVariancePercentages?: IBudgetVsActualDatePeriodPercentage[];
|
||||
}
|
||||
|
||||
export type IBudgetVsActualNode =
|
||||
| IBudgetVsActualAccountNode
|
||||
| IBudgetVsActualAggregateNode
|
||||
| IBudgetVsActualEquationNode
|
||||
| IBudgetVsActualNetIncomeNode;
|
||||
|
||||
export interface IBudgetVsActualDatePeriodTotal
|
||||
extends IFinancialCommonHorizDatePeriodNode {
|
||||
budget: IBudgetVsActualTotal;
|
||||
actual: IBudgetVsActualTotal;
|
||||
variance: IBudgetVsActualTotal;
|
||||
variancePercentage: IBudgetVsActualPercentage;
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualDatePeriodPercentage {
|
||||
fromDate: { date: Date; formattedDate: string };
|
||||
toDate: { date: Date; formattedDate: string };
|
||||
amount: number;
|
||||
formattedAmount: string;
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualQuery {
|
||||
budgetId: number;
|
||||
fromDate?: Date | string;
|
||||
toDate?: Date | string;
|
||||
displayColumnsType?: string;
|
||||
displayColumnsBy?: string;
|
||||
numberFormat?: any;
|
||||
noneZero?: boolean;
|
||||
noneTransactions?: boolean;
|
||||
accountsIds?: number[];
|
||||
branchesIds?: number[];
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualMeta {
|
||||
organizationName: string;
|
||||
baseCurrency: string;
|
||||
dateFormat: string;
|
||||
sheetName: string;
|
||||
budgetName: string;
|
||||
budgetType: string;
|
||||
periodType: string;
|
||||
fromDate: { date: Date; formattedDate: string };
|
||||
toDate: { date: Date; formattedDate: string };
|
||||
isCostComputeRunning: boolean;
|
||||
}
|
||||
|
||||
export interface IBudgetVsActualSheetData {
|
||||
data: IBudgetVsActualNode[];
|
||||
query: IBudgetVsActualQuery;
|
||||
meta: IBudgetVsActualMeta;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BudgetVsActualService } from './BudgetVsActualService';
|
||||
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetVsActualApplication {
|
||||
constructor(private readonly budgetVsActualService: BudgetVsActualService) {}
|
||||
|
||||
public sheet = (query: IBudgetVsActualQuery) => {
|
||||
return this.budgetVsActualService.budgetVsActual(query);
|
||||
};
|
||||
|
||||
public table = (query: IBudgetVsActualQuery) => {
|
||||
return this.budgetVsActualService.budgetVsActual(query);
|
||||
};
|
||||
|
||||
public csv = (query: IBudgetVsActualQuery) => {
|
||||
return this.budgetVsActualService.budgetVsActual(query);
|
||||
};
|
||||
|
||||
public xlsx = (query: IBudgetVsActualQuery) => {
|
||||
return this.budgetVsActualService.budgetVsActual(query);
|
||||
};
|
||||
|
||||
public pdf = (query: IBudgetVsActualQuery) => {
|
||||
return this.budgetVsActualService.budgetVsActual(query);
|
||||
};
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import * as R from 'ramda';
|
||||
import { sumBy } from 'lodash';
|
||||
import { GConstructor } from '@/common/types/Constructor';
|
||||
import { BUDGET_VS_ACTUAL_NODE_TYPE } from './constants';
|
||||
|
||||
interface IBudgetVsActualBaseContext {
|
||||
repository: { budget: { budgetType: string } };
|
||||
query: any;
|
||||
}
|
||||
|
||||
export const BudgetVsActualBase = <T extends GConstructor<any>>(
|
||||
Base: T,
|
||||
) =>
|
||||
class extends Base {
|
||||
public isNodeType = R.curry((type: string, node: { nodeType: string }): boolean => {
|
||||
return node.nodeType === type;
|
||||
});
|
||||
|
||||
public isSchemaNodeType = R.curry((type: string, node: { nodeType?: string; type?: string }): boolean => {
|
||||
return node.nodeType === type || node.type === type;
|
||||
});
|
||||
|
||||
public isAccountNode = (node: { nodeType: string }): boolean => {
|
||||
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNT;
|
||||
};
|
||||
|
||||
public isAccountsNode = (node: { nodeType?: string; type?: string }): boolean => {
|
||||
return (
|
||||
node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS ||
|
||||
node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE ||
|
||||
node.type === BUDGET_VS_ACTUAL_NODE_TYPE.ACCOUNTS ||
|
||||
node.type === BUDGET_VS_ACTUAL_NODE_TYPE.AGGREGATE
|
||||
);
|
||||
};
|
||||
|
||||
public isEquationNode = (node: { nodeType: string }): boolean => {
|
||||
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.EQUATION;
|
||||
};
|
||||
|
||||
public isNetIncomeNode = (node: { nodeType: string }): boolean => {
|
||||
return node.nodeType === BUDGET_VS_ACTUAL_NODE_TYPE.NET_INCOME;
|
||||
};
|
||||
|
||||
public isProfitAndLoss = (): boolean => {
|
||||
const ctx = this as unknown as IBudgetVsActualBaseContext;
|
||||
return ctx.repository.budget.budgetType === 'profit_and_loss';
|
||||
};
|
||||
|
||||
public isBalanceSheet = (): boolean => {
|
||||
const ctx = this as unknown as IBudgetVsActualBaseContext;
|
||||
return ctx.repository.budget.budgetType === 'balance_sheet';
|
||||
};
|
||||
|
||||
protected getVariance = (actual: number, budget: number): number => {
|
||||
return actual - budget;
|
||||
};
|
||||
|
||||
protected getVariancePercentage = (
|
||||
actual: number,
|
||||
budget: number,
|
||||
): number => {
|
||||
if (budget === 0) return 0;
|
||||
return (actual - budget) / Math.abs(budget);
|
||||
};
|
||||
|
||||
protected getBudgetOfNodes = (nodes: Array<{ budget: { amount: number } }>): number => {
|
||||
return sumBy(nodes, 'budget.amount');
|
||||
};
|
||||
|
||||
protected getActualOfNodes = (nodes: Array<{ actual: { amount: number } }>): number => {
|
||||
return sumBy(nodes, 'actual.amount');
|
||||
};
|
||||
};
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
import * as R from 'ramda';
|
||||
import * as moment from 'moment';
|
||||
import { sumBy } from 'lodash';
|
||||
import { GConstructor } from '@/common/types/Constructor';
|
||||
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||
import {
|
||||
IBudgetVsActualAccountNode,
|
||||
IBudgetVsActualAggregateNode,
|
||||
IBudgetVsActualEquationNode,
|
||||
IBudgetVsActualDatePeriodPercentage,
|
||||
} from './BudgetVsActual.types';
|
||||
|
||||
type BudgetVsActualDatePeriodsContext = FinancialSheet & {
|
||||
repository: BudgetVsActualRepository;
|
||||
query: any;
|
||||
isProfitAndLoss: () => boolean;
|
||||
getVariance: (actual: number, budget: number) => number;
|
||||
getVariancePercentage: (actual: number, budget: number) => number;
|
||||
getDateRanges: (fromDate: Date | string, toDate: Date | string, unit: moment.unitOfTime.StartOf) => Array<{ fromDate: Date; toDate: Date }>;
|
||||
getDatePeriodTotalMeta: (total: number, fromDate: Date, toDate: Date) => any;
|
||||
getDateMeta: (date: moment.MomentInput, format?: string) => { date: Date; formattedDate: string };
|
||||
formatPercentage: (amount: number) => string;
|
||||
getNodesTableForEvaluating: (path: string, nodes: any[]) => Record<string, number>;
|
||||
evaluateEquation: (equation: string, scope: Record<string, number>) => number;
|
||||
};
|
||||
|
||||
export const BudgetVsActualDatePeriods = <T extends GConstructor<any>>(
|
||||
Base: T,
|
||||
) =>
|
||||
class extends Base {
|
||||
public assocAccountNodeDatePeriods = (node: IBudgetVsActualAccountNode): IBudgetVsActualAccountNode => {
|
||||
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||
|
||||
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||
|
||||
const horizontalBudgets: any[] = [];
|
||||
const horizontalActuals: any[] = [];
|
||||
const horizontalVariances: any[] = [];
|
||||
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||
|
||||
dateRanges.forEach((dateRange) => {
|
||||
const periodKey = `${moment(dateRange.fromDate).format('YYYY-MM-DD')}_${moment(dateRange.toDate).format('YYYY-MM-DD')}`;
|
||||
|
||||
const childrenAccountIds: number[] = ctx.repository.accountsGraph.dependenciesOf(
|
||||
node.id,
|
||||
);
|
||||
const accountIds = R.uniq(R.append(node.id, childrenAccountIds));
|
||||
|
||||
let budgetPeriodAmount = 0;
|
||||
const periodBudgetMap = ctx.repository.budgetPeriodsByAccount?.get(periodKey);
|
||||
if (periodBudgetMap) {
|
||||
accountIds.forEach((id: number) => {
|
||||
budgetPeriodAmount += periodBudgetMap.get(id) || 0;
|
||||
});
|
||||
}
|
||||
|
||||
let actualPeriodAmount = 0;
|
||||
if (ctx.isProfitAndLoss()) {
|
||||
const periodLedger = ctx.repository.periodsAccountsLedger
|
||||
?.whereAccountsIds(accountIds)
|
||||
?.whereFromDate(dateRange.fromDate)
|
||||
?.whereToDate(dateRange.toDate);
|
||||
actualPeriodAmount = periodLedger?.getClosingBalance() || 0;
|
||||
} else {
|
||||
const closingToDate = ctx.repository.periodsAccountsLedger
|
||||
?.whereAccountsIds(accountIds)
|
||||
?.whereToDate(dateRange.toDate);
|
||||
const openingToDate = ctx.repository.periodsOpeningAccountLedger
|
||||
?.whereAccountsIds(accountIds)
|
||||
?.whereToDate(dateRange.fromDate);
|
||||
|
||||
const closing = closingToDate?.getClosingBalance() || 0;
|
||||
const opening = openingToDate?.getClosingBalance() || 0;
|
||||
actualPeriodAmount = closing - opening;
|
||||
}
|
||||
|
||||
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||
|
||||
horizontalBudgets.push(
|
||||
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalActuals.push(
|
||||
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariances.push(
|
||||
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariancePercentages.push({
|
||||
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||
amount: variancePct,
|
||||
formattedAmount: ctx.formatPercentage(variancePct),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...node,
|
||||
horizontalBudgets,
|
||||
horizontalActuals,
|
||||
horizontalVariances,
|
||||
horizontalVariancePercentages,
|
||||
};
|
||||
};
|
||||
|
||||
public assocAggregateDatePeriod = (node: IBudgetVsActualAggregateNode): IBudgetVsActualAggregateNode => {
|
||||
if (!node.children || node.children.length === 0) return node;
|
||||
|
||||
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||
|
||||
const horizontalBudgets: any[] = [];
|
||||
const horizontalActuals: any[] = [];
|
||||
const horizontalVariances: any[] = [];
|
||||
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||
|
||||
dateRanges.forEach((dateRange, index) => {
|
||||
const budgetPeriodAmount = sumBy(
|
||||
node.children as any[],
|
||||
`horizontalBudgets.${index}.total.amount`,
|
||||
);
|
||||
const actualPeriodAmount = sumBy(
|
||||
node.children as any[],
|
||||
`horizontalActuals.${index}.total.amount`,
|
||||
);
|
||||
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||
|
||||
horizontalBudgets.push(
|
||||
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalActuals.push(
|
||||
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariances.push(
|
||||
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariancePercentages.push({
|
||||
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||
amount: variancePct,
|
||||
formattedAmount: ctx.formatPercentage(variancePct),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...node,
|
||||
horizontalBudgets,
|
||||
horizontalActuals,
|
||||
horizontalVariances,
|
||||
horizontalVariancePercentages,
|
||||
};
|
||||
};
|
||||
|
||||
public assocEquationNodeDatePeriod = R.curry(
|
||||
(
|
||||
accNodes: any[],
|
||||
equation: string,
|
||||
node: IBudgetVsActualEquationNode,
|
||||
): IBudgetVsActualEquationNode => {
|
||||
const ctx = this as unknown as BudgetVsActualDatePeriodsContext;
|
||||
const fromDate = ctx.query.fromDate || ctx.repository.budget.startDate;
|
||||
const toDate = ctx.query.toDate || ctx.repository.budget.endDate;
|
||||
const periodsUnit = ctx.query.getPeriodsUnit();
|
||||
const dateRanges = ctx.getDateRanges(fromDate, toDate, periodsUnit);
|
||||
|
||||
const horizontalBudgets: any[] = [];
|
||||
const horizontalActuals: any[] = [];
|
||||
const horizontalVariances: any[] = [];
|
||||
const horizontalVariancePercentages: IBudgetVsActualDatePeriodPercentage[] = [];
|
||||
|
||||
dateRanges.forEach((dateRange, index) => {
|
||||
const budgetTable = ctx.getNodesTableForEvaluating(
|
||||
`horizontalBudgets.${index}.total.amount`,
|
||||
accNodes,
|
||||
) as Record<string, number>;
|
||||
const budgetPeriodAmount = ctx.evaluateEquation(equation, budgetTable);
|
||||
|
||||
const actualTable = ctx.getNodesTableForEvaluating(
|
||||
`horizontalActuals.${index}.total.amount`,
|
||||
accNodes,
|
||||
) as Record<string, number>;
|
||||
const actualPeriodAmount = ctx.evaluateEquation(equation, actualTable);
|
||||
|
||||
const variance = ctx.getVariance(actualPeriodAmount, budgetPeriodAmount);
|
||||
const variancePct = ctx.getVariancePercentage(actualPeriodAmount, budgetPeriodAmount);
|
||||
|
||||
horizontalBudgets.push(
|
||||
ctx.getDatePeriodTotalMeta(budgetPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalActuals.push(
|
||||
ctx.getDatePeriodTotalMeta(actualPeriodAmount, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariances.push(
|
||||
ctx.getDatePeriodTotalMeta(variance, dateRange.fromDate, dateRange.toDate),
|
||||
);
|
||||
horizontalVariancePercentages.push({
|
||||
fromDate: ctx.getDateMeta(dateRange.fromDate),
|
||||
toDate: ctx.getDateMeta(dateRange.toDate),
|
||||
amount: variancePct,
|
||||
formattedAmount: ctx.formatPercentage(variancePct),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
...node,
|
||||
horizontalBudgets,
|
||||
horizontalActuals,
|
||||
horizontalVariances,
|
||||
horizontalVariancePercentages,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FinancialSheetMeta } from '../../common/FinancialSheetMeta';
|
||||
import { IBudgetVsActualQuery, IBudgetVsActualMeta } from './BudgetVsActual.types';
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetVsActualMeta {
|
||||
constructor(private readonly financialSheetMeta: FinancialSheetMeta) {}
|
||||
|
||||
public meta = async (
|
||||
query: IBudgetVsActualQuery,
|
||||
budget: any,
|
||||
): Promise<IBudgetVsActualMeta> => {
|
||||
const meta = await this.financialSheetMeta.meta();
|
||||
const dateFormat = meta.dateFormat || 'YYYY MMM DD';
|
||||
|
||||
const fromDateVal = query.fromDate || budget.startDate;
|
||||
const toDateVal = query.toDate || budget.endDate;
|
||||
|
||||
return {
|
||||
...meta,
|
||||
sheetName: 'Budget vs Actual',
|
||||
budgetName: budget.name,
|
||||
budgetType: budget.budgetType,
|
||||
periodType: budget.periodType,
|
||||
fromDate: {
|
||||
date: moment(fromDateVal).toDate(),
|
||||
formattedDate: moment(fromDateVal).format(dateFormat),
|
||||
},
|
||||
toDate: {
|
||||
date: moment(toDateVal).toDate(),
|
||||
formattedDate: moment(toDateVal).format(dateFormat),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ToNumber } from '@/common/decorators/Validators';
|
||||
import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto';
|
||||
import { NumberFormatQueryDto } from '@/modules/BankingTransactions/dtos/NumberFormatQuery.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class BudgetVsActualQueryDto {
|
||||
@ApiProperty({ description: 'Budget ID', example: 1 })
|
||||
@IsNotEmpty()
|
||||
@ToNumber()
|
||||
@IsInt()
|
||||
budgetId: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'From date (defaults to budget start date)',
|
||||
example: '2026-01-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
fromDate: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'To date (defaults to budget end date)',
|
||||
example: '2026-12-31',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
toDate: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Display columns type',
|
||||
enum: ['total', 'date_periods'],
|
||||
example: 'total',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(['total', 'date_periods'])
|
||||
displayColumnsType: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Display columns by period',
|
||||
enum: ['month', 'quarter', 'year'],
|
||||
example: 'month',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(['month', 'quarter', 'year'])
|
||||
displayColumnsBy: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Number format settings' })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NumberFormatQueryDto)
|
||||
numberFormat: NumberFormatQueryDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by specific account IDs',
|
||||
type: [Number],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ToNumber()
|
||||
@IsInt({ each: true })
|
||||
accountsIds: number[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by branch IDs',
|
||||
type: [Number],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ToNumber()
|
||||
@IsInt({ each: true })
|
||||
branchesIds: number[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclude zero rows' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
noneZero: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclude accounts with no transactions' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
noneTransactions: boolean;
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import * as moment from 'moment';
|
||||
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||
import { BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE } from './constants';
|
||||
import { IAccountTransactionsGroupBy } from '../../types/Report.types';
|
||||
|
||||
export class BudgetVsActualQuery {
|
||||
readonly query: IBudgetVsActualQuery;
|
||||
|
||||
constructor(query: IBudgetVsActualQuery) {
|
||||
this.query = {
|
||||
...query,
|
||||
displayColumnsType:
|
||||
query.displayColumnsType ||
|
||||
BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.TOTAL,
|
||||
displayColumnsBy: query.displayColumnsBy || 'month',
|
||||
numberFormat: query.numberFormat || {
|
||||
precision: 2,
|
||||
divideOn1000: false,
|
||||
showZero: false,
|
||||
formatMoney: 'total',
|
||||
negativeFormat: 'mines',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get budgetId(): number {
|
||||
return this.query.budgetId;
|
||||
}
|
||||
|
||||
get fromDate(): Date | string {
|
||||
return this.query.fromDate;
|
||||
}
|
||||
|
||||
get toDate(): Date | string {
|
||||
return this.query.toDate;
|
||||
}
|
||||
|
||||
get displayColumnsType(): string {
|
||||
return this.query.displayColumnsType;
|
||||
}
|
||||
|
||||
get displayColumnsBy(): string {
|
||||
return this.query.displayColumnsBy;
|
||||
}
|
||||
|
||||
get accountsIds(): number[] {
|
||||
return this.query.accountsIds;
|
||||
}
|
||||
|
||||
get branchesIds(): number[] {
|
||||
return this.query.branchesIds;
|
||||
}
|
||||
|
||||
get numberFormat() {
|
||||
return this.query.numberFormat;
|
||||
}
|
||||
|
||||
public isDatePeriodsColumnsType = (): boolean => {
|
||||
return (
|
||||
this.displayColumnsType ===
|
||||
BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.DATE_PERIODS
|
||||
);
|
||||
};
|
||||
|
||||
public getGroupByType = (): IAccountTransactionsGroupBy => {
|
||||
const pairs = {
|
||||
month: IAccountTransactionsGroupBy.Month,
|
||||
quarter: IAccountTransactionsGroupBy.Quarter,
|
||||
year: IAccountTransactionsGroupBy.Year,
|
||||
day: IAccountTransactionsGroupBy.Day,
|
||||
};
|
||||
return pairs[this.displayColumnsBy] || IAccountTransactionsGroupBy.Month;
|
||||
};
|
||||
|
||||
public getPeriodsUnit = (): moment.unitOfTime.StartOf => {
|
||||
const pairs = {
|
||||
month: 'month' as moment.unitOfTime.StartOf,
|
||||
quarter: 'quarter' as moment.unitOfTime.StartOf,
|
||||
year: 'year' as moment.unitOfTime.StartOf,
|
||||
day: 'day' as moment.unitOfTime.StartOf,
|
||||
};
|
||||
return pairs[this.displayColumnsBy] || 'month';
|
||||
};
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { ModelObject } from 'objection';
|
||||
import * as R from 'ramda';
|
||||
import { Knex } from 'knex';
|
||||
import { isEmpty, castArray, sumBy } from 'lodash';
|
||||
import * as moment from 'moment';
|
||||
import { transformToMapBy } from '@/utils/transform-to-map-by';
|
||||
import { BudgetVsActualQuery } from './BudgetVsActualQuery';
|
||||
import { Ledger } from '@/modules/Ledger/Ledger';
|
||||
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||
import { IAccountTransactionsGroupBy } from '../../types/Report.types';
|
||||
import { Account } from '@/modules/Accounts/models/Account.model';
|
||||
import { FinancialDatePeriods } from '../../common/FinancialDatePeriods';
|
||||
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
|
||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||
import { Budget } from '@/modules/Budgeting/models/Budget.model';
|
||||
import { BudgetEntry } from '@/modules/Budgeting/models/BudgetEntry.model';
|
||||
import { BUDGET_TYPE } from './constants';
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class BudgetVsActualRepository extends R.compose(
|
||||
FinancialDatePeriods,
|
||||
)(class {} as any) {
|
||||
@Inject(Account.name)
|
||||
public accountModel: TenantModelProxy<typeof Account>;
|
||||
|
||||
@Inject(AccountTransaction.name)
|
||||
public accountTransactionModel: TenantModelProxy<typeof AccountTransaction>;
|
||||
|
||||
@Inject(Budget.name)
|
||||
public budgetModel: TenantModelProxy<typeof Budget>;
|
||||
|
||||
@Inject(BudgetEntry.name)
|
||||
public budgetEntryModel: TenantModelProxy<typeof BudgetEntry>;
|
||||
|
||||
@Inject(TenancyContext)
|
||||
public tenancyContext: TenancyContext;
|
||||
|
||||
public baseCurrency: string;
|
||||
public accounts: ModelObject<Account>[];
|
||||
public accountsByType: Map<string, ModelObject<Account>[]>;
|
||||
public accountsByParentType: Map<string, ModelObject<Account>[]>;
|
||||
public accountsGraph: any;
|
||||
|
||||
public query: BudgetVsActualQuery;
|
||||
public transactionsGroupType: IAccountTransactionsGroupBy =
|
||||
IAccountTransactionsGroupBy.Month;
|
||||
|
||||
public budget: Budget;
|
||||
|
||||
public totalAccountsLedger: Ledger;
|
||||
public periodsAccountsLedger: Ledger;
|
||||
public periodsOpeningAccountLedger: Ledger;
|
||||
|
||||
public budgetTotalByAccount: Map<number, number>;
|
||||
public budgetPeriodsByAccount: Map<string, Map<number, number>>;
|
||||
|
||||
setFilter(query: IBudgetVsActualQuery) {
|
||||
this.query = new BudgetVsActualQuery(query);
|
||||
|
||||
this.transactionsGroupType = this.getGroupByFromDisplayColumnsBy(
|
||||
this.query.displayColumnsBy as any,
|
||||
);
|
||||
}
|
||||
|
||||
public asyncInitialize = async () => {
|
||||
await this.initBaseCurrency();
|
||||
await this.initBudget();
|
||||
await this.initAccounts();
|
||||
await this.initAccountsGraph();
|
||||
|
||||
if (this.isProfitAndLoss()) {
|
||||
await this.initAccountsTotalLedger();
|
||||
} else {
|
||||
await this.initClosingAccountsTotalLedger();
|
||||
}
|
||||
|
||||
await this.initBudgetEntries();
|
||||
|
||||
if (this.query.isDatePeriodsColumnsType()) {
|
||||
if (this.isProfitAndLoss()) {
|
||||
await this.initTotalDatePeriods();
|
||||
} else {
|
||||
await this.initBSTotalDatePeriods();
|
||||
}
|
||||
await this.initBudgetDatePeriods();
|
||||
}
|
||||
};
|
||||
|
||||
private isProfitAndLoss = (): boolean => {
|
||||
return this.budget.budgetType === BUDGET_TYPE.PROFIT_AND_LOSS;
|
||||
};
|
||||
|
||||
private initBaseCurrency = async () => {
|
||||
const metadata = await this.tenancyContext.getTenantMetadata();
|
||||
this.baseCurrency = metadata.baseCurrency;
|
||||
};
|
||||
|
||||
private initBudget = async () => {
|
||||
this.budget = await this.budgetModel()
|
||||
.query()
|
||||
.findById(this.query.budgetId)
|
||||
.withGraphFetched('entries');
|
||||
};
|
||||
|
||||
private initAccounts = async () => {
|
||||
const accounts = await this.getAccounts();
|
||||
this.accounts = accounts;
|
||||
this.accountsByType = transformToMapBy(accounts, 'accountType');
|
||||
this.accountsByParentType = transformToMapBy(accounts, 'accountParentType');
|
||||
};
|
||||
|
||||
private initAccountsGraph = async () => {
|
||||
this.accountsGraph = this.accountModel().toDependencyGraph(this.accounts);
|
||||
};
|
||||
|
||||
private initAccountsTotalLedger = async () => {
|
||||
const totalByAccount = await this.accountsTotal(
|
||||
this.query.fromDate,
|
||||
this.query.toDate,
|
||||
);
|
||||
this.totalAccountsLedger = Ledger.fromTransactions(totalByAccount);
|
||||
};
|
||||
|
||||
private initClosingAccountsTotalLedger = async () => {
|
||||
const totalByAccount = await this.closingAccountsTotal(
|
||||
this.query.toDate,
|
||||
);
|
||||
this.totalAccountsLedger = Ledger.fromTransactions(totalByAccount);
|
||||
};
|
||||
|
||||
private initTotalDatePeriods = async () => {
|
||||
const periodsByAccount = await this.accountsDatePeriods(
|
||||
this.query.fromDate,
|
||||
this.query.toDate,
|
||||
this.transactionsGroupType,
|
||||
);
|
||||
this.periodsAccountsLedger = Ledger.fromTransactions(periodsByAccount);
|
||||
};
|
||||
|
||||
private initBSTotalDatePeriods = async () => {
|
||||
const periodsByAccount = await this.accountsDatePeriods(
|
||||
this.query.fromDate,
|
||||
this.query.toDate,
|
||||
this.transactionsGroupType,
|
||||
);
|
||||
const periodsOpeningByAccount = await this.closingAccountsTotal(
|
||||
this.query.fromDate,
|
||||
);
|
||||
this.periodsAccountsLedger = Ledger.fromTransactions(periodsByAccount);
|
||||
this.periodsOpeningAccountLedger = Ledger.fromTransactions(
|
||||
periodsOpeningByAccount,
|
||||
);
|
||||
};
|
||||
|
||||
private initBudgetEntries = async () => {
|
||||
const q = this.budgetEntryModel()
|
||||
.query()
|
||||
.where('budget_id', this.budget.id);
|
||||
|
||||
if (this.query.fromDate) {
|
||||
q.where('period_date', '>=', this.query.fromDate);
|
||||
}
|
||||
if (this.query.toDate) {
|
||||
q.where('period_date', '<=', this.query.toDate);
|
||||
}
|
||||
|
||||
const entries: BudgetEntry[] = await q;
|
||||
|
||||
this.budgetTotalByAccount = entries.reduce((map, entry) => {
|
||||
const existing = map.get(entry.accountId) || 0;
|
||||
map.set(entry.accountId, existing + entry.amount);
|
||||
return map;
|
||||
}, new Map<number, number>());
|
||||
};
|
||||
|
||||
private initBudgetDatePeriods = async () => {
|
||||
const fromDate = this.query.fromDate || this.budget.startDate;
|
||||
const toDate = this.query.toDate || this.budget.endDate;
|
||||
|
||||
const q = this.budgetEntryModel()
|
||||
.query()
|
||||
.where('budget_id', this.budget.id)
|
||||
.where('period_date', '>=', fromDate)
|
||||
.where('period_date', '<=', toDate);
|
||||
|
||||
const entries: BudgetEntry[] = await q;
|
||||
|
||||
const periodsMap = new Map<string, Map<number, number>>();
|
||||
const dateRanges = this.getDateRanges(
|
||||
fromDate,
|
||||
toDate,
|
||||
this.query.getPeriodsUnit(),
|
||||
);
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const entryDate = moment(entry.periodDate);
|
||||
const periodKey = this.findPeriodKeyForDate(entryDate, dateRanges);
|
||||
|
||||
if (!periodsMap.has(periodKey)) {
|
||||
periodsMap.set(periodKey, new Map());
|
||||
}
|
||||
const periodMap = periodsMap.get(periodKey);
|
||||
periodMap.set(entry.accountId, (periodMap.get(entry.accountId) || 0) + entry.amount);
|
||||
});
|
||||
|
||||
this.budgetPeriodsByAccount = periodsMap;
|
||||
};
|
||||
|
||||
private findPeriodKeyForDate = (
|
||||
entryDate: moment.Moment,
|
||||
dateRanges: Array<{ fromDate: Date; toDate: Date }>,
|
||||
): string => {
|
||||
for (const range of dateRanges) {
|
||||
if (
|
||||
(entryDate.isSameOrAfter(moment(range.fromDate)) &&
|
||||
entryDate.isSameOrBefore(moment(range.toDate))) ||
|
||||
entryDate.isSame(moment(range.fromDate), 'month')
|
||||
) {
|
||||
return `${moment(range.fromDate).format('YYYY-MM-DD')}_${moment(range.toDate).format('YYYY-MM-DD')}`;
|
||||
}
|
||||
}
|
||||
return `${moment(entryDate).startOf('month').format('YYYY-MM-DD')}_${moment(entryDate).endOf('month').format('YYYY-MM-DD')}`;
|
||||
};
|
||||
|
||||
public accountsTotal = async (
|
||||
fromDate: moment.MomentInput,
|
||||
toDate: moment.MomentInput,
|
||||
) => {
|
||||
return this.accountTransactionModel()
|
||||
.query()
|
||||
.onBuild((query: any) => {
|
||||
query.sum('credit as credit');
|
||||
query.sum('debit as debit');
|
||||
query.groupBy('accountId');
|
||||
query.select(['accountId']);
|
||||
|
||||
query.modify('filterDateRange', fromDate, toDate);
|
||||
query.withGraphFetched('account');
|
||||
|
||||
this.commonFilterBranchesQuery(query);
|
||||
});
|
||||
};
|
||||
|
||||
public closingAccountsTotal = async (toDate: moment.MomentInput) => {
|
||||
return this.accountTransactionModel()
|
||||
.query()
|
||||
.onBuild((query: any) => {
|
||||
query.sum('credit as credit');
|
||||
query.sum('debit as debit');
|
||||
query.groupBy('accountId');
|
||||
query.select(['accountId']);
|
||||
|
||||
query.modify('filterDateRange', null, toDate);
|
||||
query.withGraphFetched('account');
|
||||
|
||||
this.commonFilterBranchesQuery(query);
|
||||
});
|
||||
};
|
||||
|
||||
public accountsDatePeriods = async (
|
||||
fromDate: moment.MomentInput,
|
||||
toDate: moment.MomentInput,
|
||||
datePeriodsType: IAccountTransactionsGroupBy,
|
||||
) => {
|
||||
return this.accountTransactionModel()
|
||||
.query()
|
||||
.onBuild((query: any) => {
|
||||
query.sum('credit as credit');
|
||||
query.sum('debit as debit');
|
||||
query.groupBy('accountId');
|
||||
query.select(['accountId']);
|
||||
|
||||
query.modify('groupByDateFormat', datePeriodsType);
|
||||
query.modify('filterDateRange', fromDate, toDate);
|
||||
query.withGraphFetched('account');
|
||||
|
||||
this.commonFilterBranchesQuery(query);
|
||||
});
|
||||
};
|
||||
|
||||
private commonFilterBranchesQuery = (query: any) => {
|
||||
if (!isEmpty(this.query.branchesIds)) {
|
||||
query.modify('filterByBranches', this.query.branchesIds);
|
||||
}
|
||||
};
|
||||
|
||||
public getAccounts = () => {
|
||||
return this.accountModel().query();
|
||||
};
|
||||
|
||||
public getAccountsByType = (type: string[] | string): ModelObject<Account>[] => {
|
||||
const types = castArray(type) as string[];
|
||||
const result: ModelObject<Account>[] = [];
|
||||
types.forEach((accountType) => {
|
||||
const accounts = this.accountsByType.get(accountType) || [];
|
||||
result.push(...accounts);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
public getBudgetAmountForAccounts = (accountIds: number[]): number => {
|
||||
let total = 0;
|
||||
accountIds.forEach((id) => {
|
||||
total += this.budgetTotalByAccount.get(id) || 0;
|
||||
});
|
||||
return total;
|
||||
};
|
||||
|
||||
public getActualClosingBalance = (accountIds: number[]): number => {
|
||||
return this.totalAccountsLedger
|
||||
.whereAccountsIds(accountIds)
|
||||
.getClosingBalance();
|
||||
};
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
class BudgetVsActualTotalDto {
|
||||
@ApiProperty({ description: 'Amount', example: 5000.0 })
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: 'Formatted amount', example: '$5,000.00' })
|
||||
formattedAmount: string;
|
||||
|
||||
@ApiProperty({ description: 'Currency code', example: 'USD' })
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
class BudgetVsActualPercentageDto {
|
||||
@ApiProperty({ description: 'Percentage amount', example: 0.15 })
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({ description: 'Formatted percentage', example: '15.00%' })
|
||||
formattedAmount: string;
|
||||
}
|
||||
|
||||
class BudgetVsActualDatePeriodDto {
|
||||
fromDate: { date: Date; formattedDate: string };
|
||||
toDate: { date: Date; formattedDate: string };
|
||||
budget: BudgetVsActualTotalDto;
|
||||
actual: BudgetVsActualTotalDto;
|
||||
variance: BudgetVsActualTotalDto;
|
||||
variancePercentage: BudgetVsActualPercentageDto;
|
||||
}
|
||||
|
||||
class BudgetVsActualDataNodeDto {
|
||||
@ApiProperty()
|
||||
id: string | number;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
code?: string;
|
||||
|
||||
@ApiProperty()
|
||||
nodeType: string;
|
||||
|
||||
@ApiProperty()
|
||||
budget: BudgetVsActualTotalDto;
|
||||
|
||||
@ApiProperty()
|
||||
actual: BudgetVsActualTotalDto;
|
||||
|
||||
@ApiProperty()
|
||||
variance: BudgetVsActualTotalDto;
|
||||
|
||||
@ApiProperty()
|
||||
variancePercentage: BudgetVsActualPercentageDto;
|
||||
|
||||
@ApiPropertyOptional({ type: [BudgetVsActualDataNodeDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BudgetVsActualDataNodeDto)
|
||||
children?: BudgetVsActualDataNodeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||
horizontalBudgets?: BudgetVsActualDatePeriodDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||
horizontalActuals?: BudgetVsActualDatePeriodDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||
horizontalVariances?: BudgetVsActualDatePeriodDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [BudgetVsActualDatePeriodDto] })
|
||||
horizontalVariancePercentages?: BudgetVsActualDatePeriodDto[];
|
||||
}
|
||||
|
||||
class BudgetVsActualMetaDto {
|
||||
@ApiProperty()
|
||||
organizationName: string;
|
||||
|
||||
@ApiProperty()
|
||||
baseCurrency: string;
|
||||
|
||||
@ApiProperty()
|
||||
dateFormat: string;
|
||||
|
||||
@ApiProperty()
|
||||
sheetName: string;
|
||||
|
||||
@ApiProperty()
|
||||
budgetName: string;
|
||||
|
||||
@ApiProperty()
|
||||
budgetType: string;
|
||||
|
||||
@ApiProperty()
|
||||
periodType: string;
|
||||
|
||||
@ApiProperty()
|
||||
fromDate: { date: Date; formattedDate: string };
|
||||
|
||||
@ApiProperty()
|
||||
toDate: { date: Date; formattedDate: string };
|
||||
}
|
||||
|
||||
export class BudgetVsActualResponseDto {
|
||||
@ApiProperty()
|
||||
query: any;
|
||||
|
||||
@ApiProperty({ type: [BudgetVsActualDataNodeDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BudgetVsActualDataNodeDto)
|
||||
data: BudgetVsActualDataNodeDto[];
|
||||
|
||||
@ApiProperty()
|
||||
meta: BudgetVsActualMetaDto;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { GConstructor } from '@/common/types/Constructor';
|
||||
import { FinancialSheet } from '../../common/FinancialSheet';
|
||||
import { FinancialSchema } from '../../common/FinancialSchema';
|
||||
import { BUDGET_TYPE } from './constants';
|
||||
import { getProfitLossSheetSchema } from '../ProfitLossSheet/ProfitLossSchema';
|
||||
import { getBalanceSheetSchema } from '../BalanceSheet/BalanceSheetSchema';
|
||||
|
||||
interface IBudgetVsActualSchemaContext {
|
||||
repository: { budget: { budgetType: string } };
|
||||
}
|
||||
|
||||
export const BudgetVsActualSchema = <T extends GConstructor<any>>(
|
||||
Base: T,
|
||||
) =>
|
||||
class extends FinancialSchema(Base) {
|
||||
getSchema = () => {
|
||||
const ctx = this as unknown as IBudgetVsActualSchemaContext;
|
||||
if (ctx.repository.budget.budgetType === BUDGET_TYPE.PROFIT_AND_LOSS) {
|
||||
return getProfitLossSheetSchema();
|
||||
}
|
||||
return getBalanceSheetSchema();
|
||||
};
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
IBudgetVsActualQuery,
|
||||
IBudgetVsActualMeta,
|
||||
IBudgetVsActualNode,
|
||||
} from './BudgetVsActual.types';
|
||||
import { BudgetVsActualSheet } from './BudgetVsActual';
|
||||
import { mergeQueryWithDefaults } from './utils';
|
||||
import { BudgetVsActualRepository } from './BudgetVsActualRepository';
|
||||
import { BudgetVsActualMeta } from './BudgetVsActualMeta';
|
||||
import { events } from '@/common/events/events';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { ERRORS } from '@/modules/Budgeting/constants';
|
||||
|
||||
@Injectable()
|
||||
export class BudgetVsActualService {
|
||||
constructor(
|
||||
private readonly budgetVsActualMeta: BudgetVsActualMeta,
|
||||
private readonly eventPublisher: EventEmitter2,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly budgetVsActualRepository: BudgetVsActualRepository,
|
||||
) {}
|
||||
|
||||
public budgetVsActual = async (
|
||||
query: IBudgetVsActualQuery,
|
||||
): Promise<{
|
||||
data: IBudgetVsActualNode[];
|
||||
query: IBudgetVsActualQuery;
|
||||
meta: IBudgetVsActualMeta;
|
||||
}> => {
|
||||
const filter = mergeQueryWithDefaults(query);
|
||||
|
||||
this.budgetVsActualRepository.setFilter(filter);
|
||||
await this.budgetVsActualRepository.asyncInitialize();
|
||||
|
||||
if (!this.budgetVsActualRepository.budget) {
|
||||
throw new ServiceError(ERRORS.BUDGET_NOT_FOUND);
|
||||
}
|
||||
|
||||
const meta = await this.budgetVsActualMeta.meta(
|
||||
filter,
|
||||
this.budgetVsActualRepository.budget,
|
||||
);
|
||||
|
||||
const budgetVsActualInstance = new BudgetVsActualSheet(
|
||||
this.budgetVsActualRepository,
|
||||
filter,
|
||||
this.i18nService,
|
||||
{ baseCurrency: meta.baseCurrency, dateFormat: meta.dateFormat },
|
||||
);
|
||||
|
||||
const data = budgetVsActualInstance.reportData();
|
||||
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.reports.onBudgetVsActualViewed,
|
||||
{ query: filter },
|
||||
);
|
||||
|
||||
return {
|
||||
query: filter,
|
||||
data,
|
||||
meta,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const BUDGET_VS_ACTUAL_NODE_TYPE = {
|
||||
ACCOUNT: 'ACCOUNT',
|
||||
ACCOUNTS: 'ACCOUNTS',
|
||||
AGGREGATE: 'AGGREGATE',
|
||||
EQUATION: 'EQUATION',
|
||||
NET_INCOME: 'NET_INCOME',
|
||||
} as const;
|
||||
|
||||
export const BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE = {
|
||||
TOTAL: 'total',
|
||||
DATE_PERIODS: 'date_periods',
|
||||
} as const;
|
||||
|
||||
export const BUDGET_TYPE = {
|
||||
PROFIT_AND_LOSS: 'profit_and_loss',
|
||||
BALANCE_SHEET: 'balance_sheet',
|
||||
} as const;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IBudgetVsActualQuery } from './BudgetVsActual.types';
|
||||
import { BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE } from './constants';
|
||||
|
||||
export const defaultQuery: Partial<IBudgetVsActualQuery> = {
|
||||
displayColumnsType: BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE.TOTAL,
|
||||
displayColumnsBy: 'month',
|
||||
numberFormat: {
|
||||
precision: 2,
|
||||
divideOn1000: false,
|
||||
showZero: false,
|
||||
formatMoney: 'total',
|
||||
negativeFormat: 'mines',
|
||||
},
|
||||
};
|
||||
|
||||
export const mergeQueryWithDefaults = (
|
||||
query: IBudgetVsActualQuery,
|
||||
): IBudgetVsActualQuery => {
|
||||
return {
|
||||
...defaultQuery,
|
||||
...query,
|
||||
numberFormat: {
|
||||
...defaultQuery.numberFormat,
|
||||
...(query.numberFormat || {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -38,6 +38,7 @@ export enum ReportsAction {
|
||||
READ_CASHFLOW_ACCOUNT_TRANSACTION = 'read-cashflow-account-transactions',
|
||||
READ_PROJECT_PROFITABILITY_SUMMARY = 'read-project-profitability-summary',
|
||||
READ_SALES_TAX_LIABILITY_SUMMARY = 'read-sales-tax-liability-summary',
|
||||
READ_BUDGET_VS_ACTUAL = 'read-budget-vs-actual',
|
||||
}
|
||||
|
||||
export interface IFinancialSheetBranchesQuery {
|
||||
|
||||
@@ -60,7 +60,8 @@ export enum AbilitySubject {
|
||||
CreditNote = 'CreditNode',
|
||||
VendorCredit = 'VendorCredit',
|
||||
Project = 'Project',
|
||||
TaxRate = 'TaxRate'
|
||||
TaxRate = 'TaxRate',
|
||||
Budget = 'Budget',
|
||||
}
|
||||
|
||||
export interface IRoleCreatedPayload {
|
||||
|
||||
@@ -37,6 +37,8 @@ import { RefundCreditNote } from '@/modules/CreditNoteRefunds/models/RefundCredi
|
||||
import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit';
|
||||
import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundVendorCredit';
|
||||
import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
|
||||
import { Budget } from '@/modules/Budgeting/models/Budget.model';
|
||||
import { BudgetEntry } from '@/modules/Budgeting/models/BudgetEntry.model';
|
||||
import { Model } from 'objection';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TenantUser } from './models/TenantUser.model';
|
||||
@@ -80,6 +82,8 @@ const models = [
|
||||
PaymentReceived,
|
||||
PaymentReceivedEntry,
|
||||
TenantUser,
|
||||
Budget,
|
||||
BudgetEntry,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user