wip
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user