15 KiB
Financial Module TypeORM Migration Guide
Overview
This guide documents the migration of the Financial module from raw SQL queries to TypeORM. The migration maintains backwards compatibility while introducing modern ORM patterns.
Completed Tasks
1. Entity Creation ✅
All TypeORM entities have been created in /src/modules/financial/entities/:
- account-type.entity.ts - Chart of account types catalog
- account.entity.ts - Accounts with hierarchy support
- journal.entity.ts - Accounting journals
- journal-entry.entity.ts - Journal entries (header)
- journal-entry-line.entity.ts - Journal entry lines (detail)
- invoice.entity.ts - Customer and supplier invoices
- invoice-line.entity.ts - Invoice line items
- payment.entity.ts - Payment transactions
- tax.entity.ts - Tax configuration
- fiscal-year.entity.ts - Fiscal years
- fiscal-period.entity.ts - Fiscal periods (months/quarters)
- index.ts - Barrel export file
2. Entity Registration ✅
All financial entities have been registered in /src/config/typeorm.ts:
- Import statements added
- Entities added to the
entitiesarray in AppDataSource configuration
3. Service Refactoring ✅
accounts.service.ts - COMPLETED
The accounts service has been fully migrated to TypeORM with the following features:
Key Changes:
- Uses
Repository<Account>andRepository<AccountType> - Implements QueryBuilder for complex queries with joins
- Supports both snake_case (DB) and camelCase (TS) through decorators
- Maintains all original functionality including:
- Account hierarchy with cycle detection
- Soft delete with validation
- Balance calculations
- Full CRUD operations
Pattern to Follow:
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { Entity } from './entities/index.js';
class MyService {
private repository: Repository<Entity>;
constructor() {
this.repository = AppDataSource.getRepository(Entity);
}
async findAll(tenantId: string, filters = {}) {
const queryBuilder = this.repository
.createQueryBuilder('alias')
.leftJoin('alias.relation', 'relation')
.addSelect(['relation.field'])
.where('alias.tenantId = :tenantId', { tenantId });
// Apply filters
// Get count and results
return { data, total };
}
}
Remaining Tasks
Services to Migrate
1. journals.service.ts - PRIORITY HIGH
Current State: Uses raw SQL queries Target Pattern: Same as accounts.service.ts
Migration Steps:
- Import Journal entity and Repository
- Replace all
query()andqueryOne()calls with Repository methods - Use QueryBuilder for complex queries with joins (company, account, currency)
- Update return types to use entity types instead of interfaces
- Maintain validation logic for:
- Unique code per company
- Journal entry existence check before delete
- Test endpoints thoroughly
Key Relationships:
- Journal → Company (ManyToOne)
- Journal → Account (default account, ManyToOne, optional)
2. taxes.service.ts - PRIORITY HIGH
Current State: Uses raw SQL queries Special Feature: Tax calculation logic
Migration Steps:
- Import Tax entity and Repository
- Migrate CRUD operations to Repository
- IMPORTANT: Keep
calculateTaxes()andcalculateDocumentTaxes()logic intact - These calculation methods can still use raw queries if needed
- Update filters to use QueryBuilder
Tax Calculation Logic:
- Located in lines 224-354 of current service
- Critical for invoice and payment processing
- DO NOT modify calculation algorithms
- Only update data access layer
3. journal-entries.service.ts - PRIORITY MEDIUM
Current State: Uses raw SQL with transactions Complexity: HIGH - Multi-table operations
Migration Steps:
- Import JournalEntry, JournalEntryLine entities
- Use TypeORM QueryRunner for transactions:
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Operations
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
- Double-Entry Balance Validation:
- Keep validation logic lines 172-177
- Validate debit = credit before saving
- Use cascade operations for lines:
cascade: trueis already set in entity- Can save entry with lines in single operation
Critical Features:
- Transaction management (BEGIN/COMMIT/ROLLBACK)
- Balance validation (debits must equal credits)
- Status transitions (draft → posted → cancelled)
- Fiscal period validation
4. invoices.service.ts - PRIORITY MEDIUM
Current State: Uses raw SQL with complex line management Complexity: HIGH - Invoice lines, tax calculations
Migration Steps:
- Import Invoice, InvoiceLine entities
- Use transactions for multi-table operations
- Tax Integration:
- Line 331-340: Uses taxesService.calculateTaxes()
- Keep this integration intact
- Only migrate data access
- Amount Calculations:
- updateTotals() method (lines 525-543)
- Can use QueryBuilder aggregation or raw SQL
- Number Generation:
- Lines 472-478: Sequential invoice numbering
- Keep this logic, migrate to Repository
Relationships:
- Invoice → Company
- Invoice → Journal (optional)
- Invoice → JournalEntry (optional, for accounting integration)
- Invoice → InvoiceLine[] (one-to-many, cascade)
- InvoiceLine → Account (optional)
5. payments.service.ts - PRIORITY MEDIUM
Current State: Uses raw SQL with invoice reconciliation Complexity: MEDIUM-HIGH - Payment-Invoice linking
Migration Steps:
- Import Payment entity
- Payment-Invoice Junction:
- Table:
financial.payment_invoice - Not modeled as entity (junction table)
- Can use raw SQL for this or create entity
- Table:
- Use transactions for reconciliation
- Invoice Status Updates:
- Lines 373-380: Updates invoice amounts
- Must coordinate with Invoice entity
Critical Logic:
- Reconciliation workflow (lines 314-401)
- Invoice amount updates
- Transaction rollback on errors
6. fiscalPeriods.service.ts - PRIORITY LOW
Current State: Uses raw SQL + database functions Complexity: MEDIUM - Database function calls
Migration Steps:
- Import FiscalYear, FiscalPeriod entities
- Basic CRUD can use Repository
- Database Functions:
- Line 242:
financial.close_fiscal_period() - Line 265:
financial.reopen_fiscal_period() - Keep these as raw SQL calls:
await this.repository.query( 'SELECT * FROM financial.close_fiscal_period($1, $2)', [periodId, userId] ); - Line 242:
- Date Overlap Validation:
- Lines 102-107, 207-212
- Use QueryBuilder with date range checks
Controller Updates
Accept Both snake_case and camelCase
The controller currently only accepts snake_case. Update to support both:
Current:
const createAccountSchema = z.object({
company_id: z.string().uuid(),
code: z.string(),
// ...
});
Updated:
const createAccountSchema = z.object({
companyId: z.string().uuid().optional(),
company_id: z.string().uuid().optional(),
code: z.string(),
// ...
}).refine(
(data) => data.companyId || data.company_id,
{ message: "Either companyId or company_id is required" }
);
// Then normalize before service call:
const dto = {
companyId: parseResult.data.companyId || parseResult.data.company_id,
// ... rest of fields
};
Simpler Approach: Transform incoming data before validation:
// Add utility function
function toCamelCase(obj: any): any {
const camelObj: any = {};
for (const key in obj) {
const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
camelObj[camelKey] = obj[key];
}
return camelObj;
}
// Use in controller
const normalizedBody = toCamelCase(req.body);
const parseResult = createAccountSchema.safeParse(normalizedBody);
Migration Patterns
1. Repository Setup
import { Repository, IsNull } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { MyEntity } from './entities/index.js';
class MyService {
private repository: Repository<MyEntity>;
constructor() {
this.repository = AppDataSource.getRepository(MyEntity);
}
}
2. Simple Find Operations
Before (Raw SQL):
const result = await queryOne<Entity>(
`SELECT * FROM schema.table WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
After (TypeORM):
const result = await this.repository.findOne({
where: { id, tenantId, deletedAt: IsNull() }
});
3. Complex Queries with Joins
Before:
const data = await query<Entity>(
`SELECT e.*, r.name as relation_name
FROM schema.entities e
LEFT JOIN schema.relations r ON e.relation_id = r.id
WHERE e.tenant_id = $1`,
[tenantId]
);
After:
const data = await this.repository
.createQueryBuilder('entity')
.leftJoin('entity.relation', 'relation')
.addSelect(['relation.name'])
.where('entity.tenantId = :tenantId', { tenantId })
.getMany();
4. Transactions
Before:
const client = await getClient();
try {
await client.query('BEGIN');
// operations
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
After:
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// operations using queryRunner.manager
await queryRunner.manager.save(entity);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
5. Soft Deletes
Pattern:
await this.repository.update(
{ id, tenantId },
{
deletedAt: new Date(),
deletedBy: userId,
}
);
6. Pagination
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: { tenantId, deletedAt: IsNull() },
skip,
take: limit,
order: { createdAt: 'DESC' },
});
return { data, total };
Testing Strategy
1. Unit Tests
For each refactored service:
describe('AccountsService', () => {
let service: AccountsService;
let repository: Repository<Account>;
beforeEach(() => {
repository = AppDataSource.getRepository(Account);
service = new AccountsService();
});
it('should create account with valid data', async () => {
const dto = { /* ... */ };
const result = await service.create(dto, tenantId, userId);
expect(result.id).toBeDefined();
expect(result.code).toBe(dto.code);
});
});
2. Integration Tests
Test with actual database:
# Run tests
npm test src/modules/financial/__tests__/
3. API Tests
Test HTTP endpoints:
# Test accounts endpoints
curl -X GET http://localhost:3000/api/financial/accounts?companyId=xxx
curl -X POST http://localhost:3000/api/financial/accounts -d '{"companyId":"xxx",...}'
Rollback Plan
If migration causes issues:
- Restore Old Services:
cd src/modules/financial
mv accounts.service.ts accounts.service.new.ts
mv accounts.service.old.ts accounts.service.ts
-
Remove Entity Imports: Edit
/src/config/typeorm.tsand remove financial entity imports -
Restart Application:
npm run dev
Database Schema Notes
Schema: financial
All tables use the financial schema as specified in entities.
Important Columns:
- tenant_id: Multi-tenancy isolation (UUID, NOT NULL)
- company_id: Company isolation (UUID, NOT NULL)
- deleted_at: Soft delete timestamp (NULL = active)
- created_at: Audit timestamp
- created_by: User ID who created (UUID)
- updated_at: Audit timestamp
- updated_by: User ID who updated (UUID)
Decimal Precision:
- Amounts: DECIMAL(15, 2) - invoices, payments
- Quantity: DECIMAL(15, 4) - invoice lines
- Tax Rate: DECIMAL(5, 2) - tax percentage
Common Issues and Solutions
Issue 1: Column Name Mismatch
Error: column "companyId" does not exist
Solution: Entity decorators map camelCase to snake_case:
@Column({ name: 'company_id' })
companyId: string;
Issue 2: Soft Deletes Not Working
Solution: Always include deletedAt: IsNull() in where clauses:
where: { id, tenantId, deletedAt: IsNull() }
Issue 3: Transaction Not Rolling Back
Solution: Always use try-catch-finally with queryRunner:
finally {
await queryRunner.release(); // MUST release
}
Issue 4: Relations Not Loading
Solution: Use leftJoin or relations option:
// Option 1: Query Builder
.leftJoin('entity.relation', 'relation')
.addSelect(['relation.field'])
// Option 2: Find options
findOne({
where: { id },
relations: ['relation'],
})
Performance Considerations
1. Query Optimization
- Use
leftJoin+addSelectinstead ofrelationsoption for better control - Add indexes on frequently queried columns (already in entities)
- Use pagination for large result sets
2. Connection Pooling
TypeORM pool configuration (in typeorm.ts):
extra: {
max: 10, // Conservative to not compete with pg pool
min: 2,
idleTimeoutMillis: 30000,
}
3. Caching
Currently disabled:
cache: false
Can enable later for read-heavy operations.
Next Steps
-
Complete service migrations in this order:
- taxes.service.ts (High priority, simple)
- journals.service.ts (High priority, simple)
- journal-entries.service.ts (Medium, complex transactions)
- invoices.service.ts (Medium, tax integration)
- payments.service.ts (Medium, reconciliation)
- fiscalPeriods.service.ts (Low, DB functions)
-
Update controller to accept both snake_case and camelCase
-
Write tests for each migrated service
-
Update API documentation to reflect camelCase support
-
Monitor performance after deployment
Support and Questions
For questions about this migration:
- Check existing patterns in
accounts.service.ts - Review TypeORM documentation: https://typeorm.io
- Check entity definitions in
/entities/folder
Changelog
2024-12-14
- Created all TypeORM entities
- Registered entities in AppDataSource
- Completed accounts.service.ts migration
- Created this migration guide