diff --git a/packages/server/src/common/events/events.ts b/packages/server/src/common/events/events.ts index 22c770b71..32437c0c5 100644 --- a/packages/server/src/common/events/events.ts +++ b/packages/server/src/common/events/events.ts @@ -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', }, }; diff --git a/packages/server/src/database/tenant/migrations/20260419131233_create_budgets_and_budget_entries_tables.ts b/packages/server/src/database/tenant/migrations/20260419131233_create_budgets_and_budget_entries_tables.ts new file mode 100644 index 000000000..ddcc4fd20 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20260419131233_create_budgets_and_budget_entries_tables.ts @@ -0,0 +1,55 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +exports.down = function (knex) { + return knex.schema + .dropTableIfExists('budget_entries') + .dropTableIfExists('budgets'); +}; diff --git a/packages/server/src/modules/Accounts/AccountsApplication.service.ts b/packages/server/src/modules/Accounts/AccountsApplication.service.ts index 7eab22bfc..df1dfde5f 100644 --- a/packages/server/src/modules/Accounts/AccountsApplication.service.ts +++ b/packages/server/src/modules/Accounts/AccountsApplication.service.ts @@ -46,7 +46,6 @@ export class AccountsApplication { /** * Creates a new account. - * @param {number} tenantId * @param {IAccountCreateDTO} accountDTO * @returns {Promise} */ @@ -59,7 +58,6 @@ export class AccountsApplication { /** * Deletes the given account. - * @param {number} tenantId * @param {number} accountId * @returns {Promise} */ diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..12f0c8c00 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server/src/modules/Budgeting/Budgets.controller.ts b/packages/server/src/modules/Budgeting/Budgets.controller.ts new file mode 100644 index 000000000..d2a743f09 --- /dev/null +++ b/packages/server/src/modules/Budgeting/Budgets.controller.ts @@ -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 { + 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); + } +} diff --git a/packages/server/src/modules/Budgeting/Budgets.module.ts b/packages/server/src/modules/Budgeting/Budgets.module.ts new file mode 100644 index 000000000..1d70d07d2 --- /dev/null +++ b/packages/server/src/modules/Budgeting/Budgets.module.ts @@ -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 {} diff --git a/packages/server/src/modules/Budgeting/BudgetsApplication.service.ts b/packages/server/src/modules/Budgeting/BudgetsApplication.service.ts new file mode 100644 index 000000000..a23f5f840 --- /dev/null +++ b/packages/server/src/modules/Budgeting/BudgetsApplication.service.ts @@ -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 => { + return this.createBudgetService.createBudget(budgetDTO); + }; + + public editBudget = ( + budgetId: number, + budgetDTO: EditBudgetDto, + ): Promise => { + return this.editBudgetService.editBudget(budgetId, budgetDTO); + }; + + public deleteBudget = (budgetId: number): Promise => { + return this.deleteBudgetService.deleteBudget(budgetId); + }; + + public activateBudget = (budgetId: number): Promise => { + return this.activateBudgetService.activateBudget(budgetId); + }; + + public closeBudget = (budgetId: number): Promise => { + return this.closeBudgetService.closeBudget(budgetId); + }; + + public getBudget = (budgetId: number): Promise => { + 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, + ); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/ActivateBudget.service.ts b/packages/server/src/modules/Budgeting/commands/ActivateBudget.service.ts new file mode 100644 index 000000000..01b382b9a --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/ActivateBudget.service.ts @@ -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, + ) {} + + public activateBudget = async (budgetId: number): Promise => { + 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; + }); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/BudgetValidators.service.ts b/packages/server/src/modules/Budgeting/commands/BudgetValidators.service.ts new file mode 100644 index 000000000..c6777905a --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/BudgetValidators.service.ts @@ -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, + + @Inject(Budget.name) + private readonly budgetModel: TenantModelProxy, + ) {} + + 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); + } + } +} diff --git a/packages/server/src/modules/Budgeting/commands/BulkDeleteBudgets.service.ts b/packages/server/src/modules/Budgeting/commands/BulkDeleteBudgets.service.ts new file mode 100644 index 000000000..2a6fbab3f --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/BulkDeleteBudgets.service.ts @@ -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, + ) {} + + public bulkDeleteBudgets = async ( + budgetIds: number[], + options?: { skipUndeletable?: boolean }, + ): Promise => { + 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(); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/CloseBudget.service.ts b/packages/server/src/modules/Budgeting/commands/CloseBudget.service.ts new file mode 100644 index 000000000..1e3f82767 --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/CloseBudget.service.ts @@ -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, + ) {} + + public closeBudget = async (budgetId: number): Promise => { + 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; + }); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/CreateBudget.service.ts b/packages/server/src/modules/Budgeting/commands/CreateBudget.service.ts new file mode 100644 index 000000000..89dea8730 --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/CreateBudget.service.ts @@ -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, + ) {} + + public createBudget = async ( + budgetDTO: CreateBudgetDto, + trx?: Knex.Transaction, + ): Promise => { + 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); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/DeleteBudget.service.ts b/packages/server/src/modules/Budgeting/commands/DeleteBudget.service.ts new file mode 100644 index 000000000..b49d60871 --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/DeleteBudget.service.ts @@ -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, + ) {} + + public deleteBudget = async (budgetId: number): Promise => { + 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); + }); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/EditBudget.service.ts b/packages/server/src/modules/Budgeting/commands/EditBudget.service.ts new file mode 100644 index 000000000..90c80238b --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/EditBudget.service.ts @@ -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, + ) {} + + public editBudget = async ( + budgetId: number, + budgetDTO: EditBudgetDto, + ): Promise => { + 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; + }); + }; +} diff --git a/packages/server/src/modules/Budgeting/commands/ValidateBulkDeleteBudgets.service.ts b/packages/server/src/modules/Budgeting/commands/ValidateBulkDeleteBudgets.service.ts new file mode 100644 index 000000000..98d05448e --- /dev/null +++ b/packages/server/src/modules/Budgeting/commands/ValidateBulkDeleteBudgets.service.ts @@ -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, + ) {} + + 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, + }; + }; +} diff --git a/packages/server/src/modules/Budgeting/constants.ts b/packages/server/src/modules/Budgeting/constants.ts new file mode 100644 index 000000000..49d70213a --- /dev/null +++ b/packages/server/src/modules/Budgeting/constants.ts @@ -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', +}; diff --git a/packages/server/src/modules/Budgeting/dtos/BudgetEntry.dto.ts b/packages/server/src/modules/Budgeting/dtos/BudgetEntry.dto.ts new file mode 100644 index 000000000..c8cf99bbc --- /dev/null +++ b/packages/server/src/modules/Budgeting/dtos/BudgetEntry.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Budgeting/dtos/BudgetResponse.dto.ts b/packages/server/src/modules/Budgeting/dtos/BudgetResponse.dto.ts new file mode 100644 index 000000000..74037416d --- /dev/null +++ b/packages/server/src/modules/Budgeting/dtos/BudgetResponse.dto.ts @@ -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[]; +} diff --git a/packages/server/src/modules/Budgeting/dtos/CreateBudget.dto.ts b/packages/server/src/modules/Budgeting/dtos/CreateBudget.dto.ts new file mode 100644 index 000000000..de467ffa7 --- /dev/null +++ b/packages/server/src/modules/Budgeting/dtos/CreateBudget.dto.ts @@ -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 {} diff --git a/packages/server/src/modules/Budgeting/dtos/GetBudgetsQuery.dto.ts b/packages/server/src/modules/Budgeting/dtos/GetBudgetsQuery.dto.ts new file mode 100644 index 000000000..d29485e37 --- /dev/null +++ b/packages/server/src/modules/Budgeting/dtos/GetBudgetsQuery.dto.ts @@ -0,0 +1,3 @@ +import { DynamicFilterQueryDto } from '@/modules/DynamicListing/dtos/DynamicFilterQuery.dto'; + +export class GetBudgetsQueryDto extends DynamicFilterQueryDto {} diff --git a/packages/server/src/modules/Budgeting/models/Budget.meta.ts b/packages/server/src/modules/Budgeting/models/Budget.meta.ts new file mode 100644 index 000000000..2ef0c064d --- /dev/null +++ b/packages/server/src/modules/Budgeting/models/Budget.meta.ts @@ -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, + }, + }, +}; diff --git a/packages/server/src/modules/Budgeting/models/Budget.model.ts b/packages/server/src/modules/Budgeting/models/Budget.model.ts new file mode 100644 index 000000000..013bfe42b --- /dev/null +++ b/packages/server/src/modules/Budgeting/models/Budget.model.ts @@ -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' }, + ]; + } +} diff --git a/packages/server/src/modules/Budgeting/models/BudgetEntry.model.ts b/packages/server/src/modules/Budgeting/models/BudgetEntry.model.ts new file mode 100644 index 000000000..e1592c9af --- /dev/null +++ b/packages/server/src/modules/Budgeting/models/BudgetEntry.model.ts @@ -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', + }, + }, + }; + } +} diff --git a/packages/server/src/modules/Budgeting/queries/BudgetTransformer.ts b/packages/server/src/modules/Budgeting/queries/BudgetTransformer.ts new file mode 100644 index 000000000..817dad7bb --- /dev/null +++ b/packages/server/src/modules/Budgeting/queries/BudgetTransformer.ts @@ -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); + }; +} diff --git a/packages/server/src/modules/Budgeting/queries/GetBudget.service.ts b/packages/server/src/modules/Budgeting/queries/GetBudget.service.ts new file mode 100644 index 000000000..e3fdcaeff --- /dev/null +++ b/packages/server/src/modules/Budgeting/queries/GetBudget.service.ts @@ -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, + ) {} + + public getBudget = async (budgetId: number): Promise => { + const budget = await this.budgetModel() + .query() + .findById(budgetId) + .withGraphFetched('entries.account'); + + if (!budget) { + throw new ServiceError(ERRORS.BUDGET_NOT_FOUND); + } + + return budget; + }; +} diff --git a/packages/server/src/modules/Budgeting/queries/GetBudgets.service.ts b/packages/server/src/modules/Budgeting/queries/GetBudgets.service.ts new file mode 100644 index 000000000..482a1b47e --- /dev/null +++ b/packages/server/src/modules/Budgeting/queries/GetBudgets.service.ts @@ -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, + ) {} + + 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(), + }; + }; +} diff --git a/packages/server/src/modules/Budgeting/repositories/Budget.repository.ts b/packages/server/src/modules/Budgeting/repositories/Budget.repository.ts new file mode 100644 index 000000000..5beb67b78 --- /dev/null +++ b/packages/server/src/modules/Budgeting/repositories/Budget.repository.ts @@ -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'); + } +} diff --git a/packages/server/src/modules/Budgeting/types/Budgets.types.ts b/packages/server/src/modules/Budgeting/types/Budgets.types.ts new file mode 100644 index 000000000..433a68fdb --- /dev/null +++ b/packages/server/src/modules/Budgeting/types/Budgets.types.ts @@ -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; +} diff --git a/packages/server/src/modules/FinancialStatements/FinancialStatements.module.ts b/packages/server/src/modules/FinancialStatements/FinancialStatements.module.ts index bdedb8993..f71a15d77 100644 --- a/packages/server/src/modules/FinancialStatements/FinancialStatements.module.ts +++ b/packages/server/src/modules/FinancialStatements/FinancialStatements.module.ts @@ -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 {} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.controller.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.controller.ts new file mode 100644 index 000000000..26ba7adb5 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.controller.ts @@ -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); + } +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.module.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.module.ts new file mode 100644 index 000000000..6d5f29da4 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.module.ts @@ -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 {} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.ts new file mode 100644 index 000000000..190509852 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.ts @@ -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, + ): 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): 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; + const budgetTotal = this.evaluateEquation(equation, budgetTable); + + const actualTable = this.getNodesTableForEvaluating( + 'actual.amount', + accNodes, + ) as Record; + 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); + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.types.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.types.ts new file mode 100644 index 000000000..a56f9f063 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActual.types.ts @@ -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; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualApplication.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualApplication.ts new file mode 100644 index 000000000..0a848a64f --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualApplication.ts @@ -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); + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualBase.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualBase.ts new file mode 100644 index 000000000..8a1d23436 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualBase.ts @@ -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 = >( + 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'); + }; + }; diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualDatePeriods.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualDatePeriods.ts new file mode 100644 index 000000000..c15af77c9 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualDatePeriods.ts @@ -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; + evaluateEquation: (equation: string, scope: Record) => number; +}; + +export const BudgetVsActualDatePeriods = >( + 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; + const budgetPeriodAmount = ctx.evaluateEquation(equation, budgetTable); + + const actualTable = ctx.getNodesTableForEvaluating( + `horizontalActuals.${index}.total.amount`, + accNodes, + ) as Record; + 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, + }; + }, + ); + }; diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualMeta.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualMeta.ts new file mode 100644 index 000000000..4115688ff --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualMeta.ts @@ -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 => { + 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), + }, + }; + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.dto.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.dto.ts new file mode 100644 index 000000000..d165e3ce4 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.ts new file mode 100644 index 000000000..a2948c856 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualQuery.ts @@ -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'; + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualRepository.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualRepository.ts new file mode 100644 index 000000000..90a70e7ec --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualRepository.ts @@ -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; + + @Inject(AccountTransaction.name) + public accountTransactionModel: TenantModelProxy; + + @Inject(Budget.name) + public budgetModel: TenantModelProxy; + + @Inject(BudgetEntry.name) + public budgetEntryModel: TenantModelProxy; + + @Inject(TenancyContext) + public tenancyContext: TenancyContext; + + public baseCurrency: string; + public accounts: ModelObject[]; + public accountsByType: Map[]>; + public accountsByParentType: Map[]>; + 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; + public budgetPeriodsByAccount: Map>; + + 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()); + }; + + 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>(); + 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[] => { + const types = castArray(type) as string[]; + const result: ModelObject[] = []; + 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(); + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualResponse.dto.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualResponse.dto.ts new file mode 100644 index 000000000..35161c1ed --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualResponse.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualSchema.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualSchema.ts new file mode 100644 index 000000000..2c1c0cddf --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualSchema.ts @@ -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 = >( + 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(); + }; + }; diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualService.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualService.ts new file mode 100644 index 000000000..15683cd27 --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/BudgetVsActualService.ts @@ -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, + }; + }; +} diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/constants.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/constants.ts new file mode 100644 index 000000000..8ce20a40a --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/constants.ts @@ -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; diff --git a/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/utils.ts b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/utils.ts new file mode 100644 index 000000000..6f159672e --- /dev/null +++ b/packages/server/src/modules/FinancialStatements/modules/BudgetVsActual/utils.ts @@ -0,0 +1,27 @@ +import { IBudgetVsActualQuery } from './BudgetVsActual.types'; +import { BUDGET_VS_ACTUAL_DISPLAY_COLUMNS_TYPE } from './constants'; + +export const defaultQuery: Partial = { + 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 || {}), + }, + }; +}; diff --git a/packages/server/src/modules/FinancialStatements/types/Report.types.ts b/packages/server/src/modules/FinancialStatements/types/Report.types.ts index 9327c2a67..9a86f5a13 100644 --- a/packages/server/src/modules/FinancialStatements/types/Report.types.ts +++ b/packages/server/src/modules/FinancialStatements/types/Report.types.ts @@ -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 { diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 258b76509..d45def648 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -60,7 +60,8 @@ export enum AbilitySubject { CreditNote = 'CreditNode', VendorCredit = 'VendorCredit', Project = 'Project', - TaxRate = 'TaxRate' + TaxRate = 'TaxRate', + Budget = 'Budget', } export interface IRoleCreatedPayload { diff --git a/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index 0bc1d639c..06027d319 100644 --- a/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -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, ]; /**