1
0
This commit is contained in:
Ahmed Bouhuolia
2026-05-18 16:08:33 +02:00
parent c69515f618
commit 73578ab902
6 changed files with 18 additions and 647 deletions
@@ -7,7 +7,6 @@ import { DeleteWorkspaceJobService } from './commands/DeleteWorkspaceJob.service
import { InactivateWorkspaceService } from './commands/InactivateWorkspace.service';
import { SetDefaultWorkspaceService } from './commands/SetDefaultWorkspace.service';
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
import { GetWorkspacesFinancialService } from './queries/GetWorkspacesFinancial.service';
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
import { CreateUserTenantOnSignupSubscriber } from './subscribers/CreateUserTenantOnSignup.subscriber';
import { WorkspaceCreatedSubscriber } from './subscribers/WorkspaceCreated.subscriber';
@@ -47,7 +46,6 @@ import { WorkspaceDeletedSubscriber } from './subscribers/WorkspaceDeleted.subsc
InactivateWorkspaceService,
SetDefaultWorkspaceService,
GetWorkspacesService,
GetWorkspacesFinancialService,
GetWorkspaceBuildJobService,
CreateUserTenantOnSignupSubscriber,
WorkspaceCreatedSubscriber,
@@ -1,382 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateWorkspaceService } from './CreateWorkspace.service';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
import { BuildOrganizationService } from '@/modules/Organization/commands/BuildOrganization.service';
import { SystemKnexConnection } from '@/modules/System/SystemDB/SystemDB.constants';
import { events } from '@/common/events/events';
// Mock the Organization.utils module
jest.mock('@/modules/Organization/Organization.utils', () => ({
transformBuildDto: jest.fn((dto) => ({
...dto,
dateFormat: dto.dateFormat || 'DD MMM YYYY',
})),
}));
describe('CreateWorkspaceService', () => {
let service: CreateWorkspaceService;
let tenantRepository: jest.Mocked<any>;
let userTenantModel: jest.Mocked<any>;
let mockBuildOrganizationService: jest.Mocked<BuildOrganizationService>;
let mockEventEmitter: jest.Mocked<EventEmitter2>;
let mockKnexTransaction: jest.Mock;
const mockTenant = {
id: 1,
organizationId: 'org_abc123',
initializedAt: null,
seededAt: null,
builtAt: null,
buildJobId: null,
};
const createMockQuery = () => ({
insert: jest.fn().mockResolvedValue({ id: 1, userId: 1, tenantId: 1, role: 'owner' }),
findById: jest.fn().mockResolvedValue(mockTenant),
update: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
});
beforeEach(async () => {
const mockQuery = createMockQuery();
const mockUserTenantModel = {
query: jest.fn().mockReturnValue(mockQuery),
};
const mockTenantRepository = {
createWithUniqueOrgId: jest.fn().mockResolvedValue(mockTenant),
saveMetadata: jest.fn().mockResolvedValue(undefined),
markAsBuilding: jest.fn().mockReturnThis(),
findById: jest.fn().mockResolvedValue(mockTenant),
};
// Mock build organization service
mockBuildOrganizationService = {
buildForTenant: jest.fn().mockResolvedValue(undefined),
} as any;
// Mock event emitter
mockEventEmitter = {
emitAsync: jest.fn().mockResolvedValue([]),
} as any;
// Mock knex transaction
mockKnexTransaction = jest.fn(async (callback) => {
const trx = {};
return callback(trx);
});
const mockSystemKnex = {
transaction: mockKnexTransaction,
};
const module: TestingModule = await Test.createTestingModule({
providers: [
CreateWorkspaceService,
{
provide: UserTenant.name,
useValue: mockUserTenantModel,
},
{
provide: TenantRepository,
useValue: mockTenantRepository,
},
{
provide: BuildOrganizationService,
useValue: mockBuildOrganizationService,
},
{
provide: SystemKnexConnection,
useValue: mockSystemKnex,
},
{
provide: EventEmitter2,
useValue: mockEventEmitter,
},
],
}).compile();
service = module.get<CreateWorkspaceService>(CreateWorkspaceService);
tenantRepository = module.get(TenantRepository);
userTenantModel = module.get(UserTenant.name);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('createWorkspace', () => {
const userId = 1;
const dto = {
name: 'Test Organization',
baseCurrency: 'USD',
location: 'US',
timezone: 'America/New_York',
fiscalYear: 'January',
language: 'en-US',
industry: 'Technology',
};
it('should create a workspace successfully', async () => {
const result = await service.createWorkspace(userId, dto);
expect(result).toEqual({
organizationId: mockTenant.organizationId,
jobId: null,
});
});
it('should wrap database operations in a transaction', async () => {
await service.createWorkspace(userId, dto);
expect(mockKnexTransaction).toHaveBeenCalledTimes(1);
});
it('should create a new tenant with unique organization id within transaction', async () => {
await service.createWorkspace(userId, dto);
expect(tenantRepository.createWithUniqueOrgId).toHaveBeenCalledTimes(1);
expect(tenantRepository.createWithUniqueOrgId).toHaveBeenCalledWith(undefined, expect.anything());
});
it('should link the user as owner of the workspace within transaction', async () => {
await service.createWorkspace(userId, dto);
expect(userTenantModel.query).toHaveBeenCalled();
// First call should be with the transaction object
expect(userTenantModel.query.mock.calls[0][0]).toBeDefined();
});
it('should save organization metadata within transaction', async () => {
await service.createWorkspace(userId, dto);
expect(tenantRepository.saveMetadata).toHaveBeenCalledWith(
mockTenant.id,
expect.objectContaining({
name: dto.name,
baseCurrency: dto.baseCurrency,
location: dto.location,
timezone: dto.timezone,
fiscalYear: dto.fiscalYear,
language: dto.language,
industry: dto.industry,
dateFormat: 'DD MMM YYYY',
}),
expect.anything(), // transaction object
);
});
it('should call buildForTenant after transaction commit', async () => {
await service.createWorkspace(userId, dto);
expect(mockBuildOrganizationService.buildForTenant).toHaveBeenCalledWith(
mockTenant.id,
userId,
expect.objectContaining({
name: dto.name,
baseCurrency: dto.baseCurrency,
location: dto.location,
timezone: dto.timezone,
fiscalYear: dto.fiscalYear,
language: dto.language,
industry: dto.industry,
dateFormat: 'DD MMM YYYY',
}),
);
});
it('should return organization id with null job id', async () => {
const result = await service.createWorkspace(userId, dto);
expect(result).toHaveProperty('organizationId');
expect(result).toHaveProperty('jobId');
expect(result.organizationId).toBe(mockTenant.organizationId);
expect(result.jobId).toBeNull();
});
it('should handle tenant creation failure and rollback transaction', async () => {
tenantRepository.createWithUniqueOrgId.mockRejectedValueOnce(
new Error('Database error'),
);
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
'Database error',
);
});
it('should handle user tenant linking failure and rollback transaction', async () => {
const mockQuery = createMockQuery();
mockQuery.insert.mockRejectedValueOnce(new Error('Linking error'));
userTenantModel.query.mockReturnValueOnce(mockQuery);
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
'Linking error',
);
});
it('should handle metadata save failure and rollback transaction', async () => {
tenantRepository.saveMetadata.mockRejectedValueOnce(
new Error('Metadata save error'),
);
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
'Metadata save error',
);
});
it('should not call buildForTenant if transaction fails', async () => {
tenantRepository.createWithUniqueOrgId.mockRejectedValueOnce(
new Error('Database error'),
);
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
'Database error',
);
expect(mockBuildOrganizationService.buildForTenant).not.toHaveBeenCalled();
});
it('should handle buildForTenant failure after successful transaction', async () => {
mockBuildOrganizationService.buildForTenant.mockRejectedValueOnce(
new Error('Build error'),
);
// Transaction should succeed but then build should fail
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
'Build error',
);
// Transaction should have completed
expect(tenantRepository.createWithUniqueOrgId).toHaveBeenCalled();
});
it('should work with minimal DTO (only required fields)', async () => {
const minimalDto = {
name: 'Minimal Org',
baseCurrency: 'EUR',
location: 'DE',
timezone: 'Europe/Berlin',
fiscalYear: 'January',
language: 'en-US',
};
const result = await service.createWorkspace(userId, minimalDto);
expect(result.organizationId).toBe(mockTenant.organizationId);
expect(tenantRepository.saveMetadata).toHaveBeenCalledWith(
mockTenant.id,
expect.objectContaining({
name: minimalDto.name,
baseCurrency: minimalDto.baseCurrency,
location: minimalDto.location,
timezone: minimalDto.timezone,
fiscalYear: minimalDto.fiscalYear,
language: minimalDto.language,
dateFormat: 'DD MMM YYYY',
}),
expect.anything(),
);
});
it('should preserve custom date format if provided', async () => {
const dtoWithDateFormat = {
...dto,
dateFormat: 'MM/DD/YYYY',
};
await service.createWorkspace(userId, dtoWithDateFormat);
expect(tenantRepository.saveMetadata).toHaveBeenCalledWith(
mockTenant.id,
expect.objectContaining({
dateFormat: 'MM/DD/YYYY',
}),
expect.anything(),
);
});
it('should call all operations in correct sequence', async () => {
const callOrder: string[] = [];
(tenantRepository.createWithUniqueOrgId as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('createTenant');
return mockTenant;
});
(userTenantModel.query as jest.Mock).mockImplementationOnce(() => {
callOrder.push('linkUser');
return {
insert: jest.fn().mockResolvedValue({ id: 1 }),
};
});
(tenantRepository.saveMetadata as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('saveMetadata');
return 1;
});
mockKnexTransaction.mockImplementationOnce(async (callback) => {
const trx = {};
await callback(trx);
callOrder.push('transactionCommitted');
return mockTenant;
});
(mockEventEmitter.emitAsync as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('emitEvent');
return [];
});
(mockBuildOrganizationService.buildForTenant as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('buildForTenant');
});
await service.createWorkspace(userId, dto);
expect(callOrder).toEqual(['createTenant', 'linkUser', 'saveMetadata', 'transactionCommitted', 'emitEvent', 'buildForTenant']);
});
it('should emit workspace created event after transaction commit', async () => {
await service.createWorkspace(userId, dto);
expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith(
events.workspace.created,
expect.objectContaining({
tenantId: mockTenant.id,
organizationId: mockTenant.organizationId,
userId,
buildDTO: expect.objectContaining({
name: dto.name,
baseCurrency: dto.baseCurrency,
location: dto.location,
}),
}),
);
});
it('should emit event after transaction commit and before build', async () => {
const callOrder: string[] = [];
mockKnexTransaction.mockImplementationOnce(async (callback) => {
const trx = {};
const result = await callback(trx);
callOrder.push('transactionCommitted');
return result;
});
(mockEventEmitter.emitAsync as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('emitEvent');
return [];
});
(mockBuildOrganizationService.buildForTenant as jest.Mock).mockImplementationOnce(async () => {
callOrder.push('buildForTenant');
});
await service.createWorkspace(userId, dto);
expect(callOrder).toEqual(['transactionCommitted', 'emitEvent', 'buildForTenant']);
});
});
});
@@ -68,7 +68,6 @@ export class InactivateWorkspaceService {
if (!membership) {
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND, 'Workspace not found');
}
if (membership.role !== 'owner') {
throw new ServiceError(
WorkspacesError.NOT_WORKSPACE_OWNER,
@@ -3,7 +3,6 @@ import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
import { WorkspaceTransformer } from '../transformers/WorkspaceTransformer';
import { GetWorkspacesFinancialService } from './GetWorkspacesFinancial.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
@Injectable()
@@ -15,7 +14,6 @@ export class GetWorkspacesService {
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
private readonly financialService: GetWorkspacesFinancialService,
private readonly transformer: TransformerInjectable,
) {}
@@ -44,22 +42,11 @@ export class GetWorkspacesService {
const defaultTenantId = user?.defaultTenantId;
// Fetch financial data for all workspaces
const workspaceInfos = memberships.map((m) => ({
tenantId: m.tenantId,
organizationId: m.tenant?.organizationId,
isReady: m.tenant?.isReady ?? false,
}));
const financialDataMap =
await this.financialService.getWorkspacesFinancial(workspaceInfos);
return this.transformer.transform(
memberships,
new WorkspaceTransformer(),
{
defaultTenantId,
financialDataMap,
includeInactive,
currentOrganizationId,
},
@@ -1,201 +0,0 @@
import { Injectable } from '@nestjs/common';
import * as moment from 'moment';
import { Knex } from 'knex';
import { ConfigService } from '@nestjs/config';
import { knexSnakeCaseMappers } from 'objection';
import { ACCOUNT_TYPE } from '@/constants/accounts';
import { ACCOUNT_ROOT_TYPE } from '@/modules/Accounts/Accounts.constants';
interface WorkspaceFinancialData {
tenantId: number;
totalIncome: number;
totalExpenses: number;
totalAssets: number;
totalLiabilities: number;
}
interface AccountTransaction {
credit: number;
debit: number;
accountNormal: string;
accountRootType: string;
}
/**
* Service to retrieve financial data (income and expenses) for multiple workspaces.
* This service creates dynamic connections to tenant databases to fetch aggregated data.
*/
@Injectable()
export class GetWorkspacesFinancialService {
constructor(private readonly configService: ConfigService) {}
/**
* Get a knex instance for a specific tenant database.
*/
private getTenantKnex(organizationId: string): Knex {
const database = `bigcapital_tenant_${organizationId}`;
return require('knex')({
client: this.configService.get('tenantDatabase.client'),
connection: {
host: this.configService.get('tenantDatabase.host'),
user: this.configService.get('tenantDatabase.user'),
password: this.configService.get('tenantDatabase.password'),
database,
charset: 'utf8',
},
pool: { min: 0, max: 2, acquireTimeoutMillis: 5000, idleTimeoutMillis: 10000 },
...knexSnakeCaseMappers({ upperCase: true }),
});
}
/**
* Calculate total income from account transactions.
* Income accounts have credit normal, so income = credit - debit.
*/
private calculateIncome(transactions: AccountTransaction[]): number {
return transactions
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.INCOME)
.reduce((sum, t) => sum + (t.credit - t.debit), 0);
}
/**
* Calculate total expenses from account transactions.
* Expense accounts have debit normal, so expenses = debit - credit.
*/
private calculateExpenses(transactions: AccountTransaction[]): number {
return transactions
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.EXPENSE)
.reduce((sum, t) => sum + (t.debit - t.credit), 0);
}
/**
* Calculate total assets from account transactions.
* Asset accounts have debit normal, so assets = debit - credit.
*/
private calculateAssets(transactions: AccountTransaction[]): number {
return transactions
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.ASSET)
.reduce((sum, t) => sum + (t.debit - t.credit), 0);
}
/**
* Calculate total liabilities from account transactions.
* Liability accounts have credit normal, so liabilities = credit - debit.
*/
private calculateLiabilities(transactions: AccountTransaction[]): number {
return transactions
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.LIABILITY)
.reduce((sum, t) => sum + (t.credit - t.debit), 0);
}
/**
* Fetch financial data for a single tenant.
*/
private async fetchTenantFinancialData(
tenantId: number,
organizationId: string,
): Promise<WorkspaceFinancialData> {
const knex = this.getTenantKnex(organizationId);
try {
// Get the current year date range
const fromDate = moment().startOf('year').format('YYYY-MM-DD');
const toDate = moment().endOf('year').format('YYYY-MM-DD');
// Query to get aggregated transactions by account with account type info
const transactions = await knex('accounts_transactions as at')
.join('accounts as a', 'at.account_id', 'a.id')
.whereBetween('at.date', [fromDate, toDate])
.select(
knex.raw('SUM(at.credit) as credit'),
knex.raw('SUM(at.debit) as debit'),
'a.account_normal as accountNormal',
'a.root_type as accountRootType',
)
.groupBy('at.account_id', 'a.account_normal', 'a.root_type');
const totalIncome = this.calculateIncome(transactions);
const totalExpenses = this.calculateExpenses(transactions);
// Query account transactions for assets and liabilities (cumulative balance)
const assetLiabilityTransactions = await knex('accounts_transactions as at')
.join('accounts as a', 'at.account_id', 'a.id')
.whereIn('a.root_type', [ACCOUNT_ROOT_TYPE.ASSET, ACCOUNT_ROOT_TYPE.LIABILITY])
.select(
knex.raw('SUM(at.credit) as credit'),
knex.raw('SUM(at.debit) as debit'),
'a.account_normal as accountNormal',
'a.root_type as accountRootType',
)
.groupBy('at.account_id', 'a.account_normal', 'a.root_type');
const totalAssets = this.calculateAssets(assetLiabilityTransactions);
const totalLiabilities = this.calculateLiabilities(assetLiabilityTransactions);
return {
tenantId,
totalIncome: Math.max(0, totalIncome),
totalExpenses: Math.max(0, totalExpenses),
totalAssets,
totalLiabilities,
};
} catch (error) {
// If tenant database doesn't exist or other error, return zeros
return {
tenantId,
totalIncome: 0,
totalExpenses: 0,
totalAssets: 0,
totalLiabilities: 0,
};
} finally {
await knex.destroy();
}
}
/**
* Get financial data (total income and expenses) for a list of workspaces.
* @param workspaces - Array of workspace info with tenantId and organizationId
* @returns Map of tenantId to financial data
*/
async getWorkspacesFinancial(
workspaces: Array<{ tenantId: number; organizationId: string; isReady: boolean }>,
): Promise<Map<number, WorkspaceFinancialData>> {
const results = new Map<number, WorkspaceFinancialData>();
// Filter only ready workspaces (have initialized databases)
const readyWorkspaces = workspaces.filter((w) => w.isReady);
// Fetch financial data for each workspace in parallel
const promises = readyWorkspaces.map(async (workspace) => {
const data = await this.fetchTenantFinancialData(
workspace.tenantId,
workspace.organizationId,
);
return data;
});
const financialDataList = await Promise.all(promises);
// Build the map
financialDataList.forEach((data) => {
results.set(data.tenantId, data);
});
// Add zero values for non-ready workspaces
workspaces
.filter((w) => !w.isReady)
.forEach((w) => {
results.set(w.tenantId, {
tenantId: w.tenantId,
totalIncome: 0,
totalExpenses: 0,
totalAssets: 0,
totalLiabilities: 0,
});
});
return results;
}
}
@@ -1,15 +1,6 @@
import { Transformer } from '@/modules/Transformer/Transformer';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
import { formatNumber } from '@/utils/format-number';
interface FinancialData {
tenantId: number;
totalIncome: number;
totalExpenses: number;
totalAssets: number;
totalLiabilities: number;
}
/**
* Transforms UserTenant (workspace membership) to WorkspaceDto.
@@ -108,67 +99,49 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
/**
* Get total income from financial data.
*/
protected totalIncome = (financialData?: FinancialData): number => {
return financialData?.totalIncome ?? 0;
protected totalIncome = (): undefined => {
return undefined;
};
/**
* Get total expenses from financial data.
*/
protected totalExpenses = (financialData?: FinancialData): number => {
return financialData?.totalExpenses ?? 0;
protected totalExpenses = (): undefined => {
return undefined;
};
/**
* Get total assets from financial data.
*/
protected totalAssets = (financialData?: FinancialData): number => {
return financialData?.totalAssets ?? 0;
protected totalAssets = (): undefined => {
return undefined;
};
/**
* Get total liabilities from financial data.
*/
protected totalLiabilities = (financialData?: FinancialData): number => {
return financialData?.totalLiabilities ?? 0;
protected totalLiabilities = (): undefined => {
return undefined;
};
/**
* Get formatted total assets.
*/
protected formattedTotalAssets = (
membership: UserTenant,
financialData?: FinancialData,
): string => {
const currencyCode = membership.tenant?.metadata?.baseCurrency;
return formatNumber(financialData?.totalAssets ?? 0, {
currencyCode,
money: true,
});
protected formattedTotalAssets = (): string => {
return '-';
};
/**
* Get formatted total liabilities.
*/
protected formattedTotalLiabilities = (
membership: UserTenant,
financialData?: FinancialData,
): string => {
const currencyCode = membership.tenant?.metadata?.baseCurrency;
return formatNumber(financialData?.totalLiabilities ?? 0, {
currencyCode,
money: true,
});
protected formattedTotalLiabilities = (): string => {
return '-';
};
/**
* Transform single membership to WorkspaceDto.
*/
transform = (membership: UserTenant): WorkspaceDto => {
const financialData = (
this.options?.financialDataMap as Map<number, FinancialData>
)?.get(membership.tenantId);
return {
organizationId: this.organizationId(membership),
isReady: this.isReady(membership),
@@ -179,15 +152,12 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
role: membership.role,
isDefault: this.isDefault(membership),
metadata: this.metadata(membership),
totalIncome: this.totalIncome(financialData),
totalExpenses: this.totalExpenses(financialData),
totalAssets: this.totalAssets(financialData),
totalLiabilities: this.totalLiabilities(financialData),
formattedTotalAssets: this.formattedTotalAssets(membership, financialData),
formattedTotalLiabilities: this.formattedTotalLiabilities(
membership,
financialData,
),
totalIncome: this.totalIncome(),
totalExpenses: this.totalExpenses(),
totalAssets: this.totalAssets(),
totalLiabilities: this.totalLiabilities(),
formattedTotalAssets: this.formattedTotalAssets(),
formattedTotalLiabilities: this.formattedTotalLiabilities(),
};
};