1
0

feat(server): add budgeting CRUD and budget vs actual report

Implement budget management with CRUD operations and a Budget vs Actual
financial report that supports both Profit & Loss and Balance Sheet
budget types.

Budget CRUD:
- Budgets and BudgetEntries models with Objection.js
- Create, edit, delete, activate, close, bulk-delete operations
- Budget validators (date range, account existence, status transitions)
- Event-driven architecture with lifecycle hooks
- CASL permission guards and Swagger API docs

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