Compare commits

...

No commits in common. "8d201c5b5846e130c49272334d590c80e42a30be" and "3ce5c6ad1714a3fbd6aacb21fd6d99d214b438b2" have entirely different histories.

173 changed files with 904 additions and 28092 deletions

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# erp-core-backend-v2
Backend de erp-core - Workspace V2

View File

@ -1,30 +0,0 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.test.ts',
'**/__tests__/**/*.spec.ts',
],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json',
isolatedModules: true,
}],
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/config/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
testTimeout: 30000,
verbose: true,
};

31
package-lock.json generated
View File

@ -9,8 +9,6 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@ -2231,12 +2229,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@ -3146,23 +3138,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.3",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
"validator": "^13.15.20"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@ -5807,12 +5782,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.12.34",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz",
"integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==",
"license": "MIT"
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",

View File

@ -14,8 +14,6 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",

View File

@ -1,102 +0,0 @@
import { createMockRepository, createMockCountry } from '../helpers';
import { NotFoundError } from '../../shared/errors';
// Mock the entire module
const mockRepository = createMockRepository();
jest.mock('../../config/typeorm', () => ({
AppDataSource: {
getRepository: jest.fn(() => mockRepository),
},
}));
// Import after mocking
import { countriesService } from '../../modules/core/countries.service';
describe('CountriesService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return all countries ordered by name', async () => {
const mockCountries = [
createMockCountry({ id: '1', code: 'US', name: 'Estados Unidos' }),
createMockCountry({ id: '2', code: 'MX', name: 'México' }),
];
mockRepository.find.mockResolvedValue(mockCountries);
const result = await countriesService.findAll();
expect(mockRepository.find).toHaveBeenCalledWith({
order: { name: 'ASC' },
});
expect(result).toEqual(mockCountries);
expect(result).toHaveLength(2);
});
it('should return empty array when no countries exist', async () => {
mockRepository.find.mockResolvedValue([]);
const result = await countriesService.findAll();
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
describe('findById', () => {
it('should return a country by id', async () => {
const mockCountry = createMockCountry({ id: 'country-id-1' });
mockRepository.findOne.mockResolvedValue(mockCountry);
const result = await countriesService.findById('country-id-1');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'country-id-1' },
});
expect(result).toEqual(mockCountry);
});
it('should throw NotFoundError when country not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(countriesService.findById('non-existent-id')).rejects.toThrow(NotFoundError);
});
});
describe('findByCode', () => {
it('should return a country by code', async () => {
const mockCountry = createMockCountry({ code: 'MX' });
mockRepository.findOne.mockResolvedValue(mockCountry);
const result = await countriesService.findByCode('MX');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { code: 'MX' },
});
expect(result).toEqual(mockCountry);
});
it('should return a country by lowercase code (converted to uppercase)', async () => {
const mockCountry = createMockCountry({ code: 'MX' });
mockRepository.findOne.mockResolvedValue(mockCountry);
const result = await countriesService.findByCode('mx');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { code: 'MX' },
});
expect(result).toEqual(mockCountry);
});
it('should return null when country code not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
const result = await countriesService.findByCode('XX');
expect(result).toBeNull();
});
});
});

View File

@ -1,210 +0,0 @@
import { createMockRepository, createMockQueryBuilder, createMockCurrency } from '../helpers';
import { NotFoundError, ConflictError } from '../../shared/errors';
// Mock the entire module
const mockRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
jest.mock('../../config/typeorm', () => ({
AppDataSource: {
getRepository: jest.fn(() => mockRepository),
},
}));
// Import after mocking
import { currenciesService } from '../../modules/core/currencies.service';
describe('CurrenciesService', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('findAll', () => {
it('should return all currencies ordered by code', async () => {
const mockCurrencies = [
createMockCurrency({ id: '1', code: 'EUR', name: 'Euro' }),
createMockCurrency({ id: '2', code: 'MXN', name: 'Peso Mexicano' }),
createMockCurrency({ id: '3', code: 'USD', name: 'Dólar Estadounidense' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockCurrencies);
const result = await currenciesService.findAll();
expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('currency');
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('currency.code', 'ASC');
expect(result).toEqual(mockCurrencies);
});
it('should filter active currencies when activeOnly is true', async () => {
const activeCurrencies = [
createMockCurrency({ id: '1', code: 'MXN', active: true }),
];
mockQueryBuilder.getMany.mockResolvedValue(activeCurrencies);
const result = await currenciesService.findAll(true);
expect(mockQueryBuilder.where).toHaveBeenCalledWith('currency.active = :active', { active: true });
expect(result).toEqual(activeCurrencies);
});
it('should return all currencies when activeOnly is false', async () => {
const allCurrencies = [
createMockCurrency({ id: '1', code: 'MXN', active: true }),
createMockCurrency({ id: '2', code: 'USD', active: false }),
];
mockQueryBuilder.getMany.mockResolvedValue(allCurrencies);
const result = await currenciesService.findAll(false);
expect(mockQueryBuilder.where).not.toHaveBeenCalled();
expect(result).toHaveLength(2);
});
});
describe('findById', () => {
it('should return a currency by id', async () => {
const mockCurrency = createMockCurrency({ id: 'currency-id-1' });
mockRepository.findOne.mockResolvedValue(mockCurrency);
const result = await currenciesService.findById('currency-id-1');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'currency-id-1' },
});
expect(result).toEqual(mockCurrency);
});
it('should throw NotFoundError when currency not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(currenciesService.findById('non-existent')).rejects.toThrow(NotFoundError);
});
});
describe('findByCode', () => {
it('should return a currency by code', async () => {
const mockCurrency = createMockCurrency({ code: 'MXN' });
mockRepository.findOne.mockResolvedValue(mockCurrency);
const result = await currenciesService.findByCode('MXN');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { code: 'MXN' },
});
expect(result).toEqual(mockCurrency);
});
it('should convert lowercase code to uppercase', async () => {
const mockCurrency = createMockCurrency({ code: 'USD' });
mockRepository.findOne.mockResolvedValue(mockCurrency);
await currenciesService.findByCode('usd');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { code: 'USD' },
});
});
it('should return null when currency not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
const result = await currenciesService.findByCode('XXX');
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create a new currency', async () => {
const newCurrency = createMockCurrency({ id: 'new-id', code: 'GBP', name: 'Libra Esterlina' });
mockRepository.findOne.mockResolvedValue(null); // No existing currency
mockRepository.create.mockReturnValue(newCurrency);
mockRepository.save.mockResolvedValue(newCurrency);
const result = await currenciesService.create({
code: 'gbp',
name: 'Libra Esterlina',
symbol: '£',
});
expect(mockRepository.create).toHaveBeenCalledWith({
code: 'GBP',
name: 'Libra Esterlina',
symbol: '£',
decimals: 2,
});
expect(result).toEqual(newCurrency);
});
it('should throw ConflictError when currency code already exists', async () => {
const existingCurrency = createMockCurrency({ code: 'MXN' });
mockRepository.findOne.mockResolvedValue(existingCurrency);
await expect(
currenciesService.create({
code: 'MXN',
name: 'Peso Mexicano',
symbol: '$',
})
).rejects.toThrow(ConflictError);
});
it('should accept decimal_places parameter', async () => {
const newCurrency = createMockCurrency({ decimals: 4 });
mockRepository.findOne.mockResolvedValue(null);
mockRepository.create.mockReturnValue(newCurrency);
mockRepository.save.mockResolvedValue(newCurrency);
await currenciesService.create({
code: 'BTC',
name: 'Bitcoin',
symbol: '₿',
decimal_places: 4,
});
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ decimals: 4 })
);
});
});
describe('update', () => {
it('should update an existing currency', async () => {
const existingCurrency = createMockCurrency({ id: 'currency-id' });
const updatedCurrency = { ...existingCurrency, name: 'Updated Name' };
mockRepository.findOne.mockResolvedValue(existingCurrency);
mockRepository.save.mockResolvedValue(updatedCurrency);
const result = await currenciesService.update('currency-id', { name: 'Updated Name' });
expect(mockRepository.save).toHaveBeenCalled();
expect(result.name).toBe('Updated Name');
});
it('should update active status', async () => {
const existingCurrency = createMockCurrency({ id: 'currency-id', active: true });
const updatedCurrency = { ...existingCurrency, active: false };
mockRepository.findOne.mockResolvedValue(existingCurrency);
mockRepository.save.mockResolvedValue(updatedCurrency);
const result = await currenciesService.update('currency-id', { active: false });
expect(result.active).toBe(false);
});
it('should throw NotFoundError when currency not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(
currenciesService.update('non-existent', { name: 'New Name' })
).rejects.toThrow(NotFoundError);
});
});
});

View File

@ -1,151 +0,0 @@
import { createMockRepository, createMockQueryBuilder, createMockState, createMockCountry } from '../helpers';
import { NotFoundError } from '../../shared/errors';
const mockRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const mockCountryRepository = createMockRepository();
jest.mock('../../config/typeorm', () => ({
AppDataSource: {
getRepository: jest.fn((entity: any) => {
if (entity.name === 'Country') return mockCountryRepository;
return mockRepository;
}),
},
}));
import { statesService } from '../../modules/core/states.service';
describe('StatesService', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('findAll', () => {
it('should return all states with country relation', async () => {
const mockStates = [
createMockState({ id: '1', code: 'JAL', name: 'Jalisco' }),
createMockState({ id: '2', code: 'NLE', name: 'Nuevo León' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockStates);
const result = await statesService.findAll();
expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith('state');
expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('state.country', 'country');
expect(result).toEqual(mockStates);
});
it('should accept filter options', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await statesService.findAll({ active: true });
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return a state by id', async () => {
const mockState = createMockState({ id: 'state-id-1' });
mockRepository.findOne.mockResolvedValue(mockState);
const result = await statesService.findById('state-id-1');
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'state-id-1' },
relations: ['country'],
});
expect(result).toEqual(mockState);
});
it('should throw NotFoundError when state not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(statesService.findById('non-existent')).rejects.toThrow(NotFoundError);
});
});
describe('findByCountry', () => {
it('should return states by country id', async () => {
const mockStates = [
createMockState({ countryId: 'country-1', code: 'JAL' }),
];
mockRepository.find.mockResolvedValue(mockStates);
const result = await statesService.findByCountry('country-1');
expect(result).toEqual(mockStates);
});
});
describe('findByCountryCode', () => {
it('should return states filtered by country code', async () => {
const mockStates = [createMockState({ countryId: 'country-mx' })];
mockQueryBuilder.getMany.mockResolvedValue(mockStates);
const result = await statesService.findByCountryCode('MX');
expect(result).toEqual(mockStates);
});
});
describe('create', () => {
it('should create a new state', async () => {
const mockCountry = createMockCountry({ id: 'country-mx' });
const newState = createMockState({ id: 'new-state', code: 'AGS', name: 'Aguascalientes' });
mockCountryRepository.findOne.mockResolvedValue(mockCountry);
mockRepository.findOne.mockResolvedValue(null);
mockRepository.create.mockReturnValue(newState);
mockRepository.save.mockResolvedValue(newState);
const result = await statesService.create({
countryId: 'country-mx',
code: 'AGS',
name: 'Aguascalientes',
});
expect(result).toEqual(newState);
});
// Note: validation of country existence is tested at the integration level
});
describe('update', () => {
it('should update an existing state', async () => {
const existingState = createMockState({ id: 'state-id' });
const updatedState = { ...existingState, name: 'Updated State' };
mockRepository.findOne.mockResolvedValue(existingState);
mockRepository.save.mockResolvedValue(updatedState);
const result = await statesService.update('state-id', { name: 'Updated State' });
expect(result.name).toBe('Updated State');
});
});
describe('delete', () => {
it('should delete a state', async () => {
const existingState = createMockState({ id: 'state-to-delete' });
mockRepository.findOne.mockResolvedValue(existingState);
mockRepository.remove.mockResolvedValue(existingState);
await statesService.delete('state-to-delete');
expect(mockRepository.remove).toHaveBeenCalledWith(existingState);
});
it('should throw NotFoundError when state not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(statesService.delete('non-existent')).rejects.toThrow(NotFoundError);
});
});
});

View File

@ -1,218 +0,0 @@
import { createMockRepository, createMockQueryBuilder, createMockUom, createMockUomCategory } from '../helpers';
import { NotFoundError } from '../../shared/errors';
const mockRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
const mockCategoryRepository = createMockRepository();
const mockCategoryQb = createMockQueryBuilder();
mockCategoryRepository.createQueryBuilder.mockReturnValue(mockCategoryQb);
jest.mock('../../config/typeorm', () => ({
AppDataSource: {
getRepository: jest.fn((entity: any) => {
if (entity.name === 'UomCategory') return mockCategoryRepository;
return mockRepository;
}),
},
}));
import { uomService } from '../../modules/core/uom.service';
describe('UomService', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
mockCategoryRepository.createQueryBuilder.mockReturnValue(mockCategoryQb);
});
describe('findAllCategories', () => {
it('should return all UoM categories', async () => {
const mockCategories = [
createMockUomCategory({ id: '1', name: 'Unidades' }),
createMockUomCategory({ id: '2', name: 'Peso' }),
];
mockCategoryQb.getMany.mockResolvedValue(mockCategories);
const result = await uomService.findAllCategories();
expect(result).toEqual(mockCategories);
});
});
describe('findAll', () => {
it('should return all UoMs', async () => {
const mockUoms = [
createMockUom({ id: '1', code: 'unit', name: 'Unidad' }),
createMockUom({ id: '2', code: 'kg', name: 'Kilogramo' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockUoms);
const result = await uomService.findAll({});
expect(result).toEqual(mockUoms);
});
it('should accept filter options', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await uomService.findAll({ categoryId: 'cat-1', active: true });
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return a UoM by id', async () => {
const mockUom = createMockUom({ id: 'uom-id-1' });
mockRepository.findOne.mockResolvedValue(mockUom);
const result = await uomService.findById('uom-id-1');
expect(result).toEqual(mockUom);
});
it('should throw NotFoundError when UoM not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(uomService.findById('non-existent')).rejects.toThrow(NotFoundError);
});
});
describe('findByCode', () => {
it('should return a UoM by code', async () => {
const mockUom = createMockUom({ code: 'kg' });
mockRepository.findOne.mockResolvedValue(mockUom);
const result = await uomService.findByCode('kg');
expect(result).toEqual(mockUom);
});
it('should return null when UoM not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
const result = await uomService.findByCode('xxx');
expect(result).toBeNull();
});
});
describe('create', () => {
it('should create a new UoM', async () => {
const mockCategory = createMockUomCategory({ id: 'cat-1' });
const newUom = createMockUom({ id: 'new-uom', code: 'dozen', name: 'Docena' });
mockCategoryRepository.findOne.mockResolvedValue(mockCategory);
mockRepository.findOne.mockResolvedValue(null);
mockRepository.create.mockReturnValue(newUom);
mockRepository.save.mockResolvedValue(newUom);
const result = await uomService.create({
categoryId: 'cat-1',
code: 'dozen',
name: 'Docena',
});
expect(result).toEqual(newUom);
});
it('should throw NotFoundError when category not found', async () => {
mockCategoryRepository.findOne.mockResolvedValue(null);
await expect(
uomService.create({
categoryId: 'non-existent',
code: 'tst',
name: 'Test',
})
).rejects.toThrow(NotFoundError);
});
});
describe('update', () => {
it('should update an existing UoM', async () => {
const existingUom = createMockUom({ id: 'uom-id' });
const updatedUom = { ...existingUom, name: 'Updated Name' };
mockRepository.findOne.mockResolvedValue(existingUom);
mockRepository.save.mockResolvedValue(updatedUom);
const result = await uomService.update('uom-id', { name: 'Updated Name' });
expect(result.name).toBe('Updated Name');
});
it('should throw NotFoundError when UoM not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(uomService.update('non-existent', { name: 'Test' })).rejects.toThrow(NotFoundError);
});
});
describe('convertQuantity', () => {
it('should return same quantity when from and to are the same', async () => {
const uom = createMockUom({ id: 'same' });
mockRepository.findOne.mockResolvedValue(uom);
const result = await uomService.convertQuantity(10, 'same', 'same');
expect(result).toBe(10);
});
it('should perform conversion between UoMs', async () => {
const fromUom = createMockUom({ id: 'from', code: 'kg', factor: 1, uomType: 'reference', categoryId: 'cat-1' });
const toUom = createMockUom({ id: 'to', code: 'g', factor: 1000, uomType: 'smaller', categoryId: 'cat-1' });
mockRepository.findOne
.mockResolvedValueOnce(fromUom)
.mockResolvedValueOnce(toUom);
const result = await uomService.convertQuantity(5, 'from', 'to');
// Result depends on implementation - just verify it returns a number
expect(typeof result).toBe('number');
});
});
describe('getReferenceUom', () => {
it('should return reference UoM for category', async () => {
const referenceUom = createMockUom({ uomType: 'reference' });
mockRepository.findOne.mockResolvedValue(referenceUom);
const result = await uomService.getReferenceUom('cat-1');
expect(result).toEqual(referenceUom);
});
it('should return null when no reference UoM found', async () => {
mockRepository.findOne.mockResolvedValue(null);
const result = await uomService.getReferenceUom('cat-1');
expect(result).toBeNull();
});
});
describe('getConversionTable', () => {
it('should return conversion table structure', async () => {
const category = createMockUomCategory({ id: 'cat-1', name: 'Weight' });
const referenceUom = createMockUom({ id: 'ref', code: 'kg', name: 'Kilogram', uomType: 'reference', factor: 1 });
const uoms = [referenceUom];
mockCategoryRepository.findOne.mockResolvedValue(category);
mockRepository.findOne.mockResolvedValue(referenceUom);
mockQueryBuilder.getMany.mockResolvedValue(uoms);
const result = await uomService.getConversionTable('cat-1');
expect(result).toBeDefined();
expect(result.referenceUom).toBeDefined();
});
});
});

View File

@ -1,741 +0,0 @@
// Test helpers and mock factories
// Note: jest is available globally in Jest test environment
// Mock repository factory
export function createMockRepository<T>() {
return {
find: jest.fn(),
findOne: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn((data: Partial<T>) => data as T),
save: jest.fn((entity: T) => Promise.resolve(entity)),
update: jest.fn(),
delete: jest.fn(),
remove: jest.fn((entity: T) => Promise.resolve(entity)),
softDelete: jest.fn(),
createQueryBuilder: jest.fn(() => createMockQueryBuilder()),
count: jest.fn(),
merge: jest.fn((entity: T, ...sources: Partial<T>[]) => Object.assign(entity as object, ...sources)),
};
}
// Mock query builder
export function createMockQueryBuilder() {
const qb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
innerJoinAndSelect: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getOne: jest.fn(),
getMany: jest.fn(),
getManyAndCount: jest.fn(),
getCount: jest.fn(),
execute: jest.fn(),
setParameter: jest.fn().mockReturnThis(),
setParameters: jest.fn().mockReturnThis(),
};
return qb;
}
// Partner factory
export function createMockPartner(overrides = {}) {
return {
id: 'partner-uuid-1',
tenantId: global.testTenantId,
name: 'Test Partner',
email: 'partner@test.com',
phone: '+1234567890',
isCustomer: true,
isSupplier: false,
isActive: true,
creditLimit: 10000,
currentBalance: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Product factory
export function createMockProduct(overrides = {}) {
return {
id: 'product-uuid-1',
tenantId: global.testTenantId,
sku: 'PROD-001',
name: 'Test Product',
description: 'Test product description',
productType: 'product',
salePrice: 100,
costPrice: 50,
currency: 'MXN',
taxRate: 16,
isActive: true,
isSellable: true,
isPurchasable: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Warehouse factory
export function createMockWarehouse(overrides = {}) {
return {
id: 'warehouse-uuid-1',
tenantId: global.testTenantId,
code: 'WH-001',
name: 'Test Warehouse',
address: '123 Test St',
city: 'Test City',
state: 'Test State',
country: 'MEX',
isActive: true,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Invoice factory
export function createMockInvoice(overrides = {}) {
return {
id: 'invoice-uuid-1',
tenantId: global.testTenantId,
invoiceNumber: 'INV-001',
invoiceType: 'sale',
partnerId: 'partner-uuid-1',
status: 'draft',
subtotal: 1000,
taxAmount: 160,
total: 1160,
currency: 'MXN',
invoiceDate: new Date(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Employee factory
export function createMockEmployee(overrides = {}) {
return {
id: 'employee-uuid-1',
tenantId: global.testTenantId,
employeeNumber: 'EMP-001',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@test.com',
departmentId: 'dept-uuid-1',
position: 'Developer',
hireDate: new Date(),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Account factory
export function createMockAccount(overrides = {}) {
return {
id: 'account-uuid-1',
tenantId: global.testTenantId,
code: '1000',
name: 'Cash',
accountType: 'asset',
isActive: true,
balance: 0,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Quotation factory
export function createMockQuotation(overrides: Record<string, any> = {}) {
return {
id: 'quotation-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'QUO-000001',
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
quotation_date: new Date(),
validity_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
pricelist_id: null,
user_id: 'user-uuid-1',
sales_team_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
sale_order_id: null,
notes: null,
terms_conditions: null,
lines: [],
created_at: new Date(),
...overrides,
};
}
// Quotation line factory
export function createMockQuotationLine(overrides: Record<string, any> = {}) {
return {
id: 'quotation-line-uuid-1',
quotation_id: 'quotation-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
description: 'Test product description',
quantity: 10,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
tax_ids: [],
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
...overrides,
};
}
// Sales Order factory
export function createMockSalesOrder(overrides: Record<string, any> = {}) {
return {
id: 'order-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'SO-000001',
client_order_ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
order_date: new Date(),
validity_date: null,
commitment_date: null,
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
pricelist_id: null,
payment_term_id: null,
user_id: 'user-uuid-1',
sales_team_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
invoice_status: 'pending' as const,
delivery_status: 'pending' as const,
invoice_policy: 'order' as const,
picking_id: null,
notes: null,
terms_conditions: null,
lines: [],
created_at: new Date(),
confirmed_at: null,
...overrides,
};
}
// Sales Order line factory
export function createMockSalesOrderLine(overrides: Record<string, any> = {}) {
return {
id: 'order-line-uuid-1',
order_id: 'order-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
description: 'Test product description',
quantity: 10,
qty_delivered: 0,
qty_invoiced: 0,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
tax_ids: [],
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
analytic_account_id: null,
...overrides,
};
}
// Purchase Order factory
export function createMockPurchaseOrder(overrides: Record<string, any> = {}) {
return {
id: 'purchase-order-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'PO-000001',
ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Supplier',
order_date: new Date(),
expected_date: null,
effective_date: null,
currency_id: 'currency-uuid-1',
currency_code: 'MXN',
payment_term_id: null,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
status: 'draft' as const,
receipt_status: 'pending',
invoice_status: 'pending',
notes: null,
lines: [],
created_at: new Date(),
confirmed_at: null,
...overrides,
};
}
// Purchase Order line factory
export function createMockPurchaseOrderLine(overrides: Record<string, any> = {}) {
return {
id: 'purchase-line-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
product_code: 'PROD-001',
description: 'Test product description',
quantity: 10,
qty_received: 0,
qty_invoiced: 0,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
price_unit: 100,
discount: 0,
amount_untaxed: 1000,
amount_tax: 160,
amount_total: 1160,
expected_date: null,
...overrides,
};
}
// RFQ factory
export function createMockRfq(overrides: Record<string, any> = {}) {
return {
id: 'rfq-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'RFQ-000001',
partner_ids: ['supplier-uuid-1'],
partner_names: ['Test Supplier'],
request_date: new Date(),
deadline_date: null,
response_date: null,
status: 'draft' as const,
description: null,
notes: null,
lines: [],
created_at: new Date(),
...overrides,
};
}
// RFQ line factory
export function createMockRfqLine(overrides: Record<string, any> = {}) {
return {
id: 'rfq-line-uuid-1',
rfq_id: 'rfq-uuid-1',
product_id: 'product-uuid-1',
product_name: 'Test Product',
product_code: 'PROD-001',
description: 'Test product description',
quantity: 10,
uom_id: 'uom-uuid-1',
uom_name: 'Unit',
created_at: new Date(),
...overrides,
};
}
// Lead factory
export function createMockLead(overrides: Record<string, any> = {}) {
return {
id: 'lead-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Lead',
ref: null,
contact_name: 'John Doe',
email: 'john@test.com',
phone: '+1234567890',
mobile: null,
website: null,
company_prospect_name: 'Prospect Inc',
job_position: 'Manager',
industry: 'Technology',
stage_id: 'stage-uuid-1',
stage_name: 'New',
status: 'new' as const,
user_id: 'user-uuid-1',
sales_team_id: null,
source: 'website' as const,
priority: 1,
probability: 10,
expected_revenue: 5000,
date_open: new Date(),
date_closed: null,
partner_id: null,
opportunity_id: null,
lost_reason_id: null,
description: null,
notes: null,
tags: [],
created_at: new Date(),
...overrides,
};
}
// Opportunity factory
export function createMockOpportunity(overrides: Record<string, any> = {}) {
return {
id: 'opportunity-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Opportunity',
ref: null,
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
contact_name: 'John Doe',
email: 'john@test.com',
phone: '+1234567890',
stage_id: 'stage-uuid-1',
stage_name: 'Qualification',
status: 'open' as const,
user_id: 'user-uuid-1',
sales_team_id: null,
priority: 2,
probability: 30,
expected_revenue: 10000,
recurring_revenue: null,
recurring_plan: null,
date_deadline: null,
date_closed: null,
date_last_activity: null,
lead_id: null,
source: 'website' as const,
lost_reason_id: null,
quotation_id: null,
order_id: null,
description: null,
notes: null,
tags: [],
created_at: new Date(),
...overrides,
};
}
// Stage factory (for both Lead and Opportunity stages)
export function createMockStage(overrides: Record<string, any> = {}) {
return {
id: 'stage-uuid-1',
tenant_id: global.testTenantId,
name: 'New',
sequence: 1,
is_won: false,
probability: 10,
requirements: null,
active: true,
created_at: new Date(),
...overrides,
};
}
// Lost Reason factory
export function createMockLostReason(overrides: Record<string, any> = {}) {
return {
id: 'lost-reason-uuid-1',
tenant_id: global.testTenantId,
name: 'Too expensive',
description: 'Customer found a cheaper alternative',
active: true,
created_at: new Date(),
...overrides,
};
}
// Project factory
export function createMockProject(overrides: Record<string, any> = {}) {
return {
id: 'project-uuid-1',
tenant_id: global.testTenantId,
company_id: 'company-uuid-1',
company_name: 'Test Company',
name: 'Test Project',
code: 'PROJ-001',
description: 'Test project description',
manager_id: 'user-uuid-1',
manager_name: 'John Manager',
partner_id: 'partner-uuid-1',
partner_name: 'Test Partner',
analytic_account_id: null,
date_start: new Date(),
date_end: null,
status: 'active' as const,
privacy: 'public' as const,
allow_timesheets: true,
color: '#3498db',
task_count: 5,
completed_task_count: 2,
created_at: new Date(),
...overrides,
};
}
// Task factory
export function createMockTask(overrides: Record<string, any> = {}) {
return {
id: 'task-uuid-1',
tenant_id: global.testTenantId,
project_id: 'project-uuid-1',
project_name: 'Test Project',
stage_id: 'stage-uuid-1',
stage_name: 'To Do',
name: 'Test Task',
description: 'Test task description',
assigned_to: 'user-uuid-1',
assigned_name: 'John Doe',
parent_id: null,
parent_name: null,
date_deadline: null,
estimated_hours: 8,
spent_hours: 0,
priority: 'normal' as const,
status: 'todo' as const,
sequence: 1,
color: null,
created_at: new Date(),
...overrides,
};
}
// Timesheet factory
export function createMockTimesheet(overrides: Record<string, any> = {}) {
return {
id: 'timesheet-uuid-1',
tenant_id: global.testTenantId,
project_id: 'project-uuid-1',
project_name: 'Test Project',
task_id: 'task-uuid-1',
task_name: 'Test Task',
employee_id: 'employee-uuid-1',
employee_name: 'John Doe',
date: new Date(),
hours: 4,
description: 'Worked on feature X',
billable: true,
invoiced: false,
created_at: new Date(),
...overrides,
};
}
// =====================================================
// Core Catalog Factories
// =====================================================
// Country factory
export function createMockCountry(overrides: Record<string, any> = {}) {
return {
id: 'country-uuid-1',
code: 'MX',
codeAlpha3: 'MEX',
name: 'México',
phoneCode: '+52',
currencyCode: 'MXN',
createdAt: new Date(),
...overrides,
};
}
// State factory
export function createMockState(overrides: Record<string, any> = {}) {
return {
id: 'state-uuid-1',
countryId: 'country-uuid-1',
code: 'JAL',
name: 'Jalisco',
timezone: 'America/Mexico_City',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Currency factory (core)
export function createMockCurrency(overrides: Record<string, any> = {}) {
return {
id: 'currency-uuid-1',
code: 'MXN',
name: 'Peso Mexicano',
symbol: '$',
decimals: 2,
rounding: 0.01,
active: true,
createdAt: new Date(),
...overrides,
};
}
// Currency Rate factory
export function createMockCurrencyRate(overrides: Record<string, any> = {}) {
return {
id: 'rate-uuid-1',
tenantId: global.testTenantId,
fromCurrencyId: 'currency-uuid-usd',
toCurrencyId: 'currency-uuid-mxn',
rate: 17.50,
rateDate: new Date(),
source: 'manual' as const,
createdBy: global.testUserId,
createdAt: new Date(),
...overrides,
};
}
// UoM Category factory
export function createMockUomCategory(overrides: Record<string, any> = {}) {
return {
id: 'uom-category-uuid-1',
tenantId: null,
name: 'Unidades',
description: 'Unidades discretas',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// UoM factory
export function createMockUom(overrides: Record<string, any> = {}) {
return {
id: 'uom-uuid-1',
tenantId: null,
categoryId: 'uom-category-uuid-1',
code: 'unit',
name: 'Unidad',
symbol: 'u',
uomType: 'reference' as const,
factor: 1.0,
rounding: 0.01,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Payment Term factory
export function createMockPaymentTerm(overrides: Record<string, any> = {}) {
return {
id: 'payment-term-uuid-1',
tenantId: global.testTenantId,
code: 'NET30',
name: 'Neto 30 días',
description: 'Pago en 30 días',
dueDays: 30,
discountPercent: null,
discountDays: null,
isImmediate: false,
isActive: true,
lines: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Discount Rule factory
export function createMockDiscountRule(overrides: Record<string, any> = {}) {
return {
id: 'discount-rule-uuid-1',
tenantId: global.testTenantId,
code: 'PROMO10',
name: '10% de descuento',
description: 'Promoción del 10%',
discountType: 'percentage' as const,
discountValue: 10,
maxDiscountAmount: null,
appliesTo: 'all' as const,
appliesToId: null,
conditionType: 'none' as const,
conditionValue: null,
startDate: null,
endDate: null,
priority: 1,
combinable: true,
usageLimit: null,
usageCount: 0,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Product Category factory (core)
export function createMockProductCategory(overrides: Record<string, any> = {}) {
return {
id: 'product-category-uuid-1',
tenantId: global.testTenantId,
parentId: null,
code: 'GEN',
name: 'General',
description: 'Categoría general',
hierarchyPath: '/GEN',
hierarchyLevel: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Sequence factory
export function createMockSequence(overrides: Record<string, any> = {}) {
return {
id: 'sequence-uuid-1',
tenantId: global.testTenantId,
code: 'invoice',
name: 'Facturas',
prefix: 'INV-',
suffix: null,
padding: 6,
step: 1,
currentNumber: 1,
resetFrequency: null,
lastResetDate: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}

View File

@ -1,42 +0,0 @@
// Test setup file for Jest
export {}; // Make this file a module
// Mock AppDataSource
jest.mock('../config/typeorm', () => ({
AppDataSource: {
getRepository: jest.fn(),
isInitialized: true,
initialize: jest.fn(() => Promise.resolve()),
destroy: jest.fn(() => Promise.resolve()),
},
}));
// Mock logger
jest.mock('../shared/utils/logger', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Global test utilities
(global as any).testTenantId = 'test-tenant-uuid';
(global as any).testUserId = 'test-user-uuid';
// Extend global types for tests
declare global {
var testTenantId: string;
var testUserId: string;
}
// Clean up mocks after each test
afterEach(() => {
jest.clearAllMocks();
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
});

View File

@ -372,10 +372,12 @@ export function initializeModules(
} }
// Initialize Invoices Module // Initialize Invoices Module
// Note: Invoices now uses routes-based approach via invoices.routes.ts in app.ts
if (config.invoices?.enabled) { if (config.invoices?.enabled) {
const invoicesModuleInstance = new InvoicesModule(); const invoicesModule = new InvoicesModule({
app.use(invoicesModuleInstance.router); dataSource,
basePath: config.invoices.basePath,
});
app.use(invoicesModule.router);
console.log('✅ Invoices module initialized'); console.log('✅ Invoices module initialized');
} }

View File

@ -24,10 +24,6 @@ import systemRoutes from './modules/system/system.routes.js';
import crmRoutes from './modules/crm/crm.routes.js'; import crmRoutes from './modules/crm/crm.routes.js';
import hrRoutes from './modules/hr/hr.routes.js'; import hrRoutes from './modules/hr/hr.routes.js';
import reportsRoutes from './modules/reports/reports.routes.js'; import reportsRoutes from './modules/reports/reports.routes.js';
import invoicesRoutes from './modules/invoices/invoices.routes.js';
import productsRoutes from './modules/products/products.routes.js';
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
const app: Application = express(); const app: Application = express();
@ -77,10 +73,6 @@ app.use(`${apiPrefix}/system`, systemRoutes);
app.use(`${apiPrefix}/crm`, crmRoutes); app.use(`${apiPrefix}/crm`, crmRoutes);
app.use(`${apiPrefix}/hr`, hrRoutes); app.use(`${apiPrefix}/hr`, hrRoutes);
app.use(`${apiPrefix}/reports`, reportsRoutes); app.use(`${apiPrefix}/reports`, reportsRoutes);
app.use(`${apiPrefix}/invoices`, invoicesRoutes);
app.use(`${apiPrefix}/products`, productsRoutes);
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
// 404 handler // 404 handler
app.use((_req: Request, res: Response) => { app.use((_req: Request, res: Response) => {

View File

@ -3,9 +3,13 @@
*/ */
import swaggerJSDoc from 'swagger-jsdoc'; import swaggerJSDoc from 'swagger-jsdoc';
import { Application } from 'express'; import { Express } from 'express';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Swagger definition // Swagger definition
const swaggerDefinition = { const swaggerDefinition = {
@ -149,9 +153,9 @@ const options: swaggerJSDoc.Options = {
definition: swaggerDefinition, definition: swaggerDefinition,
// Path to the API routes for JSDoc comments // Path to the API routes for JSDoc comments
apis: [ apis: [
path.resolve(process.cwd(), 'src/modules/**/*.routes.ts'), path.join(__dirname, '../modules/**/*.routes.ts'),
path.resolve(process.cwd(), 'src/modules/**/*.routes.js'), path.join(__dirname, '../modules/**/*.routes.js'),
path.resolve(process.cwd(), 'src/docs/openapi.yaml'), path.join(__dirname, '../docs/openapi.yaml'),
], ],
}; };
@ -161,7 +165,7 @@ const swaggerSpec = swaggerJSDoc(options);
/** /**
* Setup Swagger documentation for Express app * Setup Swagger documentation for Express app
*/ */
export function setupSwagger(app: Application, prefix: string = '/api/v1') { export function setupSwagger(app: Express, prefix: string = '/api/v1') {
// Swagger UI options // Swagger UI options
const swaggerUiOptions = { const swaggerUiOptions = {
customCss: ` customCss: `

View File

@ -29,16 +29,11 @@ import {
import { Partner } from '../modules/partners/entities/index.js'; import { Partner } from '../modules/partners/entities/index.js';
import { import {
Currency, Currency,
CurrencyRate,
Country, Country,
State,
UomCategory, UomCategory,
Uom, Uom,
ProductCategory, ProductCategory,
Sequence, Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
} from '../modules/core/entities/index.js'; } from '../modules/core/entities/index.js';
// Import Financial Entities // Import Financial Entities
@ -65,27 +60,11 @@ import {
Lot, Lot,
Picking, Picking,
StockMove, StockMove,
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
InventoryAdjustment, InventoryAdjustment,
InventoryAdjustmentLine, InventoryAdjustmentLine,
TransferOrder,
TransferOrderLine,
StockValuationLayer, StockValuationLayer,
} from '../modules/inventory/entities/index.js'; } from '../modules/inventory/entities/index.js';
// Import Fiscal Entities
import {
TaxCategory,
FiscalRegime,
CfdiUse,
PaymentMethod,
PaymentType,
WithholdingType,
} from '../modules/fiscal/entities/index.js';
/** /**
* TypeORM DataSource configuration * TypeORM DataSource configuration
* *
@ -125,16 +104,11 @@ export const AppDataSource = new DataSource({
// Core Module Entities // Core Module Entities
Partner, Partner,
Currency, Currency,
CurrencyRate,
Country, Country,
State,
UomCategory, UomCategory,
Uom, Uom,
ProductCategory, ProductCategory,
Sequence, Sequence,
PaymentTerm,
PaymentTermLine,
DiscountRule,
// Financial Entities // Financial Entities
AccountType, AccountType,
Account, Account,
@ -155,22 +129,9 @@ export const AppDataSource = new DataSource({
Lot, Lot,
Picking, Picking,
StockMove, StockMove,
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
InventoryAdjustment, InventoryAdjustment,
InventoryAdjustmentLine, InventoryAdjustmentLine,
TransferOrder,
TransferOrderLine,
StockValuationLayer, StockValuationLayer,
// Fiscal Entities
TaxCategory,
FiscalRegime,
CfdiUse,
PaymentMethod,
PaymentType,
WithholdingType,
], ],
// Directorios de migraciones (para uso futuro) // Directorios de migraciones (para uso futuro)

View File

@ -124,6 +124,7 @@ export class AIService {
.update() .update()
.set({ .set({
usageCount: () => 'usage_count + 1', usageCount: () => 'usage_count + 1',
lastUsedAt: new Date(),
}) })
.where('id = :id', { id }) .where('id = :id', { id })
.execute(); .execute();
@ -338,9 +339,9 @@ export class AIService {
.createQueryBuilder() .createQueryBuilder()
.update() .update()
.set({ .set({
currentRequests: () => `current_requests + ${requestCount}`, currentRequestsMonth: () => `current_requests_month + ${requestCount}`,
currentTokens: () => `current_tokens + ${tokenCount}`, currentTokensMonth: () => `current_tokens_month + ${tokenCount}`,
currentCost: () => `current_cost + ${costUsd}`, currentSpendMonth: () => `current_spend_month + ${costUsd}`,
}) })
.where('tenant_id = :tenantId', { tenantId }) .where('tenant_id = :tenantId', { tenantId })
.execute(); .execute();
@ -353,15 +354,15 @@ export class AIService {
const quota = await this.getTenantQuota(tenantId); const quota = await this.getTenantQuota(tenantId);
if (!quota) return { available: true }; if (!quota) return { available: true };
if (quota.monthlyRequestLimit && quota.currentRequests >= quota.monthlyRequestLimit) { if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) {
return { available: false, reason: 'Monthly request limit reached' }; return { available: false, reason: 'Monthly request limit reached' };
} }
if (quota.monthlyTokenLimit && quota.currentTokens >= quota.monthlyTokenLimit) { if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) {
return { available: false, reason: 'Monthly token limit reached' }; return { available: false, reason: 'Monthly token limit reached' };
} }
if (quota.monthlyCostLimit && quota.currentCost >= quota.monthlyCostLimit) { if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) {
return { available: false, reason: 'Monthly spend limit reached' }; return { available: false, reason: 'Monthly spend limit reached' };
} }
@ -372,9 +373,10 @@ export class AIService {
const result = await this.quotaRepository.update( const result = await this.quotaRepository.update(
{}, {},
{ {
currentRequests: 0, currentRequestsMonth: 0,
currentTokens: 0, currentTokensMonth: 0,
currentCost: 0, currentSpendMonth: 0,
lastResetAt: new Date(),
} }
); );
return result.affected ?? 0; return result.affected ?? 0;

View File

@ -180,13 +180,20 @@ export class AuditController {
} }
} }
private async markSessionLogout(_req: Request, res: Response, _next: NextFunction): Promise<void> { private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
// Note: Session logout tracking requires a separate Session entity try {
// LoginHistory only tracks login attempts, not active sessions const { sessionId } = req.params;
res.status(501).json({ const marked = await this.auditService.markSessionLogout(sessionId);
error: 'Session logout tracking not implemented',
message: 'Use the Auth module session endpoints for logout tracking', if (!marked) {
}); res.status(404).json({ error: 'Session not found' });
return;
}
res.json({ data: { success: true } });
} catch (error) {
next(error);
}
} }
// ============================================ // ============================================

View File

@ -56,9 +56,9 @@ export class AuditService {
const where: FindOptionsWhere<AuditLog> = { tenantId }; const where: FindOptionsWhere<AuditLog> = { tenantId };
if (filters.userId) where.userId = filters.userId; if (filters.userId) where.userId = filters.userId;
if (filters.entityType) where.resourceType = filters.entityType; if (filters.entityType) where.entityType = filters.entityType;
if (filters.action) where.action = filters.action as any; if (filters.action) where.action = filters.action as any;
if (filters.category) where.actionCategory = filters.category as any; if (filters.category) where.category = filters.category as any;
if (filters.ipAddress) where.ipAddress = filters.ipAddress; if (filters.ipAddress) where.ipAddress = filters.ipAddress;
if (filters.startDate && filters.endDate) { if (filters.startDate && filters.endDate) {
@ -85,7 +85,7 @@ export class AuditService {
entityId: string entityId: string
): Promise<AuditLog[]> { ): Promise<AuditLog[]> {
return this.auditLogRepository.find({ return this.auditLogRepository.find({
where: { tenantId, resourceType: entityType, resourceId: entityId }, where: { tenantId, entityType, entityId },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}); });
} }
@ -143,21 +143,24 @@ export class AuditService {
return this.loginHistoryRepository.find({ return this.loginHistoryRepository.find({
where, where,
order: { attemptedAt: 'DESC' }, order: { loginAt: 'DESC' },
take: limit, take: limit,
}); });
} }
async getActiveSessionsCount(userId: string): Promise<number> { async getActiveSessionsCount(userId: string): Promise<number> {
// Note: LoginHistory tracks login attempts, not sessions
// This counts successful login attempts (not truly active sessions)
return this.loginHistoryRepository.count({ return this.loginHistoryRepository.count({
where: { userId, status: 'success' }, where: { userId, logoutAt: undefined, status: 'success' },
}); });
} }
// Note: Session logout tracking requires a separate Session entity async markSessionLogout(sessionId: string): Promise<boolean> {
// LoginHistory only tracks login attempts const result = await this.loginHistoryRepository.update(
{ sessionId },
{ logoutAt: new Date() }
);
return (result.affected ?? 0) > 0;
}
// ============================================ // ============================================
// SENSITIVE DATA ACCESS // SENSITIVE DATA ACCESS
@ -213,7 +216,7 @@ export class AuditService {
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> { async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
return this.dataExportRepository.find({ return this.dataExportRepository.find({
where: { tenantId, userId }, where: { tenantId, requestedBy: userId },
order: { requestedAt: 'DESC' }, order: { requestedAt: 'DESC' },
}); });
} }
@ -288,16 +291,13 @@ export class AuditService {
}); });
} }
// Note: ConfigChange entity doesn't track versions async getConfigVersion(
// Use changedAt timestamp to get specific config snapshots
async getConfigChangeByDate(
tenantId: string, tenantId: string,
configKey: string, configKey: string,
date: Date version: number
): Promise<ConfigChange | null> { ): Promise<ConfigChange | null> {
return this.configChangeRepository.findOne({ return this.configChangeRepository.findOne({
where: { tenantId, configKey }, where: { tenantId, configKey, version },
order: { changedAt: 'DESC' },
}); });
} }
} }

View File

@ -181,10 +181,7 @@ class ApiKeysController {
} }
const dto: UpdateApiKeyDto = { const dto: UpdateApiKeyDto = {
name: validation.data.name, ...validation.data,
scope: validation.data.scope ?? undefined,
allowed_ips: validation.data.allowed_ips ?? undefined,
is_active: validation.data.is_active,
expiration_date: validation.data.expiration_date expiration_date: validation.data.expiration_date
? new Date(validation.data.expiration_date) ? new Date(validation.data.expiration_date)
: validation.data.expiration_date === null : validation.data.expiration_date === null

View File

@ -1,64 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Index,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { User } from './user.entity.js';
@Entity({ schema: 'auth', name: 'devices' })
@Index('idx_devices_tenant_id', ['tenantId'])
@Index('idx_devices_user_id', ['userId'])
@Index('idx_devices_device_id', ['deviceId'])
export class Device {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'device_id' })
deviceId: string;
@Column({ type: 'varchar', length: 255, nullable: true, name: 'device_name' })
deviceName: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'device_type' })
deviceType: string;
@Column({ type: 'varchar', length: 50, nullable: true })
platform: string;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'os_version' })
osVersion: string;
@Column({ type: 'varchar', length: 20, nullable: true, name: 'app_version' })
appVersion: string;
@Column({ type: 'text', nullable: true, name: 'push_token' })
pushToken: string;
@Column({ name: 'is_trusted', default: false })
isTrusted: boolean;
@Column({ type: 'timestamptz', nullable: true, name: 'last_active_at' })
lastActiveAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -13,8 +13,3 @@ export { MfaAuditLog, MfaEventType } from './mfa-audit-log.entity.js';
export { OAuthProvider } from './oauth-provider.entity.js'; export { OAuthProvider } from './oauth-provider.entity.js';
export { OAuthUserLink } from './oauth-user-link.entity.js'; export { OAuthUserLink } from './oauth-user-link.entity.js';
export { OAuthState } from './oauth-state.entity.js'; export { OAuthState } from './oauth-state.entity.js';
export { UserProfile } from './user-profile.entity.js';
export { ProfileTool } from './profile-tool.entity.js';
export { ProfileModule } from './profile-module.entity.js';
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
export { Device } from './device.entity.js';

View File

@ -1,27 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'profile_modules' })
export class ProfileModule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'module_code' })
moduleCode: string;
@Column({ name: 'is_enabled', default: true })
isEnabled: boolean;
@ManyToOne(() => UserProfile, (p) => p.modules, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -1,36 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'profile_tools' })
export class ProfileTool {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ type: 'varchar', length: 50, nullable: false, name: 'tool_code' })
toolCode: string;
@Column({ name: 'can_view', default: false })
canView: boolean;
@Column({ name: 'can_create', default: false })
canCreate: boolean;
@Column({ name: 'can_edit', default: false })
canEdit: boolean;
@Column({ name: 'can_delete', default: false })
canDelete: boolean;
@ManyToOne(() => UserProfile, (p) => p.tools, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -1,36 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { User } from './user.entity.js';
import { UserProfile } from './user-profile.entity.js';
@Entity({ schema: 'auth', name: 'user_profile_assignments' })
export class UserProfileAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
userId: string;
@Column({ type: 'uuid', nullable: false, name: 'profile_id' })
profileId: string;
@Column({ name: 'is_default', default: false })
isDefault: boolean;
@CreateDateColumn({ name: 'assigned_at', type: 'timestamp' })
assignedAt: Date;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => UserProfile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'profile_id' })
profile: UserProfile;
}

View File

@ -1,52 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Tenant } from './tenant.entity.js';
import { ProfileTool } from './profile-tool.entity.js';
import { ProfileModule } from './profile-module.entity.js';
@Entity({ schema: 'auth', name: 'user_profiles' })
@Index('idx_user_profiles_tenant_id', ['tenantId'])
export class UserProfile {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'varchar', length: 10, nullable: false })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date;
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@OneToMany(() => ProfileTool, (pt) => pt.profile)
tools: ProfileTool[];
@OneToMany(() => ProfileModule, (pm) => pm.profile)
modules: ProfileModule[];
}

View File

@ -60,24 +60,6 @@ export class User {
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' }) @Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
isSuperuser: boolean; isSuperuser: boolean;
@Column({ name: 'is_superadmin', default: false })
isSuperadmin: boolean;
@Column({ name: 'mfa_enabled', default: false })
mfaEnabled: boolean;
@Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true })
mfaSecretEncrypted: string;
@Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true })
mfaBackupCodes: string[];
@Column({ name: 'oauth_provider', length: 50, nullable: true })
oauthProvider: string;
@Column({ name: 'oauth_provider_id', length: 255, nullable: true })
oauthProviderId: string;
@Column({ @Column({
type: 'timestamp', type: 'timestamp',
nullable: true, nullable: true,

View File

@ -1,409 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for billing entities
function createMockCoupon(overrides: Record<string, any> = {}) {
return {
id: 'coupon-uuid-1',
code: 'SAVE20',
name: '20% Discount',
description: 'Get 20% off your subscription',
discountType: 'percentage',
discountValue: 20,
currency: 'MXN',
applicablePlans: [],
minAmount: 0,
durationPeriod: 'once',
durationMonths: null,
maxRedemptions: 100,
currentRedemptions: 10,
validFrom: new Date('2024-01-01'),
validUntil: new Date('2030-12-31'),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockCouponRedemption(overrides: Record<string, any> = {}) {
return {
id: 'redemption-uuid-1',
couponId: 'coupon-uuid-1',
tenantId: 'tenant-uuid-1',
subscriptionId: 'subscription-uuid-1',
discountAmount: 200,
expiresAt: null,
createdAt: new Date(),
...overrides,
};
}
// Mock repositories
const mockCouponRepository = createMockRepository();
const mockRedemptionRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock transaction manager
const mockManager = {
save: jest.fn().mockResolvedValue({}),
};
// Mock DataSource with transaction
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'Coupon') return mockCouponRepository;
if (entityName === 'CouponRedemption') return mockRedemptionRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
return mockCouponRepository;
}),
transaction: jest.fn((callback: (manager: any) => Promise<void>) => callback(mockManager)),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { CouponsService } from '../services/coupons.service.js';
describe('CouponsService', () => {
let service: CouponsService;
beforeEach(() => {
jest.clearAllMocks();
service = new CouponsService(mockDataSource as any);
mockCouponRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new coupon successfully', async () => {
const dto = {
code: 'NEWCODE',
name: 'New Discount',
discountType: 'percentage' as const,
discountValue: 15,
validFrom: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
};
const mockCoupon = createMockCoupon({ ...dto, id: 'new-coupon-uuid', code: 'NEWCODE' });
mockCouponRepository.findOne.mockResolvedValue(null);
mockCouponRepository.create.mockReturnValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue(mockCoupon);
const result = await service.create(dto);
expect(result.code).toBe('NEWCODE');
expect(mockCouponRepository.create).toHaveBeenCalled();
expect(mockCouponRepository.save).toHaveBeenCalled();
});
it('should throw error if coupon code already exists', async () => {
const dto = {
code: 'EXISTING',
name: 'Existing Discount',
discountType: 'percentage' as const,
discountValue: 10,
validFrom: new Date(),
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
};
mockCouponRepository.findOne.mockResolvedValue(createMockCoupon({ code: 'EXISTING' }));
await expect(service.create(dto)).rejects.toThrow('Coupon with code EXISTING already exists');
});
});
describe('findByCode', () => {
it('should find a coupon by code', async () => {
const mockCoupon = createMockCoupon({ code: 'TESTCODE' });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.findByCode('TESTCODE');
expect(result).toBeDefined();
expect(result?.code).toBe('TESTCODE');
expect(mockCouponRepository.findOne).toHaveBeenCalledWith({
where: { code: 'TESTCODE' },
});
});
it('should return null if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
const result = await service.findByCode('NOTFOUND');
expect(result).toBeNull();
});
});
describe('validateCoupon', () => {
it('should validate an active coupon successfully', async () => {
const mockCoupon = createMockCoupon({
code: 'VALID',
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null);
const result = await service.validateCoupon('VALID', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(true);
expect(result.message).toBe('Cupón válido');
});
it('should reject inactive coupon', async () => {
const mockCoupon = createMockCoupon({ isActive: false });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('INACTIVE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón inactivo');
});
it('should reject coupon not yet valid', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Future date
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('FUTURE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón aún no válido');
});
it('should reject expired coupon', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2020-01-01'),
validUntil: new Date('2020-12-31'), // Past date
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('EXPIRED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón expirado');
});
it('should reject coupon exceeding max redemptions', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 10,
currentRedemptions: 10,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
const result = await service.validateCoupon('MAXED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón agotado');
});
it('should reject if tenant already redeemed', async () => {
const mockCoupon = createMockCoupon({
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(createMockCouponRedemption());
const result = await service.validateCoupon('ONCEONLY', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón ya utilizado');
});
it('should reject if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
const result = await service.validateCoupon('NOTFOUND', 'tenant-uuid-1', 'plan-uuid-1', 1000);
expect(result.success).toBe(false);
expect(result.message).toBe('Cupón no encontrado');
});
});
describe('applyCoupon', () => {
it('should apply percentage discount correctly', async () => {
const mockCoupon = createMockCoupon({
id: 'coupon-uuid-1',
discountType: 'percentage',
discountValue: 20,
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null); // No existing redemption
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 200 }));
const result = await service.applyCoupon('SAVE20', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
expect(result.discountAmount).toBe(200); // 20% of 1000
expect(mockManager.save).toHaveBeenCalled();
});
it('should apply fixed discount correctly', async () => {
const mockCoupon = createMockCoupon({
id: 'coupon-uuid-1',
discountType: 'fixed',
discountValue: 150,
isActive: true,
validFrom: new Date('2023-01-01'),
validUntil: new Date('2030-12-31'),
maxRedemptions: 100,
currentRedemptions: 10,
applicablePlans: [],
minAmount: 0,
});
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.findOne.mockResolvedValue(null);
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 150 }));
const result = await service.applyCoupon('FIXED150', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
expect(result.discountAmount).toBe(150);
});
it('should throw error if coupon is invalid', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(
service.applyCoupon('INVALID', 'tenant-uuid-1', 'subscription-uuid-1', 1000)
).rejects.toThrow('Cupón no encontrado');
});
});
describe('findAll', () => {
it('should return all coupons', async () => {
const mockCoupons = [
createMockCoupon({ code: 'CODE1' }),
createMockCoupon({ code: 'CODE2' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
const result = await service.findAll();
expect(result).toHaveLength(2);
});
it('should filter by active status', async () => {
const mockCoupons = [createMockCoupon({ isActive: true })];
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
await service.findAll({ isActive: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('coupon.isActive = :isActive', { isActive: true });
});
});
describe('getStats', () => {
it('should return coupon statistics', async () => {
const mockCoupon = createMockCoupon({
maxRedemptions: 100,
currentRedemptions: 25,
});
const mockRedemptions = [
createMockCouponRedemption({ discountAmount: 200 }),
createMockCouponRedemption({ discountAmount: 300 }),
];
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
const result = await service.getStats('coupon-uuid-1');
expect(result.totalRedemptions).toBe(2);
expect(result.totalDiscountGiven).toBe(500);
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.getStats('nonexistent')).rejects.toThrow('Coupon not found');
});
});
describe('deactivate', () => {
it('should deactivate a coupon', async () => {
const mockCoupon = createMockCoupon({ isActive: true });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, isActive: false });
const result = await service.deactivate('coupon-uuid-1');
expect(result.isActive).toBe(false);
expect(mockCouponRepository.save).toHaveBeenCalled();
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.deactivate('nonexistent')).rejects.toThrow('Coupon not found');
});
});
describe('update', () => {
it('should update coupon properties', async () => {
const mockCoupon = createMockCoupon({ name: 'Old Name' });
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, name: 'New Name' });
const result = await service.update('coupon-uuid-1', { name: 'New Name' });
expect(result.name).toBe('New Name');
});
it('should throw error if coupon not found', async () => {
mockCouponRepository.findOne.mockResolvedValue(null);
await expect(service.update('nonexistent', { name: 'New' })).rejects.toThrow('Coupon not found');
});
});
describe('getActiveRedemptions', () => {
it('should return active redemptions for tenant', async () => {
const mockRedemptions = [
createMockCouponRedemption({ expiresAt: null }),
createMockCouponRedemption({ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }),
];
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
const result = await service.getActiveRedemptions('tenant-uuid-1');
expect(result).toHaveLength(2);
});
});
});

View File

@ -1,786 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories
function createMockInvoice(overrides: Record<string, any> = {}) {
return {
id: 'invoice-uuid-1',
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
invoiceNumber: 'INV-202601-0001',
invoiceDate: new Date('2026-01-15'),
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
billingName: 'Test Company',
billingEmail: 'billing@test.com',
billingAddress: { street: '123 Main St', city: 'Mexico City' },
taxId: 'RFC123456789',
subtotal: 499,
taxAmount: 79.84,
discountAmount: 0,
total: 578.84,
paidAmount: 0,
currency: 'MXN',
status: 'draft',
dueDate: new Date('2026-01-30'),
notes: '',
internalNotes: '',
items: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockInvoiceItem(overrides: Record<string, any> = {}) {
return {
id: 'item-uuid-1',
invoiceId: 'invoice-uuid-1',
itemType: 'subscription',
description: 'Suscripcion Starter - Mensual',
quantity: 1,
unitPrice: 499,
subtotal: 499,
metadata: {},
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
billingCycle: 'monthly',
contractedUsers: 10,
contractedBranches: 3,
billingName: 'Test Company',
billingEmail: 'billing@test.com',
billingAddress: { street: '123 Main St' },
taxId: 'RFC123456789',
plan: {
id: 'plan-uuid-1',
name: 'Starter',
maxUsers: 10,
maxBranches: 3,
storageGb: 20,
},
...overrides,
};
}
function createMockUsage(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
...overrides,
};
}
// Mock repositories
const mockInvoiceRepository = {
...createMockRepository(),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const mockItemRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockUsageRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'Invoice') return mockInvoiceRepository;
if (entityName === 'InvoiceItem') return mockItemRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'UsageTracking') return mockUsageRepository;
return mockInvoiceRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { InvoicesService } from '../services/invoices.service.js';
describe('InvoicesService', () => {
let service: InvoicesService;
beforeEach(() => {
jest.clearAllMocks();
service = new InvoicesService(mockDataSource as any);
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create invoice with items', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
billingName: 'Test Company',
billingEmail: 'billing@test.com',
dueDate: new Date('2026-01-30'),
items: [
{
itemType: 'subscription' as const,
description: 'Suscripcion Starter',
quantity: 1,
unitPrice: 499,
},
],
};
// Mock invoice number generation
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const mockInvoice = createMockInvoice({ ...dto, id: 'new-invoice-uuid' });
mockInvoiceRepository.create.mockReturnValue(mockInvoice);
mockInvoiceRepository.save.mockResolvedValue(mockInvoice);
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
mockInvoiceRepository.findOne.mockResolvedValue({
...mockInvoice,
items: [createMockInvoiceItem()],
});
const result = await service.create(dto);
expect(mockInvoiceRepository.create).toHaveBeenCalled();
expect(mockInvoiceRepository.save).toHaveBeenCalled();
expect(mockItemRepository.create).toHaveBeenCalled();
expect(result.tenantId).toBe('tenant-uuid-1');
});
it('should calculate totals with tax', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
items: [
{
itemType: 'subscription' as const,
description: 'Plan',
quantity: 1,
unitPrice: 1000,
},
],
};
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
id: 'invoice-id',
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
mockInvoiceRepository.findOne.mockImplementation((opts: any) => Promise.resolve({
...createMockInvoice(),
id: opts.where.id,
items: [],
}));
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Verify subtotal calculation (1000)
// Tax should be 16% = 160
// Total should be 1160
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 1000,
taxAmount: 160,
total: 1160,
})
);
});
it('should apply item discounts', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
items: [
{
itemType: 'subscription' as const,
description: 'Plan',
quantity: 1,
unitPrice: 1000,
discountPercent: 10, // 10% off
},
],
};
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Subtotal after 10% discount: 1000 - 100 = 900
// Tax 16%: 144
// Total: 1044
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
subtotal: 900,
taxAmount: 144,
total: 1044,
})
);
});
});
describe('generateFromSubscription', () => {
it('should generate invoice from subscription', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
};
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
const result = await service.generateFromSubscription(dto);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { id: 'sub-uuid-1' },
relations: ['plan'],
});
expect(result).toBeDefined();
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(
service.generateFromSubscription({
tenantId: 'tenant-uuid-1',
subscriptionId: 'invalid-id',
periodStart: new Date(),
periodEnd: new Date(),
})
).rejects.toThrow('Subscription not found');
});
it('should include usage charges when requested', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
subscriptionId: 'sub-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
includeUsageCharges: true,
};
const mockSub = createMockSubscription();
const mockUsage = createMockUsage({
activeUsers: 15, // 5 extra users
activeBranches: 5, // 2 extra branches
storageUsedGb: 25, // 5 extra GB
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
let createdItems: any[] = [];
mockInvoiceRepository.create.mockImplementation((data: any) => ({
...createMockInvoice(),
...data,
}));
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockImplementation((item: any) => {
createdItems.push(item);
return item;
});
mockItemRepository.save.mockImplementation((item: any) => Promise.resolve(item));
await service.generateFromSubscription(dto);
// Should have created items for: subscription + extra users + extra branches + extra storage
expect(createdItems.length).toBeGreaterThan(1);
expect(createdItems.some((i: any) => i.description.includes('Usuarios adicionales'))).toBe(true);
expect(createdItems.some((i: any) => i.description.includes('Sucursales adicionales'))).toBe(true);
expect(createdItems.some((i: any) => i.description.includes('Almacenamiento adicional'))).toBe(true);
});
});
describe('findById', () => {
it('should return invoice by id with items', async () => {
const mockInvoice = createMockInvoice();
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
const result = await service.findById('invoice-uuid-1');
expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({
where: { id: 'invoice-uuid-1' },
relations: ['items'],
});
expect(result?.id).toBe('invoice-uuid-1');
});
it('should return null if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent');
expect(result).toBeNull();
});
});
describe('findByNumber', () => {
it('should return invoice by invoice number', async () => {
const mockInvoice = createMockInvoice();
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
const result = await service.findByNumber('INV-202601-0001');
expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({
where: { invoiceNumber: 'INV-202601-0001' },
relations: ['items'],
});
expect(result?.invoiceNumber).toBe('INV-202601-0001');
});
});
describe('findAll', () => {
it('should return invoices with filters', async () => {
const mockInvoices = [
createMockInvoice({ id: 'inv-1' }),
createMockInvoice({ id: 'inv-2' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockInvoices);
mockQueryBuilder.getCount.mockResolvedValue(2);
const result = await service.findAll({ tenantId: 'tenant-uuid-1' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.tenantId = :tenantId',
{ tenantId: 'tenant-uuid-1' }
);
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
await service.findAll({ status: 'paid' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.status = :status',
{ status: 'paid' }
);
});
it('should filter by date range', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
const dateFrom = new Date('2026-01-01');
const dateTo = new Date('2026-01-31');
await service.findAll({ dateFrom, dateTo });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.invoiceDate >= :dateFrom',
{ dateFrom }
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.invoiceDate <= :dateTo',
{ dateTo }
);
});
it('should filter overdue invoices', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(0);
await service.findAll({ overdue: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'invoice.dueDate < :now',
expect.any(Object)
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
"invoice.status IN ('sent', 'partial')"
);
});
it('should apply pagination', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
mockQueryBuilder.getCount.mockResolvedValue(100);
await service.findAll({ limit: 10, offset: 20 });
expect(mockQueryBuilder.take).toHaveBeenCalledWith(10);
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20);
});
});
describe('update', () => {
it('should update draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.update('invoice-uuid-1', { notes: 'Updated note' });
expect(result.notes).toBe('Updated note');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { notes: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error if invoice is not draft', async () => {
const mockInvoice = createMockInvoice({ status: 'sent' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.update('invoice-uuid-1', { notes: 'test' })).rejects.toThrow(
'Only draft invoices can be updated'
);
});
});
describe('send', () => {
it('should send draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.send('invoice-uuid-1');
expect(result.status).toBe('sent');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.send('invalid-id')).rejects.toThrow('Invoice not found');
});
it('should throw error if invoice is not draft', async () => {
const mockInvoice = createMockInvoice({ status: 'paid' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.send('invoice-uuid-1')).rejects.toThrow(
'Only draft invoices can be sent'
);
});
});
describe('recordPayment', () => {
it('should record full payment', async () => {
const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.recordPayment('invoice-uuid-1', {
amount: 578.84,
paymentMethod: 'card',
paymentReference: 'PAY-123',
});
expect(result.status).toBe('paid');
expect(result.paidAmount).toBe(578.84);
});
it('should record partial payment', async () => {
const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.recordPayment('invoice-uuid-1', {
amount: 300,
paymentMethod: 'transfer',
});
expect(result.status).toBe('partial');
expect(result.paidAmount).toBe(300);
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(
service.recordPayment('invalid-id', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Invoice not found');
});
it('should throw error for voided invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'void' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Cannot record payment for voided or refunded invoice');
});
it('should throw error for refunded invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'refunded' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' })
).rejects.toThrow('Cannot record payment for voided or refunded invoice');
});
});
describe('void', () => {
it('should void draft invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.void('invoice-uuid-1', { reason: 'Created by mistake' });
expect(result.status).toBe('void');
expect(result.internalNotes).toContain('Voided: Created by mistake');
});
it('should void sent invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'sent' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.void('invoice-uuid-1', { reason: 'Customer cancelled' });
expect(result.status).toBe('void');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.void('invalid-id', { reason: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error for paid invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'paid' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Cannot void paid or refunded invoice'
);
});
it('should throw error for already refunded invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'refunded' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Cannot void paid or refunded invoice'
);
});
});
describe('refund', () => {
it('should refund paid invoice fully', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.refund('invoice-uuid-1', { reason: 'Customer requested' });
expect(result.status).toBe('refunded');
expect(result.internalNotes).toContain('Refunded: 578.84');
});
it('should refund partial amount', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv));
const result = await service.refund('invoice-uuid-1', {
amount: 200,
reason: 'Partial service',
});
expect(result.status).toBe('refunded');
expect(result.internalNotes).toContain('Refunded: 200');
});
it('should throw error if invoice not found', async () => {
mockInvoiceRepository.findOne.mockResolvedValue(null);
await expect(service.refund('invalid-id', { reason: 'test' })).rejects.toThrow(
'Invoice not found'
);
});
it('should throw error for unpaid invoice', async () => {
const mockInvoice = createMockInvoice({ status: 'draft' });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(service.refund('invoice-uuid-1', { reason: 'test' })).rejects.toThrow(
'Only paid invoices can be refunded'
);
});
it('should throw error if refund amount exceeds paid amount', async () => {
const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 100 });
mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice);
await expect(
service.refund('invoice-uuid-1', { amount: 200, reason: 'test' })
).rejects.toThrow('Refund amount cannot exceed paid amount');
});
});
describe('markOverdueInvoices', () => {
it('should mark overdue invoices', async () => {
const mockUpdateBuilder = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 5 }),
};
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder);
const result = await service.markOverdueInvoices();
expect(result).toBe(5);
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ status: 'overdue' });
});
it('should return 0 when no invoices are overdue', async () => {
const mockUpdateBuilder = {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 0 }),
};
mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder);
const result = await service.markOverdueInvoices();
expect(result).toBe(0);
});
});
describe('getStats', () => {
it('should return invoice statistics', async () => {
const mockInvoices = [
createMockInvoice({ status: 'paid', paidAmount: 500, total: 500 }),
createMockInvoice({ status: 'paid', paidAmount: 300, total: 300 }),
createMockInvoice({ status: 'sent', paidAmount: 0, total: 400, dueDate: new Date('2025-01-01') }),
createMockInvoice({ status: 'draft', paidAmount: 0, total: 200 }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockInvoices);
const result = await service.getStats('tenant-uuid-1');
expect(mockQueryBuilder.where).toHaveBeenCalledWith(
'invoice.tenantId = :tenantId',
{ tenantId: 'tenant-uuid-1' }
);
expect(result.total).toBe(4);
expect(result.byStatus.paid).toBe(2);
expect(result.byStatus.sent).toBe(1);
expect(result.byStatus.draft).toBe(1);
expect(result.totalRevenue).toBe(800);
expect(result.pendingAmount).toBe(400);
expect(result.overdueAmount).toBe(400); // The sent invoice is overdue
});
it('should return stats without tenant filter', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
const result = await service.getStats();
expect(mockQueryBuilder.where).not.toHaveBeenCalled();
expect(result.total).toBe(0);
});
});
describe('generateInvoiceNumber (via create)', () => {
it('should generate sequential invoice numbers', async () => {
// First invoice of the month
mockQueryBuilder.getOne.mockResolvedValueOnce(null);
const dto = {
tenantId: 'tenant-uuid-1',
items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }],
};
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Verify the invoice number format (INV-YYYYMM-0001)
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
invoiceNumber: expect.stringMatching(/^INV-\d{6}-0001$/),
})
);
});
it('should increment sequence for existing invoices', async () => {
// Return existing invoice for the month
mockQueryBuilder.getOne.mockResolvedValueOnce(
createMockInvoice({ invoiceNumber: 'INV-202601-0005' })
);
const dto = {
tenantId: 'tenant-uuid-1',
items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }],
};
mockInvoiceRepository.create.mockImplementation((data: any) => data);
mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' }));
mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice());
mockItemRepository.create.mockReturnValue(createMockInvoiceItem());
mockItemRepository.save.mockResolvedValue(createMockInvoiceItem());
await service.create(dto);
// Should be 0006
expect(mockInvoiceRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
invoiceNumber: expect.stringMatching(/^INV-\d{6}-0006$/),
})
);
});
});
});

View File

@ -1,466 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for billing entities
function createMockPlanLimit(overrides: Record<string, any> = {}) {
return {
id: 'limit-uuid-1',
planId: 'plan-uuid-1',
limitKey: 'users',
limitName: 'Active Users',
limitValue: 10,
limitType: 'monthly',
allowOverage: false,
overageUnitPrice: 0,
overageCurrency: 'MXN',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'PRO',
name: 'Professional Plan',
description: 'Professional subscription plan',
monthlyPrice: 499,
annualPrice: 4990,
currency: 'MXN',
isActive: true,
displayOrder: 2,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'subscription-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
status: 'active',
currentPrice: 499,
billingCycle: 'monthly',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockUsageTracking(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
periodEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0),
activeUsers: 5,
storageUsedGb: 2.5,
apiCalls: 1000,
activeBranches: 2,
documentsCount: 150,
invoicesGenerated: 50,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock repositories with extended methods
const mockLimitRepository = {
...createMockRepository(),
remove: jest.fn(),
};
const mockPlanRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockUsageRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'PlanLimit') return mockLimitRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'UsageTracking') return mockUsageRepository;
return mockLimitRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { PlanLimitsService } from '../services/plan-limits.service.js';
describe('PlanLimitsService', () => {
let service: PlanLimitsService;
const tenantId = 'tenant-uuid-1';
beforeEach(() => {
jest.clearAllMocks();
service = new PlanLimitsService(mockDataSource as any);
});
describe('create', () => {
it('should create a new plan limit successfully', async () => {
const dto = {
planId: 'plan-uuid-1',
limitKey: 'storage_gb',
limitName: 'Storage (GB)',
limitValue: 50,
limitType: 'fixed' as const,
};
const mockPlan = createMockSubscriptionPlan();
const mockLimit = createMockPlanLimit({ ...dto, id: 'new-limit-uuid' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockLimitRepository.findOne.mockResolvedValue(null);
mockLimitRepository.create.mockReturnValue(mockLimit);
mockLimitRepository.save.mockResolvedValue(mockLimit);
const result = await service.create(dto);
expect(result.limitKey).toBe('storage_gb');
expect(result.limitValue).toBe(50);
expect(mockLimitRepository.create).toHaveBeenCalled();
expect(mockLimitRepository.save).toHaveBeenCalled();
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const dto = {
planId: 'nonexistent-plan',
limitKey: 'users',
limitName: 'Users',
limitValue: 10,
};
await expect(service.create(dto)).rejects.toThrow('Plan not found');
});
it('should throw error if limit key already exists for plan', async () => {
const mockPlan = createMockSubscriptionPlan();
const existingLimit = createMockPlanLimit({ limitKey: 'users' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockLimitRepository.findOne.mockResolvedValue(existingLimit);
const dto = {
planId: 'plan-uuid-1',
limitKey: 'users',
limitName: 'Users',
limitValue: 10,
};
await expect(service.create(dto)).rejects.toThrow('Limit users already exists for this plan');
});
});
describe('findByPlan', () => {
it('should return all limits for a plan', async () => {
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
createMockPlanLimit({ limitKey: 'api_calls', limitValue: 10000 }),
];
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.findByPlan('plan-uuid-1');
expect(result).toHaveLength(3);
expect(mockLimitRepository.find).toHaveBeenCalledWith({
where: { planId: 'plan-uuid-1' },
order: { limitKey: 'ASC' },
});
});
});
describe('findByKey', () => {
it('should find a specific limit by key', async () => {
const mockLimit = createMockPlanLimit({ limitKey: 'users' });
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
const result = await service.findByKey('plan-uuid-1', 'users');
expect(result).toBeDefined();
expect(result?.limitKey).toBe('users');
});
it('should return null if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
const result = await service.findByKey('plan-uuid-1', 'nonexistent');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update a plan limit', async () => {
const mockLimit = createMockPlanLimit({ limitValue: 10 });
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
mockLimitRepository.save.mockResolvedValue({ ...mockLimit, limitValue: 20 });
const result = await service.update('limit-uuid-1', { limitValue: 20 });
expect(result.limitValue).toBe(20);
});
it('should throw error if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
await expect(service.update('nonexistent', { limitValue: 20 })).rejects.toThrow('Limit not found');
});
});
describe('delete', () => {
it('should delete a plan limit', async () => {
const mockLimit = createMockPlanLimit();
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
mockLimitRepository.remove.mockResolvedValue(mockLimit);
await expect(service.delete('limit-uuid-1')).resolves.not.toThrow();
expect(mockLimitRepository.remove).toHaveBeenCalledWith(mockLimit);
});
it('should throw error if limit not found', async () => {
mockLimitRepository.findOne.mockResolvedValue(null);
await expect(service.delete('nonexistent')).rejects.toThrow('Limit not found');
});
});
describe('getTenantLimits', () => {
it('should return limits for tenant with active subscription', async () => {
const mockSubscription = createMockSubscription({ planId: 'pro-plan' });
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 25 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 100 }),
];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimits(tenantId);
expect(result).toHaveLength(2);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId, status: 'active' },
});
});
it('should return free plan limits if no active subscription', async () => {
const mockFreePlan = createMockSubscriptionPlan({ id: 'free-plan', code: 'FREE' });
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 3 })];
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockFreePlan);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimits(tenantId);
expect(result).toHaveLength(1);
expect(result[0].limitValue).toBe(3);
});
it('should return empty array if no subscription and no free plan', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.getTenantLimits(tenantId);
expect(result).toEqual([]);
});
});
describe('getTenantLimit', () => {
it('should return specific limit value for tenant', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10 })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.getTenantLimit(tenantId, 'users');
expect(result).toBe(10);
});
it('should return 0 if limit not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.getTenantLimit(tenantId, 'nonexistent');
expect(result).toBe(0);
});
});
describe('checkUsage', () => {
it('should allow usage within limits', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 5, 1);
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(4);
expect(result.message).toBe('Dentro del límite');
});
it('should reject usage exceeding limits when overage not allowed', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 10, 1);
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(0);
expect(result.message).toContain('Límite alcanzado');
});
it('should allow overage when configured', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({
limitKey: 'users',
limitValue: 10,
allowOverage: true,
overageUnitPrice: 50,
}),
];
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
const result = await service.checkUsage(tenantId, 'users', 10, 2);
expect(result.allowed).toBe(true);
expect(result.overageAllowed).toBe(true);
expect(result.overageUnits).toBe(2);
expect(result.overageCost).toBe(100); // 2 * 50
});
it('should allow unlimited when no limit defined', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.checkUsage(tenantId, 'nonexistent', 1000, 100);
expect(result.allowed).toBe(true);
expect(result.limit).toBe(-1);
expect(result.remaining).toBe(-1);
});
});
describe('getCurrentUsage', () => {
it('should return current usage for a limit key', async () => {
const mockUsage = createMockUsageTracking({ activeUsers: 7 });
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getCurrentUsage(tenantId, 'users');
expect(result).toBe(7);
});
it('should return 0 if no usage record found', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getCurrentUsage(tenantId, 'users');
expect(result).toBe(0);
});
it('should return correct value for different limit keys', async () => {
const mockUsage = createMockUsageTracking({
activeUsers: 5,
storageUsedGb: 10,
apiCalls: 5000,
});
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
expect(await service.getCurrentUsage(tenantId, 'users')).toBe(5);
expect(await service.getCurrentUsage(tenantId, 'storage_gb')).toBe(10);
expect(await service.getCurrentUsage(tenantId, 'api_calls')).toBe(5000);
});
});
describe('validateAllLimits', () => {
it('should return valid when all limits OK', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
];
const mockUsage = createMockUsageTracking({ activeUsers: 5, storageUsedGb: 20 });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.validateAllLimits(tenantId);
expect(result.valid).toBe(true);
expect(result.violations).toHaveLength(0);
});
it('should return violations when limits exceeded', async () => {
const mockSubscription = createMockSubscription();
const mockLimits = [
createMockPlanLimit({ limitKey: 'users', limitValue: 5, allowOverage: false }),
];
const mockUsage = createMockUsageTracking({ activeUsers: 10 });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockLimitRepository.find.mockResolvedValue(mockLimits);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.validateAllLimits(tenantId);
expect(result.valid).toBe(false);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].limitKey).toBe('users');
});
});
describe('copyLimitsFromPlan', () => {
it('should copy all limits from source to target plan', async () => {
const sourceLimits = [
createMockPlanLimit({ id: 'limit-1', limitKey: 'users', limitValue: 10 }),
createMockPlanLimit({ id: 'limit-2', limitKey: 'storage_gb', limitValue: 50 }),
];
const targetPlan = createMockSubscriptionPlan({ id: 'target-plan' });
mockLimitRepository.find.mockResolvedValue(sourceLimits);
mockPlanRepository.findOne.mockResolvedValue(targetPlan);
mockLimitRepository.findOne.mockResolvedValue(null); // No existing limits
mockLimitRepository.create.mockImplementation((data) => data as any);
mockLimitRepository.save.mockImplementation((data) => Promise.resolve({ ...data, id: 'new-limit' }));
const result = await service.copyLimitsFromPlan('source-plan', 'target-plan');
expect(result).toHaveLength(2);
expect(mockLimitRepository.create).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -1,597 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository } from '../../../__tests__/helpers.js';
// Mock factories for Stripe entities
function createMockStripeEvent(overrides: Record<string, any> = {}) {
return {
id: 'event-uuid-1',
stripeEventId: 'evt_1234567890',
eventType: 'customer.subscription.created',
apiVersion: '2023-10-16',
data: {
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
processed: false,
processedAt: null,
retryCount: 0,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'subscription-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
status: 'active',
stripeCustomerId: 'cus_123',
stripeSubscriptionId: 'sub_123',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
billingCycle: 'monthly',
currentPrice: 499,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// Mock repositories
const mockEventRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'StripeEvent') return mockEventRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
return mockEventRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { StripeWebhookService, StripeWebhookPayload } from '../services/stripe-webhook.service.js';
describe('StripeWebhookService', () => {
let service: StripeWebhookService;
beforeEach(() => {
jest.clearAllMocks();
service = new StripeWebhookService(mockDataSource as any);
});
describe('processWebhook', () => {
it('should process a new webhook event successfully', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_new_event',
type: 'customer.subscription.created',
api_version: '2023-10-16',
data: {
object: {
id: 'sub_new',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_new_event' });
const mockSubscription = createMockSubscription();
mockEventRepository.findOne.mockResolvedValue(null); // No existing event
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed successfully');
expect(mockEventRepository.create).toHaveBeenCalled();
});
it('should return success for already processed event', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_already_processed',
type: 'customer.subscription.created',
data: { object: {} },
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const existingEvent = createMockStripeEvent({
stripeEventId: 'evt_already_processed',
processed: true,
});
mockEventRepository.findOne.mockResolvedValue(existingEvent);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event already processed');
});
it('should retry processing for failed event', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_failed',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_retry',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const failedEvent = createMockStripeEvent({
stripeEventId: 'evt_failed',
processed: false,
retryCount: 1,
data: payload.data,
});
const mockSubscription = createMockSubscription();
mockEventRepository.findOne.mockResolvedValue(failedEvent);
mockEventRepository.save.mockResolvedValue(failedEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed on retry');
});
it('should handle processing errors gracefully', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_error',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_error',
customer: 'cus_123',
status: 'active',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_error' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockRejectedValue(new Error('Database error'));
const result = await service.processWebhook(payload);
expect(result.success).toBe(false);
expect(result.error).toBe('Database error');
});
});
describe('handleSubscriptionCreated', () => {
it('should create/link subscription for existing customer', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_created',
type: 'customer.subscription.created',
data: {
object: {
id: 'sub_new_123',
customer: 'cus_existing',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
trial_end: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_existing' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
expect(mockSubscriptionRepository.save).toHaveBeenCalled();
});
});
describe('handleSubscriptionUpdated', () => {
it('should update subscription status', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_updated',
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_123',
customer: 'cus_123',
status: 'past_due',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
cancel_at_period_end: false,
canceled_at: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_123' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue({ ...mockSubscription, status: 'past_due' });
const result = await service.processWebhook(payload);
expect(result.success).toBe(true);
});
it('should handle cancel_at_period_end flag', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_cancel_scheduled',
type: 'customer.subscription.updated',
data: {
object: {
id: 'sub_cancel',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
cancel_at_period_end: true,
canceled_at: null,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'customer.subscription.updated' });
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_cancel' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ cancelAtPeriodEnd: true })
);
});
});
describe('handleSubscriptionDeleted', () => {
it('should mark subscription as cancelled', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_sub_deleted',
type: 'customer.subscription.deleted',
data: {
object: {
id: 'sub_deleted',
customer: 'cus_123',
status: 'canceled',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent();
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_deleted' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
});
describe('handlePaymentSucceeded', () => {
it('should update subscription with payment info', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_payment_success',
type: 'invoice.payment_succeeded',
data: {
object: {
id: 'inv_123',
customer: 'cus_123',
amount_paid: 49900, // $499.00 in cents
subscription: 'sub_123',
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_succeeded' });
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
status: 'active',
lastPaymentAmount: 499, // Converted from cents
})
);
});
});
describe('handlePaymentFailed', () => {
it('should mark subscription as past_due', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_payment_failed',
type: 'invoice.payment_failed',
data: {
object: {
id: 'inv_failed',
customer: 'cus_123',
attempt_count: 1,
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_failed' });
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123', status: 'active' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: 'past_due' })
);
});
});
describe('handleCheckoutCompleted', () => {
it('should link Stripe customer to tenant', async () => {
const payload: StripeWebhookPayload = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_123',
customer: 'cus_new',
subscription: 'sub_new',
metadata: {
tenant_id: 'tenant-uuid-1',
},
},
},
created: Math.floor(Date.now() / 1000),
livemode: false,
};
const mockEvent = createMockStripeEvent({ eventType: 'checkout.session.completed' });
const mockSubscription = createMockSubscription({ tenantId: 'tenant-uuid-1' });
mockEventRepository.findOne.mockResolvedValue(null);
mockEventRepository.create.mockReturnValue(mockEvent);
mockEventRepository.save.mockResolvedValue(mockEvent);
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
await service.processWebhook(payload);
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
stripeCustomerId: 'cus_new',
stripeSubscriptionId: 'sub_new',
status: 'active',
})
);
});
});
describe('retryProcessing', () => {
it('should retry and succeed', async () => {
const failedEvent = createMockStripeEvent({
processed: false,
retryCount: 2,
data: {
object: {
id: 'sub_retry',
customer: 'cus_123',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
},
});
const mockSubscription = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
mockEventRepository.save.mockResolvedValue(failedEvent);
const result = await service.retryProcessing(failedEvent as any);
expect(result.success).toBe(true);
expect(result.message).toBe('Event processed on retry');
});
it('should fail if max retries exceeded', async () => {
const maxRetriedEvent = createMockStripeEvent({
processed: false,
retryCount: 5,
errorMessage: 'Previous error',
});
const result = await service.retryProcessing(maxRetriedEvent as any);
expect(result.success).toBe(false);
expect(result.message).toBe('Max retries exceeded');
});
});
describe('getFailedEvents', () => {
it('should return unprocessed events', async () => {
const failedEvents = [
createMockStripeEvent({ processed: false }),
createMockStripeEvent({ processed: false, stripeEventId: 'evt_2' }),
];
mockEventRepository.find.mockResolvedValue(failedEvents);
const result = await service.getFailedEvents();
expect(result).toHaveLength(2);
expect(mockEventRepository.find).toHaveBeenCalledWith({
where: { processed: false },
order: { createdAt: 'ASC' },
take: 100,
});
});
it('should respect limit parameter', async () => {
mockEventRepository.find.mockResolvedValue([]);
await service.getFailedEvents(50);
expect(mockEventRepository.find).toHaveBeenCalledWith(
expect.objectContaining({ take: 50 })
);
});
});
describe('findByStripeEventId', () => {
it('should find event by Stripe ID', async () => {
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_find' });
mockEventRepository.findOne.mockResolvedValue(mockEvent);
const result = await service.findByStripeEventId('evt_find');
expect(result).toBeDefined();
expect(result?.stripeEventId).toBe('evt_find');
});
it('should return null if not found', async () => {
mockEventRepository.findOne.mockResolvedValue(null);
const result = await service.findByStripeEventId('evt_notfound');
expect(result).toBeNull();
});
});
describe('getRecentEvents', () => {
it('should return recent events with default options', async () => {
const mockEvents = [createMockStripeEvent()];
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(mockEvents),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
const result = await service.getRecentEvents();
expect(result).toHaveLength(1);
expect(mockQueryBuilder.take).toHaveBeenCalledWith(50);
});
it('should filter by event type', async () => {
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
await service.getRecentEvents({ eventType: 'invoice.payment_succeeded' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'event.eventType = :eventType',
{ eventType: 'invoice.payment_succeeded' }
);
});
it('should filter by processed status', async () => {
const mockQueryBuilder = {
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
};
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
await service.getRecentEvents({ processed: false });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'event.processed = :processed',
{ processed: false }
);
});
});
});

View File

@ -1,408 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories for subscription plan entities
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'STARTER',
name: 'Starter Plan',
description: 'Perfect for small businesses',
planType: 'saas',
baseMonthlyPrice: 499,
baseAnnualPrice: 4990,
setupFee: 0,
maxUsers: 5,
maxBranches: 1,
storageGb: 10,
apiCallsMonthly: 10000,
includedModules: ['core', 'sales', 'inventory'],
includedPlatforms: ['web'],
features: { analytics: true, reports: false },
isActive: true,
isPublic: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
};
}
// Mock repositories
const mockPlanRepository = {
...createMockRepository(),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
};
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn(() => mockPlanRepository),
createQueryBuilder: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({ count: '0' }),
})),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { SubscriptionPlansService } from '../services/subscription-plans.service.js';
describe('SubscriptionPlansService', () => {
let service: SubscriptionPlansService;
beforeEach(() => {
jest.clearAllMocks();
service = new SubscriptionPlansService(mockDataSource as any);
mockPlanRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new subscription plan successfully', async () => {
const dto = {
code: 'NEWPLAN',
name: 'New Plan',
baseMonthlyPrice: 999,
maxUsers: 10,
};
const mockPlan = createMockSubscriptionPlan({ ...dto, id: 'new-plan-uuid' });
mockPlanRepository.findOne.mockResolvedValue(null);
mockPlanRepository.create.mockReturnValue(mockPlan);
mockPlanRepository.save.mockResolvedValue(mockPlan);
const result = await service.create(dto);
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'NEWPLAN' } });
expect(mockPlanRepository.create).toHaveBeenCalled();
expect(mockPlanRepository.save).toHaveBeenCalled();
expect(result.code).toBe('NEWPLAN');
});
it('should throw error if plan code already exists', async () => {
const dto = {
code: 'STARTER',
name: 'Duplicate Plan',
baseMonthlyPrice: 999,
};
mockPlanRepository.findOne.mockResolvedValue(createMockSubscriptionPlan());
await expect(service.create(dto)).rejects.toThrow('Plan with code STARTER already exists');
});
it('should use default values when not provided', async () => {
const dto = {
code: 'MINIMAL',
name: 'Minimal Plan',
baseMonthlyPrice: 199,
};
mockPlanRepository.findOne.mockResolvedValue(null);
mockPlanRepository.create.mockImplementation((data: any) => ({
...data,
id: 'minimal-plan-uuid',
}));
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan));
await service.create(dto);
expect(mockPlanRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
planType: 'saas',
setupFee: 0,
maxUsers: 5,
maxBranches: 1,
storageGb: 10,
apiCallsMonthly: 10000,
includedModules: [],
includedPlatforms: ['web'],
features: {},
isActive: true,
isPublic: true,
})
);
});
});
describe('findAll', () => {
it('should return all plans without filters', async () => {
const mockPlans = [
createMockSubscriptionPlan({ id: 'plan-1', code: 'STARTER' }),
createMockSubscriptionPlan({ id: 'plan-2', code: 'PRO' }),
];
mockQueryBuilder.getMany.mockResolvedValue(mockPlans);
const result = await service.findAll();
expect(mockPlanRepository.createQueryBuilder).toHaveBeenCalledWith('plan');
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('plan.baseMonthlyPrice', 'ASC');
expect(result).toHaveLength(2);
});
it('should filter by isActive', async () => {
mockQueryBuilder.getMany.mockResolvedValue([createMockSubscriptionPlan()]);
await service.findAll({ isActive: true });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isActive = :isActive',
{ isActive: true }
);
});
it('should filter by isPublic', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ isPublic: false });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isPublic = :isPublic',
{ isPublic: false }
);
});
it('should filter by planType', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ planType: 'on_premise' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.planType = :planType',
{ planType: 'on_premise' }
);
});
it('should apply multiple filters', async () => {
mockQueryBuilder.getMany.mockResolvedValue([]);
await service.findAll({ isActive: true, isPublic: true, planType: 'saas' });
expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3);
});
});
describe('findPublicPlans', () => {
it('should return only active and public plans', async () => {
const publicPlans = [createMockSubscriptionPlan({ isActive: true, isPublic: true })];
mockQueryBuilder.getMany.mockResolvedValue(publicPlans);
const result = await service.findPublicPlans();
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isActive = :isActive',
{ isActive: true }
);
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
'plan.isPublic = :isPublic',
{ isPublic: true }
);
expect(result).toHaveLength(1);
});
});
describe('findById', () => {
it('should return plan by id', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
const result = await service.findById('plan-uuid-1');
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } });
expect(result?.id).toBe('plan-uuid-1');
});
it('should return null if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.findById('non-existent-id');
expect(result).toBeNull();
});
});
describe('findByCode', () => {
it('should return plan by code', async () => {
const mockPlan = createMockSubscriptionPlan({ code: 'STARTER' });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
const result = await service.findByCode('STARTER');
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'STARTER' } });
expect(result?.code).toBe('STARTER');
});
it('should return null if code not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
const result = await service.findByCode('UNKNOWN');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update plan successfully', async () => {
const existingPlan = createMockSubscriptionPlan();
const updateDto = { name: 'Updated Plan Name', baseMonthlyPrice: 599 };
mockPlanRepository.findOne.mockResolvedValue(existingPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan));
const result = await service.update('plan-uuid-1', updateDto);
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } });
expect(result.name).toBe('Updated Plan Name');
expect(result.baseMonthlyPrice).toBe(599);
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.update('non-existent-id', { name: 'Test' }))
.rejects.toThrow('Plan not found');
});
});
describe('delete', () => {
it('should soft delete plan with no active subscriptions', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockDataSource.createQueryBuilder().getRawOne.mockResolvedValue({ count: '0' });
await service.delete('plan-uuid-1');
expect(mockPlanRepository.softDelete).toHaveBeenCalledWith('plan-uuid-1');
});
it('should throw error if plan not found', async () => {
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.delete('non-existent-id'))
.rejects.toThrow('Plan not found');
});
it('should throw error if plan has active subscriptions', async () => {
const mockPlan = createMockSubscriptionPlan();
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
// Need to reset the mock to return count > 0 for this test
const mockQb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue({ count: '5' }),
};
mockDataSource.createQueryBuilder.mockReturnValue(mockQb);
await expect(service.delete('plan-uuid-1'))
.rejects.toThrow('Cannot delete plan with active subscriptions');
});
});
describe('setActive', () => {
it('should activate a plan', async () => {
const mockPlan = createMockSubscriptionPlan({ isActive: false });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({
...plan,
isActive: true,
}));
const result = await service.setActive('plan-uuid-1', true);
expect(result.isActive).toBe(true);
});
it('should deactivate a plan', async () => {
const mockPlan = createMockSubscriptionPlan({ isActive: true });
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({
...plan,
isActive: false,
}));
const result = await service.setActive('plan-uuid-1', false);
expect(result.isActive).toBe(false);
});
});
describe('comparePlans', () => {
it('should compare two plans and return differences', async () => {
const plan1 = createMockSubscriptionPlan({
id: 'plan-1',
code: 'STARTER',
baseMonthlyPrice: 499,
maxUsers: 5,
includedModules: ['core', 'sales'],
});
const plan2 = createMockSubscriptionPlan({
id: 'plan-2',
code: 'PRO',
baseMonthlyPrice: 999,
maxUsers: 20,
includedModules: ['core', 'sales', 'inventory', 'reports'],
});
mockPlanRepository.findOne
.mockResolvedValueOnce(plan1)
.mockResolvedValueOnce(plan2);
const result = await service.comparePlans('plan-1', 'plan-2');
expect(result.plan1.code).toBe('STARTER');
expect(result.plan2.code).toBe('PRO');
expect(result.differences.baseMonthlyPrice).toEqual({
plan1: 499,
plan2: 999,
});
expect(result.differences.maxUsers).toEqual({
plan1: 5,
plan2: 20,
});
expect(result.differences.includedModules).toBeDefined();
});
it('should throw error if plan1 not found', async () => {
mockPlanRepository.findOne
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(createMockSubscriptionPlan());
await expect(service.comparePlans('invalid-1', 'plan-2'))
.rejects.toThrow('One or both plans not found');
});
it('should throw error if plan2 not found', async () => {
mockPlanRepository.findOne
.mockResolvedValueOnce(createMockSubscriptionPlan())
.mockResolvedValueOnce(null);
await expect(service.comparePlans('plan-1', 'invalid-2'))
.rejects.toThrow('One or both plans not found');
});
it('should return empty differences for identical plans', async () => {
const plan = createMockSubscriptionPlan();
mockPlanRepository.findOne
.mockResolvedValueOnce(plan)
.mockResolvedValueOnce({ ...plan, id: 'plan-2' });
const result = await service.comparePlans('plan-1', 'plan-2');
expect(Object.keys(result.differences)).toHaveLength(0);
});
});
});

View File

@ -1,502 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'STARTER',
name: 'Starter Plan',
baseMonthlyPrice: 499,
baseAnnualPrice: 4990,
maxUsers: 5,
maxBranches: 1,
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
billingCycle: 'monthly',
currentPeriodStart: new Date('2026-01-01'),
currentPeriodEnd: new Date('2026-02-01'),
status: 'active',
trialStart: null,
trialEnd: null,
billingEmail: 'billing@example.com',
billingName: 'Test Company',
billingAddress: {},
taxId: 'RFC123456',
paymentMethodId: null,
paymentProvider: null,
currentPrice: 499,
discountPercent: 0,
discountReason: null,
contractedUsers: 5,
contractedBranches: 1,
autoRenew: true,
nextInvoiceDate: new Date('2026-02-01'),
cancelAtPeriodEnd: false,
cancelledAt: null,
cancellationReason: null,
createdAt: new Date(),
updatedAt: new Date(),
plan: createMockSubscriptionPlan(),
...overrides,
};
}
// Mock repositories
const mockSubscriptionRepository = createMockRepository();
const mockPlanRepository = createMockRepository();
const mockQueryBuilder = createMockQueryBuilder();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
return mockSubscriptionRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { SubscriptionsService } from '../services/subscriptions.service.js';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
beforeEach(() => {
jest.clearAllMocks();
service = new SubscriptionsService(mockDataSource as any);
mockSubscriptionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
});
describe('create', () => {
it('should create a new subscription successfully', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'plan-uuid-1',
billingEmail: 'test@example.com',
currentPrice: 499,
};
const mockPlan = createMockSubscriptionPlan();
const mockSub = createMockSubscription({ tenantId: dto.tenantId });
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockSubscriptionRepository.create.mockReturnValue(mockSub);
mockSubscriptionRepository.save.mockResolvedValue(mockSub);
const result = await service.create(dto);
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: 'tenant-uuid-new' },
});
expect(mockPlanRepository.findOne).toHaveBeenCalledWith({
where: { id: 'plan-uuid-1' },
});
expect(result.tenantId).toBe('tenant-uuid-new');
});
it('should throw error if tenant already has subscription', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
};
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription');
});
it('should throw error if plan not found', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'invalid-plan',
currentPrice: 499,
};
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.create(dto)).rejects.toThrow('Plan not found');
});
it('should create subscription with trial', async () => {
const dto = {
tenantId: 'tenant-uuid-new',
planId: 'plan-uuid-1',
currentPrice: 499,
startWithTrial: true,
trialDays: 14,
};
const mockPlan = createMockSubscriptionPlan();
mockSubscriptionRepository.findOne.mockResolvedValue(null);
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
mockSubscriptionRepository.create.mockImplementation((data: any) => ({
...data,
id: 'new-sub-id',
}));
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.create(dto);
expect(mockSubscriptionRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
status: 'trial',
})
);
expect(result.trialStart).toBeDefined();
expect(result.trialEnd).toBeDefined();
});
});
describe('findByTenantId', () => {
it('should return subscription with plan relation', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
const result = await service.findByTenantId('tenant-uuid-1');
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { tenantId: 'tenant-uuid-1' },
relations: ['plan'],
});
expect(result?.tenantId).toBe('tenant-uuid-1');
});
it('should return null if not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
const result = await service.findByTenantId('non-existent');
expect(result).toBeNull();
});
});
describe('findById', () => {
it('should return subscription by id', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
const result = await service.findById('sub-uuid-1');
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
where: { id: 'sub-uuid-1' },
relations: ['plan'],
});
expect(result?.id).toBe('sub-uuid-1');
});
});
describe('update', () => {
it('should update subscription successfully', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.update('sub-uuid-1', {
billingEmail: 'new@example.com',
});
expect(result.billingEmail).toBe('new@example.com');
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { billingEmail: 'test@example.com' }))
.rejects.toThrow('Subscription not found');
});
it('should validate plan when changing plan', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.update('sub-uuid-1', { planId: 'new-plan-id' }))
.rejects.toThrow('Plan not found');
});
});
describe('cancel', () => {
it('should cancel at period end by default', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.cancel('sub-uuid-1', { reason: 'Too expensive' });
expect(result.cancelAtPeriodEnd).toBe(true);
expect(result.autoRenew).toBe(false);
expect(result.cancellationReason).toBe('Too expensive');
expect(result.status).toBe('active'); // Still active until period end
});
it('should cancel immediately when specified', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.cancel('sub-uuid-1', {
reason: 'Closing business',
cancelImmediately: true,
});
expect(result.status).toBe('cancelled');
});
it('should throw error if already cancelled', async () => {
const mockSub = createMockSubscription({ status: 'cancelled' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.cancel('sub-uuid-1', {}))
.rejects.toThrow('Subscription is already cancelled');
});
it('should throw error if not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.cancel('invalid-id', {}))
.rejects.toThrow('Subscription not found');
});
});
describe('reactivate', () => {
it('should reactivate cancelled subscription', async () => {
const mockSub = createMockSubscription({ status: 'cancelled', cancelAtPeriodEnd: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.reactivate('sub-uuid-1');
expect(result.status).toBe('active');
expect(result.cancelAtPeriodEnd).toBe(false);
expect(result.autoRenew).toBe(true);
});
it('should reactivate subscription pending cancellation', async () => {
const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: true });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.reactivate('sub-uuid-1');
expect(result.cancelAtPeriodEnd).toBe(false);
expect(result.autoRenew).toBe(true);
});
it('should throw error if not cancelled', async () => {
const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.reactivate('sub-uuid-1'))
.rejects.toThrow('Subscription is not cancelled');
});
});
describe('changePlan', () => {
it('should change to new plan', async () => {
const mockSub = createMockSubscription();
const newPlan = createMockSubscriptionPlan({
id: 'plan-uuid-2',
code: 'PRO',
baseMonthlyPrice: 999,
maxUsers: 20,
maxBranches: 5,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(newPlan);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' });
expect(result.planId).toBe('plan-uuid-2');
expect(result.currentPrice).toBe(999);
expect(result.contractedUsers).toBe(20);
expect(result.contractedBranches).toBe(5);
});
it('should throw error if new plan not found', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(null);
await expect(service.changePlan('sub-uuid-1', { newPlanId: 'invalid-plan' }))
.rejects.toThrow('New plan not found');
});
it('should apply existing discount to new plan price', async () => {
const mockSub = createMockSubscription({ discountPercent: 20 });
const newPlan = createMockSubscriptionPlan({
id: 'plan-uuid-2',
baseMonthlyPrice: 1000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockPlanRepository.findOne.mockResolvedValue(newPlan);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' });
expect(result.currentPrice).toBe(800); // 1000 - 20%
});
});
describe('setPaymentMethod', () => {
it('should set payment method', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.setPaymentMethod('sub-uuid-1', {
paymentMethodId: 'pm_123',
paymentProvider: 'stripe',
});
expect(result.paymentMethodId).toBe('pm_123');
expect(result.paymentProvider).toBe('stripe');
});
});
describe('renew', () => {
it('should renew subscription and advance period', async () => {
const mockSub = createMockSubscription({
currentPeriodStart: new Date('2026-01-01'),
currentPeriodEnd: new Date('2026-02-01'),
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.currentPeriodStart.getTime()).toBe(new Date('2026-02-01').getTime());
});
it('should cancel if cancelAtPeriodEnd is true', async () => {
const mockSub = createMockSubscription({ cancelAtPeriodEnd: true });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.status).toBe('cancelled');
});
it('should throw error if autoRenew is disabled', async () => {
const mockSub = createMockSubscription({ autoRenew: false });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
await expect(service.renew('sub-uuid-1'))
.rejects.toThrow('Subscription auto-renew is disabled');
});
it('should transition from trial to active', async () => {
const mockSub = createMockSubscription({ status: 'trial' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.renew('sub-uuid-1');
expect(result.status).toBe('active');
});
});
describe('status updates', () => {
it('should mark as past due', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.markPastDue('sub-uuid-1');
expect(result.status).toBe('past_due');
});
it('should suspend subscription', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.suspend('sub-uuid-1');
expect(result.status).toBe('suspended');
});
it('should activate subscription', async () => {
const mockSub = createMockSubscription({ status: 'suspended' });
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub));
const result = await service.activate('sub-uuid-1');
expect(result.status).toBe('active');
});
});
describe('findExpiringSoon', () => {
it('should find subscriptions expiring within days', async () => {
const mockSubs = [createMockSubscription()];
mockQueryBuilder.getMany.mockResolvedValue(mockSubs);
const result = await service.findExpiringSoon(7);
expect(mockSubscriptionRepository.createQueryBuilder).toHaveBeenCalledWith('sub');
expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('sub.plan', 'plan');
expect(result).toHaveLength(1);
});
});
describe('findTrialsEndingSoon', () => {
it('should find trials ending within days', async () => {
const mockSubs = [createMockSubscription({ status: 'trial' })];
mockQueryBuilder.getMany.mockResolvedValue(mockSubs);
const result = await service.findTrialsEndingSoon(3);
expect(mockQueryBuilder.where).toHaveBeenCalledWith("sub.status = 'trial'");
expect(result).toHaveLength(1);
});
});
describe('getStats', () => {
it('should return subscription statistics', async () => {
const mockSubs = [
createMockSubscription({ status: 'active', currentPrice: 499, plan: { code: 'STARTER' } }),
createMockSubscription({ status: 'active', currentPrice: 999, plan: { code: 'PRO' } }),
createMockSubscription({ status: 'trial', currentPrice: 499, plan: { code: 'STARTER' } }),
createMockSubscription({ status: 'cancelled', currentPrice: 499, plan: { code: 'STARTER' } }),
];
mockSubscriptionRepository.find.mockResolvedValue(mockSubs);
const result = await service.getStats();
expect(result.total).toBe(4);
expect(result.byStatus.active).toBe(2);
expect(result.byStatus.trial).toBe(1);
expect(result.byStatus.cancelled).toBe(1);
expect(result.byPlan['STARTER']).toBe(3);
expect(result.byPlan['PRO']).toBe(1);
expect(result.totalMRR).toBe(499 + 999 + 499); // Active and trial subscriptions
expect(result.totalARR).toBe(result.totalMRR * 12);
});
});
});

View File

@ -1,423 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository } from '../../../__tests__/helpers.js';
// Mock factories
function createMockUsageTracking(overrides: Record<string, any> = {}) {
return {
id: 'usage-uuid-1',
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
peakConcurrentUsers: 3,
usersByProfile: { ADM: 1, VNT: 2, ALM: 2 },
usersByPlatform: { web: 5, mobile: 2 },
activeBranches: 2,
storageUsedGb: 5.5,
documentsCount: 1500,
apiCalls: 5000,
apiErrors: 50,
salesCount: 200,
salesAmount: 150000,
invoicesGenerated: 150,
mobileSessions: 100,
offlineSyncs: 25,
paymentTransactions: 180,
totalBillableAmount: 499,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
return {
id: 'sub-uuid-1',
tenantId: 'tenant-uuid-1',
planId: 'plan-uuid-1',
currentPrice: 499,
contractedUsers: 10,
contractedBranches: 3,
plan: {
id: 'plan-uuid-1',
code: 'STARTER',
maxUsers: 10,
maxBranches: 3,
storageGb: 20,
apiCallsMonthly: 10000,
},
...overrides,
};
}
// Mock repositories
const mockUsageRepository = createMockRepository();
const mockSubscriptionRepository = createMockRepository();
const mockPlanRepository = createMockRepository();
// Mock DataSource
const mockDataSource = {
getRepository: jest.fn((entity: any) => {
const entityName = entity.name || entity;
if (entityName === 'UsageTracking') return mockUsageRepository;
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
return mockUsageRepository;
}),
};
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
},
}));
// Import after mocking
import { UsageTrackingService } from '../services/usage-tracking.service.js';
describe('UsageTrackingService', () => {
let service: UsageTrackingService;
beforeEach(() => {
jest.clearAllMocks();
service = new UsageTrackingService(mockDataSource as any);
});
describe('recordUsage', () => {
it('should create new usage record', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
apiCalls: 1000,
};
const mockUsage = createMockUsageTracking(dto);
mockUsageRepository.findOne.mockResolvedValueOnce(null); // No existing record
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockReturnValue(mockUsage);
mockUsageRepository.save.mockResolvedValue(mockUsage);
const result = await service.recordUsage(dto);
expect(mockUsageRepository.findOne).toHaveBeenCalled();
expect(mockUsageRepository.create).toHaveBeenCalled();
expect(result.tenantId).toBe('tenant-uuid-1');
});
it('should update existing record if one exists for period', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 10,
};
const existingUsage = createMockUsageTracking();
mockUsageRepository.findOne
.mockResolvedValueOnce(existingUsage) // First call - check existing
.mockResolvedValueOnce(existingUsage); // Second call - in update
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
expect(result.activeUsers).toBe(10);
});
});
describe('update', () => {
it('should update usage record', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.update('usage-uuid-1', { apiCalls: 8000 });
expect(result.apiCalls).toBe(8000);
});
it('should throw error if record not found', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
await expect(service.update('invalid-id', { apiCalls: 100 }))
.rejects.toThrow('Usage record not found');
});
it('should recalculate billable amount on update', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.update('usage-uuid-1', { activeUsers: 15 }); // Exceeds limit
expect(mockUsageRepository.save).toHaveBeenCalled();
});
});
describe('incrementMetric', () => {
it('should increment metric on existing record', async () => {
const mockUsage = createMockUsageTracking({ apiCalls: 5000 });
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.incrementMetric('tenant-uuid-1', 'apiCalls', 100);
expect(mockUsageRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ apiCalls: 5100 })
);
});
it('should create record if none exists for period', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => ({
...createMockUsageTracking(),
...data,
apiCalls: 0,
}));
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
await service.incrementMetric('tenant-uuid-1', 'apiCalls', 50);
expect(mockUsageRepository.create).toHaveBeenCalled();
});
});
describe('getCurrentUsage', () => {
it('should return current period usage', async () => {
const mockUsage = createMockUsageTracking();
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getCurrentUsage('tenant-uuid-1');
expect(result?.tenantId).toBe('tenant-uuid-1');
});
it('should return null if no usage for current period', async () => {
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getCurrentUsage('tenant-uuid-1');
expect(result).toBeNull();
});
});
describe('getUsageHistory', () => {
it('should return usage records within date range', async () => {
const mockUsages = [
createMockUsageTracking({ id: 'usage-1' }),
createMockUsageTracking({ id: 'usage-2' }),
];
mockUsageRepository.find.mockResolvedValue(mockUsages);
const result = await service.getUsageHistory(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-03-31')
);
expect(mockUsageRepository.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-uuid-1' }),
order: { periodStart: 'DESC' },
})
);
expect(result).toHaveLength(2);
});
});
describe('getUsageSummary', () => {
it('should return usage summary with limits', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.getUsageSummary('tenant-uuid-1');
expect(result.tenantId).toBe('tenant-uuid-1');
expect(result.currentUsers).toBe(5);
expect(result.limits.maxUsers).toBe(10);
expect(result.percentages.usersUsed).toBe(50);
});
it('should throw error if subscription not found', async () => {
mockSubscriptionRepository.findOne.mockResolvedValue(null);
await expect(service.getUsageSummary('tenant-uuid-1'))
.rejects.toThrow('Subscription not found');
});
it('should handle missing current usage gracefully', async () => {
const mockSub = createMockSubscription();
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(null);
const result = await service.getUsageSummary('tenant-uuid-1');
expect(result.currentUsers).toBe(0);
expect(result.apiCallsThisMonth).toBe(0);
});
});
describe('checkLimits', () => {
it('should return no violations when within limits', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(false);
expect(result.violations).toHaveLength(0);
});
it('should return violations when limits exceeded', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 15, // Exceeds 10
activeBranches: 5, // Exceeds 3
storageUsedGb: 10,
apiCalls: 5000,
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(true);
expect(result.violations.length).toBeGreaterThan(0);
expect(result.violations.some((v: string) => v.includes('Users'))).toBe(true);
expect(result.violations.some((v: string) => v.includes('Branches'))).toBe(true);
});
it('should return warnings at 80% threshold', async () => {
const mockSub = createMockSubscription();
const mockUsage = createMockUsageTracking({
activeUsers: 8, // 80% of 10
activeBranches: 2,
storageUsedGb: 16, // 80% of 20
apiCalls: 8000, // 80% of 10000
});
mockSubscriptionRepository.findOne.mockResolvedValue(mockSub);
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
const result = await service.checkLimits('tenant-uuid-1');
expect(result.exceeds).toBe(false);
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings.some((w: string) => w.includes('Users'))).toBe(true);
expect(result.warnings.some((w: string) => w.includes('Storage'))).toBe(true);
});
});
describe('getUsageReport', () => {
it('should generate usage report with totals and averages', async () => {
const mockUsages = [
createMockUsageTracking({
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 5,
apiCalls: 5000,
salesCount: 100,
salesAmount: 50000,
}),
createMockUsageTracking({
activeUsers: 7,
activeBranches: 3,
storageUsedGb: 6,
apiCalls: 6000,
salesCount: 150,
salesAmount: 75000,
}),
];
mockUsageRepository.find.mockResolvedValue(mockUsages);
const result = await service.getUsageReport(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-02-28')
);
expect(result.tenantId).toBe('tenant-uuid-1');
expect(result.data).toHaveLength(2);
expect(result.totals.apiCalls).toBe(11000);
expect(result.totals.salesCount).toBe(250);
expect(result.totals.salesAmount).toBe(125000);
expect(result.averages.activeUsers).toBe(6);
expect(result.averages.activeBranches).toBe(3);
});
it('should handle empty usage data', async () => {
mockUsageRepository.find.mockResolvedValue([]);
const result = await service.getUsageReport(
'tenant-uuid-1',
new Date('2026-01-01'),
new Date('2026-02-28')
);
expect(result.data).toHaveLength(0);
expect(result.totals.apiCalls).toBe(0);
expect(result.averages.activeUsers).toBe(0);
});
});
describe('calculateBillableAmount (via recordUsage)', () => {
it('should calculate base price for usage within limits', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 5,
activeBranches: 2,
storageUsedGb: 10,
apiCalls: 5000,
};
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => data);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
expect(result.totalBillableAmount).toBe(499); // Base price, no overages
});
it('should add overage charges when limits exceeded', async () => {
const dto = {
tenantId: 'tenant-uuid-1',
periodStart: new Date('2026-01-01'),
periodEnd: new Date('2026-01-31'),
activeUsers: 15, // 5 extra users at $10 each = $50
activeBranches: 5, // 2 extra branches at $20 each = $40
storageUsedGb: 25, // 5 extra GB at $0.50 each = $2.50
apiCalls: 15000, // 5000 extra at $0.001 each = $5
};
mockUsageRepository.findOne.mockResolvedValue(null);
mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription());
mockUsageRepository.create.mockImplementation((data: any) => data);
mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage));
const result = await service.recordUsage(dto);
// Base: 499 + Extra users: 50 + Extra branches: 40 + Extra storage: 2.5 + Extra API: 5 = 596.5
expect(result.totalBillableAmount).toBe(596.5);
});
});
});

View File

@ -53,7 +53,6 @@ export class BillingUsageModule {
require('./entities/usage-tracking.entity').UsageTracking, require('./entities/usage-tracking.entity').UsageTracking,
require('./entities/invoice.entity').Invoice, require('./entities/invoice.entity').Invoice,
require('./entities/invoice-item.entity').InvoiceItem, require('./entities/invoice-item.entity').InvoiceItem,
require('./entities/plan-feature.entity').PlanFeature,
]; ];
} }
} }

View File

@ -1,44 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
} from 'typeorm';
import { Coupon } from './coupon.entity.js';
import { TenantSubscription } from './tenant-subscription.entity.js';
@Entity({ name: 'coupon_redemptions', schema: 'billing' })
@Unique(['couponId', 'tenantId'])
export class CouponRedemption {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'coupon_id', type: 'uuid' })
couponId!: string;
@ManyToOne(() => Coupon, (coupon) => coupon.redemptions)
@JoinColumn({ name: 'coupon_id' })
coupon!: Coupon;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId!: string;
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId?: string;
@ManyToOne(() => TenantSubscription, { nullable: true })
@JoinColumn({ name: 'subscription_id' })
subscription?: TenantSubscription;
@Column({ name: 'discount_amount', type: 'decimal', precision: 10, scale: 2 })
discountAmount!: number;
@CreateDateColumn({ name: 'redeemed_at', type: 'timestamptz' })
redeemedAt!: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date;
}

View File

@ -1,72 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { CouponRedemption } from './coupon-redemption.entity.js';
export type DiscountType = 'percentage' | 'fixed';
export type DurationPeriod = 'once' | 'forever' | 'months';
@Entity({ name: 'coupons', schema: 'billing' })
export class Coupon {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 50, unique: true })
code!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'discount_type', type: 'varchar', length: 20 })
discountType!: DiscountType;
@Column({ name: 'discount_value', type: 'decimal', precision: 10, scale: 2 })
discountValue!: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency!: string;
@Column({ name: 'applicable_plans', type: 'uuid', array: true, default: [] })
applicablePlans!: string[];
@Column({ name: 'min_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
minAmount!: number;
@Column({ name: 'duration_period', type: 'varchar', length: 20, default: 'once' })
durationPeriod!: DurationPeriod;
@Column({ name: 'duration_months', type: 'integer', nullable: true })
durationMonths?: number;
@Column({ name: 'max_redemptions', type: 'integer', nullable: true })
maxRedemptions?: number;
@Column({ name: 'current_redemptions', type: 'integer', default: 0 })
currentRedemptions!: number;
@Column({ name: 'valid_from', type: 'timestamptz', nullable: true })
validFrom?: Date;
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
validUntil?: Date;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@OneToMany(() => CouponRedemption, (redemption) => redemption.coupon)
redemptions!: CouponRedemption[];
}

View File

@ -1,13 +1,8 @@
export { SubscriptionPlan, PlanType } from './subscription-plan.entity.js'; export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity.js'; export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
export { UsageTracking } from './usage-tracking.entity.js'; export { UsageTracking } from './usage-tracking.entity';
export { UsageEvent, EventCategory } from './usage-event.entity.js'; export { UsageEvent, EventCategory } from './usage-event.entity';
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity.js'; export { Invoice, InvoiceStatus } from './invoice.entity';
export { InvoiceItemType } from './invoice-item.entity.js'; export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity.js'; export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity.js'; export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
export { PlanFeature } from './plan-feature.entity.js';
export { PlanLimit, LimitType } from './plan-limit.entity.js';
export { Coupon, DiscountType, DurationPeriod } from './coupon.entity.js';
export { CouponRedemption } from './coupon-redemption.entity.js';
export { StripeEvent } from './stripe-event.entity.js';

View File

@ -1,17 +1,121 @@
/** import {
* @deprecated Use Invoice from 'modules/invoices/entities' instead. Entity,
* PrimaryGeneratedColumn,
* This entity has been unified with the commercial Invoice entity. Column,
* Both SaaS billing and commercial invoices now use the same table. CreateDateColumn,
* UpdateDateColumn,
* Migration guide: Index,
* - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity'; OneToMany,
* - Set invoiceContext: 'saas' for SaaS billing invoices } from 'typeorm';
* - Use subscriptionId, periodStart, periodEnd for SaaS-specific fields import { InvoiceItem } from './invoice-item.entity';
*/
// Re-export from unified invoice entity export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded';
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType } from '../../invoices/entities/invoice.entity';
// Re-export InvoiceItem as well since it's used together @Entity({ name: 'invoices', schema: 'billing' })
export { InvoiceItem } from './invoice-item.entity'; export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId: string;
// Numero de factura
@Index({ unique: true })
@Column({ name: 'invoice_number', type: 'varchar', length: 30 })
invoiceNumber: string;
@Index()
@Column({ name: 'invoice_date', type: 'date' })
invoiceDate: Date;
// Periodo facturado
@Column({ name: 'period_start', type: 'date' })
periodStart: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd: Date;
// Cliente
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
billingName: string;
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string;
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
billingAddress: Record<string, any>;
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
taxId: string;
// Montos
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
discountAmount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: InvoiceStatus;
// Fechas de pago
@Index()
@Column({ name: 'due_date', type: 'date' })
dueDate: Date;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
paidAmount: number;
// Detalles de pago
@Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true })
paymentMethod: string;
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
paymentReference: string;
// CFDI (para Mexico)
@Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true })
cfdiUuid: string;
@Column({ name: 'cfdi_xml', type: 'text', nullable: true })
cfdiXml: string;
@Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true })
cfdiPdfUrl: string;
// Metadata
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
items: InvoiceItem[];
}

View File

@ -1,61 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { SubscriptionPlan } from './subscription-plan.entity.js';
/**
* PlanFeature Entity
* Maps to billing.plan_features DDL table
* Features disponibles por plan de suscripcion
* Propagated from template-saas HU-REFACT-005
*/
@Entity({ schema: 'billing', name: 'plan_features' })
@Index('idx_plan_features_plan', ['planId'])
@Index('idx_plan_features_key', ['featureKey'])
export class PlanFeature {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'plan_id' })
planId: string;
@Column({ type: 'varchar', length: 100, nullable: false, name: 'feature_key' })
featureKey: string;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'feature_name' })
featureName: string;
@Column({ type: 'varchar', length: 100, nullable: true })
category: string | null;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@Column({ type: 'jsonb', default: {} })
configuration: Record<string, any>;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
// Relaciones
@ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'plan_id' })
plan: SubscriptionPlan;
// Timestamps
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,52 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { SubscriptionPlan } from './subscription-plan.entity.js';
export type LimitType = 'monthly' | 'daily' | 'total' | 'per_user';
@Entity({ name: 'plan_limits', schema: 'billing' })
export class PlanLimit {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'plan_id', type: 'uuid' })
planId!: string;
@ManyToOne(() => SubscriptionPlan, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'plan_id' })
plan!: SubscriptionPlan;
@Column({ name: 'limit_key', type: 'varchar', length: 100 })
limitKey!: string;
@Column({ name: 'limit_name', type: 'varchar', length: 255 })
limitName!: string;
@Column({ name: 'limit_value', type: 'integer' })
limitValue!: number;
@Column({ name: 'limit_type', type: 'varchar', length: 50, default: 'monthly' })
limitType!: LimitType;
@Column({ name: 'allow_overage', type: 'boolean', default: false })
allowOverage!: boolean;
@Column({ name: 'overage_unit_price', type: 'decimal', precision: 10, scale: 4, default: 0 })
overageUnitPrice!: number;
@Column({ name: 'overage_currency', type: 'varchar', length: 3, default: 'MXN' })
overageCurrency!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@ -1,43 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity({ name: 'stripe_events', schema: 'billing' })
export class StripeEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'stripe_event_id', type: 'varchar', length: 255, unique: true })
@Index()
stripeEventId!: string;
@Column({ name: 'event_type', type: 'varchar', length: 100 })
@Index()
eventType!: string;
@Column({ name: 'api_version', type: 'varchar', length: 20, nullable: true })
apiVersion?: string;
@Column({ type: 'jsonb' })
data!: Record<string, any>;
@Column({ type: 'boolean', default: false })
@Index()
processed!: boolean;
@Column({ name: 'processed_at', type: 'timestamptz', nullable: true })
processedAt?: Date;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage?: string;
@Column({ name: 'retry_count', type: 'integer', default: 0 })
retryCount!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
}

View File

@ -70,21 +70,6 @@ export class TenantSubscription {
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true }) @Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
paymentProvider: string; // stripe, mercadopago, bank_transfer paymentProvider: string; // stripe, mercadopago, bank_transfer
// Stripe integration
@Index()
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
stripeCustomerId?: string;
@Index()
@Column({ name: 'stripe_subscription_id', type: 'varchar', length: 255, nullable: true })
stripeSubscriptionId?: string;
@Column({ name: 'last_payment_at', type: 'timestamptz', nullable: true })
lastPaymentAt?: Date;
@Column({ name: 'last_payment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
lastPaymentAmount?: number;
// Precios actuales // Precios actuales
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 }) @Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
currentPrice: number; currentPrice: number;

View File

@ -1,366 +0,0 @@
/**
* Plan Enforcement Middleware
*
* Middleware for validating plan limits and features before allowing operations
*/
import { Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { PlanLimitsService } from '../services/plan-limits.service.js';
import { TenantSubscription, PlanFeature } from '../entities/index.js';
import { logger } from '../../../shared/utils/logger.js';
// Extend Express Request to include user info
interface AuthenticatedRequest extends Request {
user?: {
id: string;
tenantId: string;
userId: string;
email: string;
role: string;
};
}
// Configuration for limit checks
export interface LimitCheckConfig {
limitKey: string;
getCurrentUsage?: (req: AuthenticatedRequest, tenantId: string) => Promise<number>;
requestedUnits?: number;
errorMessage?: string;
}
// Configuration for feature checks
export interface FeatureCheckConfig {
featureKey: string;
errorMessage?: string;
}
/**
* Create a middleware that checks plan limits
*/
export function requireLimit(
dataSource: DataSource,
config: LimitCheckConfig
) {
const planLimitsService = new PlanLimitsService(dataSource);
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(401).json({
success: false,
error: 'No autenticado',
});
}
// Get current usage
let currentUsage = 0;
if (config.getCurrentUsage) {
currentUsage = await config.getCurrentUsage(req, tenantId);
} else {
currentUsage = await planLimitsService.getCurrentUsage(tenantId, config.limitKey);
}
// Check if within limits
const check = await planLimitsService.checkUsage(
tenantId,
config.limitKey,
currentUsage,
config.requestedUnits || 1
);
if (!check.allowed) {
logger.warn('Plan limit exceeded', {
tenantId,
limitKey: config.limitKey,
currentUsage,
limit: check.limit,
});
return res.status(403).json({
success: false,
error: config.errorMessage || check.message,
details: {
limitKey: config.limitKey,
currentUsage: check.currentUsage,
limit: check.limit,
remaining: check.remaining,
},
});
}
// Add limit info to request for downstream use
(req as any).limitCheck = check;
next();
} catch (error) {
logger.error('Plan limit check failed', {
error: (error as Error).message,
limitKey: config.limitKey,
});
next(error);
}
};
}
/**
* Create a middleware that checks if tenant has a specific feature
*/
export function requireFeature(
dataSource: DataSource,
config: FeatureCheckConfig
) {
const featureRepository = dataSource.getRepository(PlanFeature);
const subscriptionRepository = dataSource.getRepository(TenantSubscription);
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(401).json({
success: false,
error: 'No autenticado',
});
}
// Get tenant's subscription
const subscription = await subscriptionRepository.findOne({
where: { tenantId, status: 'active' },
});
let planId: string | null = null;
if (subscription) {
planId = subscription.planId;
} else {
// Check for free plan
const freePlanFeature = await featureRepository.findOne({
where: { featureKey: config.featureKey },
relations: ['plan'],
});
if (freePlanFeature?.plan?.code === 'FREE') {
planId = freePlanFeature.plan.id;
}
}
if (!planId) {
return res.status(403).json({
success: false,
error: config.errorMessage || 'Suscripción requerida para esta función',
});
}
// Check if plan has the feature
const feature = await featureRepository.findOne({
where: { planId, featureKey: config.featureKey },
});
if (!feature || !feature.enabled) {
logger.warn('Feature not available', {
tenantId,
featureKey: config.featureKey,
planId,
});
return res.status(403).json({
success: false,
error: config.errorMessage || `Función no disponible: ${config.featureKey}`,
details: {
featureKey: config.featureKey,
upgrade: true,
},
});
}
// Add feature info to request
(req as any).feature = feature;
next();
} catch (error) {
logger.error('Feature check failed', {
error: (error as Error).message,
featureKey: config.featureKey,
});
next(error);
}
};
}
/**
* Create a middleware that checks subscription status
*/
export function requireActiveSubscription(dataSource: DataSource) {
const subscriptionRepository = dataSource.getRepository(TenantSubscription);
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return res.status(401).json({
success: false,
error: 'No autenticado',
});
}
const subscription = await subscriptionRepository.findOne({
where: { tenantId },
});
if (!subscription) {
// Allow free tier access
(req as any).subscription = null;
return next();
}
if (subscription.status === 'cancelled') {
return res.status(403).json({
success: false,
error: 'Suscripción cancelada',
details: {
status: subscription.status,
cancelledAt: subscription.cancelledAt,
},
});
}
if (subscription.status === 'past_due') {
// Allow limited access for past_due, but warn
logger.warn('Tenant accessing with past_due subscription', { tenantId });
}
// Add subscription to request
(req as any).subscription = subscription;
next();
} catch (error) {
logger.error('Subscription check failed', {
error: (error as Error).message,
});
next(error);
}
};
}
/**
* Create a rate limiting middleware based on plan
*/
export function planBasedRateLimit(
dataSource: DataSource,
options: {
windowMs?: number;
defaultLimit?: number;
limitKey?: string;
} = {}
) {
const planLimitsService = new PlanLimitsService(dataSource);
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
const defaultLimit = options.defaultLimit || 100;
const limitKey = options.limitKey || 'api_calls_per_minute';
// In-memory rate limit store (use Redis in production)
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const tenantId = req.user?.tenantId;
if (!tenantId) {
return next(); // Skip for unauthenticated requests
}
const now = Date.now();
const key = `${tenantId}:${limitKey}`;
// Get or create rate limit entry
let entry = rateLimitStore.get(key);
if (!entry || entry.resetAt < now) {
entry = { count: 0, resetAt: now + windowMs };
rateLimitStore.set(key, entry);
}
// Get plan limit
const planLimit = await planLimitsService.getTenantLimit(tenantId, limitKey);
const limit = planLimit > 0 ? planLimit : defaultLimit;
// Check if exceeded
if (entry.count >= limit) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
return res.status(429).json({
success: false,
error: 'Límite de peticiones excedido',
details: {
limit,
remaining: 0,
resetAt: new Date(entry.resetAt).toISOString(),
retryAfter,
},
});
}
// Increment counter
entry.count++;
// Set rate limit headers
res.set({
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(limit - entry.count),
'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)),
});
next();
} catch (error) {
logger.error('Rate limit check failed', {
error: (error as Error).message,
});
next(error);
}
};
}
/**
* Utility: Get common usage getters
*/
export const usageGetters = {
/**
* Get user count for tenant
*/
async getUserCount(dataSource: DataSource, tenantId: string): Promise<number> {
const result = await dataSource.query(
`SELECT COUNT(*) as count FROM auth.users WHERE tenant_id = $1 AND is_active = true`,
[tenantId]
);
return parseInt(result[0]?.count || '0', 10);
},
/**
* Get storage usage for tenant (in GB)
*/
async getStorageUsage(dataSource: DataSource, tenantId: string): Promise<number> {
// This would need to integrate with file storage system
// Placeholder implementation
return 0;
},
/**
* Get API calls count for current month
*/
async getApiCallsCount(dataSource: DataSource, tenantId: string): Promise<number> {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const result = await dataSource.query(
`SELECT COALESCE(SUM(api_calls_count), 0) as count
FROM billing.usage_tracking
WHERE tenant_id = $1 AND period_start >= $2`,
[tenantId, startOfMonth]
);
return parseInt(result[0]?.count || '0', 10);
},
};

View File

@ -1,348 +0,0 @@
/**
* Coupons Service
*
* Service for managing discount coupons and redemptions
*/
import { Repository, DataSource, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { Coupon, CouponRedemption, TenantSubscription, DiscountType } from '../entities/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreateCouponDto {
code: string;
name: string;
description?: string;
discountType: DiscountType;
discountValue: number;
currency?: string;
applicablePlans?: string[];
minAmount?: number;
durationPeriod?: 'once' | 'forever' | 'months';
durationMonths?: number;
maxRedemptions?: number;
validFrom?: Date;
validUntil?: Date;
}
export interface UpdateCouponDto {
name?: string;
description?: string;
maxRedemptions?: number;
validUntil?: Date;
isActive?: boolean;
}
export interface ApplyCouponResult {
success: boolean;
discountAmount: number;
message: string;
coupon?: Coupon;
}
export class CouponsService {
private couponRepository: Repository<Coupon>;
private redemptionRepository: Repository<CouponRedemption>;
private subscriptionRepository: Repository<TenantSubscription>;
constructor(private dataSource: DataSource) {
this.couponRepository = dataSource.getRepository(Coupon);
this.redemptionRepository = dataSource.getRepository(CouponRedemption);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
}
/**
* Create a new coupon
*/
async create(dto: CreateCouponDto): Promise<Coupon> {
// Check if code already exists
const existing = await this.couponRepository.findOne({
where: { code: dto.code.toUpperCase() },
});
if (existing) {
throw new Error(`Coupon with code ${dto.code} already exists`);
}
const coupon = this.couponRepository.create({
code: dto.code.toUpperCase(),
name: dto.name,
description: dto.description,
discountType: dto.discountType,
discountValue: dto.discountValue,
currency: dto.currency || 'MXN',
applicablePlans: dto.applicablePlans || [],
minAmount: dto.minAmount || 0,
durationPeriod: dto.durationPeriod || 'once',
durationMonths: dto.durationMonths,
maxRedemptions: dto.maxRedemptions,
validFrom: dto.validFrom,
validUntil: dto.validUntil,
isActive: true,
currentRedemptions: 0,
});
const saved = await this.couponRepository.save(coupon);
logger.info('Coupon created', { couponId: saved.id, code: saved.code });
return saved;
}
/**
* Find all coupons
*/
async findAll(options?: { isActive?: boolean }): Promise<Coupon[]> {
const query = this.couponRepository.createQueryBuilder('coupon');
if (options?.isActive !== undefined) {
query.andWhere('coupon.isActive = :isActive', { isActive: options.isActive });
}
return query.orderBy('coupon.createdAt', 'DESC').getMany();
}
/**
* Find coupon by code
*/
async findByCode(code: string): Promise<Coupon | null> {
return this.couponRepository.findOne({
where: { code: code.toUpperCase() },
});
}
/**
* Find coupon by ID
*/
async findById(id: string): Promise<Coupon | null> {
return this.couponRepository.findOne({
where: { id },
relations: ['redemptions'],
});
}
/**
* Update a coupon
*/
async update(id: string, dto: UpdateCouponDto): Promise<Coupon> {
const coupon = await this.findById(id);
if (!coupon) {
throw new Error('Coupon not found');
}
if (dto.name !== undefined) coupon.name = dto.name;
if (dto.description !== undefined) coupon.description = dto.description;
if (dto.maxRedemptions !== undefined) coupon.maxRedemptions = dto.maxRedemptions;
if (dto.validUntil !== undefined) coupon.validUntil = dto.validUntil;
if (dto.isActive !== undefined) coupon.isActive = dto.isActive;
return this.couponRepository.save(coupon);
}
/**
* Validate if a coupon can be applied
*/
async validateCoupon(
code: string,
tenantId: string,
planId?: string,
amount?: number
): Promise<ApplyCouponResult> {
const coupon = await this.findByCode(code);
if (!coupon) {
return { success: false, discountAmount: 0, message: 'Cupón no encontrado' };
}
if (!coupon.isActive) {
return { success: false, discountAmount: 0, message: 'Cupón inactivo' };
}
// Check validity dates
const now = new Date();
if (coupon.validFrom && now < coupon.validFrom) {
return { success: false, discountAmount: 0, message: 'Cupón aún no válido' };
}
if (coupon.validUntil && now > coupon.validUntil) {
return { success: false, discountAmount: 0, message: 'Cupón expirado' };
}
// Check max redemptions
if (coupon.maxRedemptions && coupon.currentRedemptions >= coupon.maxRedemptions) {
return { success: false, discountAmount: 0, message: 'Cupón agotado' };
}
// Check if already redeemed by this tenant
const existingRedemption = await this.redemptionRepository.findOne({
where: { couponId: coupon.id, tenantId },
});
if (existingRedemption) {
return { success: false, discountAmount: 0, message: 'Cupón ya utilizado' };
}
// Check applicable plans
if (planId && coupon.applicablePlans.length > 0) {
if (!coupon.applicablePlans.includes(planId)) {
return { success: false, discountAmount: 0, message: 'Cupón no aplicable a este plan' };
}
}
// Check minimum amount
if (amount && coupon.minAmount > 0 && amount < coupon.minAmount) {
return {
success: false,
discountAmount: 0,
message: `Monto mínimo requerido: ${coupon.minAmount} ${coupon.currency}`,
};
}
// Calculate discount
let discountAmount = 0;
if (amount) {
if (coupon.discountType === 'percentage') {
discountAmount = (amount * coupon.discountValue) / 100;
} else {
discountAmount = Math.min(coupon.discountValue, amount);
}
}
return {
success: true,
discountAmount,
message: 'Cupón válido',
coupon,
};
}
/**
* Apply a coupon to a subscription
*/
async applyCoupon(
code: string,
tenantId: string,
subscriptionId: string,
amount: number
): Promise<CouponRedemption> {
const validation = await this.validateCoupon(code, tenantId, undefined, amount);
if (!validation.success || !validation.coupon) {
throw new Error(validation.message);
}
const coupon = validation.coupon;
// Create redemption record
const redemption = this.redemptionRepository.create({
couponId: coupon.id,
tenantId,
subscriptionId,
discountAmount: validation.discountAmount,
expiresAt: this.calculateRedemptionExpiry(coupon),
});
// Update coupon redemption count
coupon.currentRedemptions += 1;
// Save in transaction
await this.dataSource.transaction(async (manager) => {
await manager.save(redemption);
await manager.save(coupon);
});
logger.info('Coupon applied', {
couponId: coupon.id,
code: coupon.code,
tenantId,
subscriptionId,
discountAmount: validation.discountAmount,
});
return redemption;
}
/**
* Calculate when a redemption expires based on coupon duration
*/
private calculateRedemptionExpiry(coupon: Coupon): Date | undefined {
if (coupon.durationPeriod === 'forever') {
return undefined;
}
if (coupon.durationPeriod === 'once') {
// Expires at end of current billing period (30 days)
const expiry = new Date();
expiry.setDate(expiry.getDate() + 30);
return expiry;
}
if (coupon.durationPeriod === 'months' && coupon.durationMonths) {
const expiry = new Date();
expiry.setMonth(expiry.getMonth() + coupon.durationMonths);
return expiry;
}
return undefined;
}
/**
* Get active redemptions for a tenant
*/
async getActiveRedemptions(tenantId: string): Promise<CouponRedemption[]> {
const now = new Date();
return this.redemptionRepository.find({
where: [
{ tenantId, expiresAt: IsNull() },
{ tenantId, expiresAt: MoreThanOrEqual(now) },
],
relations: ['coupon'],
});
}
/**
* Deactivate a coupon
*/
async deactivate(id: string): Promise<Coupon> {
const coupon = await this.findById(id);
if (!coupon) {
throw new Error('Coupon not found');
}
coupon.isActive = false;
return this.couponRepository.save(coupon);
}
/**
* Get coupon statistics
*/
async getStats(id: string): Promise<{
totalRedemptions: number;
totalDiscountGiven: number;
activeRedemptions: number;
}> {
const coupon = await this.findById(id);
if (!coupon) {
throw new Error('Coupon not found');
}
const now = new Date();
const redemptions = await this.redemptionRepository.find({
where: { couponId: id },
});
const totalDiscountGiven = redemptions.reduce(
(sum, r) => sum + Number(r.discountAmount),
0
);
const activeRedemptions = redemptions.filter(
(r) => !r.expiresAt || r.expiresAt > now
).length;
return {
totalRedemptions: redemptions.length,
totalDiscountGiven,
activeRedemptions,
};
}
}

View File

@ -2,10 +2,7 @@
* Billing Usage Services Index * Billing Usage Services Index
*/ */
export { SubscriptionPlansService } from './subscription-plans.service.js'; export { SubscriptionPlansService } from './subscription-plans.service';
export { SubscriptionsService } from './subscriptions.service.js'; export { SubscriptionsService } from './subscriptions.service';
export { UsageTrackingService } from './usage-tracking.service.js'; export { UsageTrackingService } from './usage-tracking.service';
export { InvoicesService } from './invoices.service.js'; export { InvoicesService } from './invoices.service';
export { CouponsService, CreateCouponDto, UpdateCouponDto, ApplyCouponResult } from './coupons.service.js';
export { PlanLimitsService, CreatePlanLimitDto, UpdatePlanLimitDto, UsageCheckResult } from './plan-limits.service.js';
export { StripeWebhookService, StripeEventType, StripeWebhookPayload, ProcessResult } from './stripe-webhook.service.js';

View File

@ -76,14 +76,15 @@ export class InvoicesService {
const discount = itemTotal * ((itemDto.discountPercent || 0) / 100); const discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
const item = this.itemRepository.create({ const item = this.itemRepository.create({
invoiceId: savedInvoice.id,
itemType: itemDto.itemType, itemType: itemDto.itemType,
description: itemDto.description, description: itemDto.description,
quantity: itemDto.quantity, quantity: itemDto.quantity,
unitPrice: itemDto.unitPrice, unitPrice: itemDto.unitPrice,
discountPercent: itemDto.discountPercent || 0,
subtotal: itemTotal - discount, subtotal: itemTotal - discount,
metadata: itemDto.metadata || {}, metadata: itemDto.metadata || {},
}); });
item.invoiceId = savedInvoice.id;
await this.itemRepository.save(item); await this.itemRepository.save(item);
} }
@ -309,7 +310,7 @@ export class InvoicesService {
invoice.paidAmount = newPaidAmount; invoice.paidAmount = newPaidAmount;
invoice.paymentMethod = dto.paymentMethod; invoice.paymentMethod = dto.paymentMethod;
invoice.paymentReference = dto.paymentReference || ''; invoice.paymentReference = dto.paymentReference;
if (newPaidAmount >= total) { if (newPaidAmount >= total) {
invoice.status = 'paid'; invoice.status = 'paid';
@ -405,15 +406,12 @@ export class InvoicesService {
const byStatus: Record<InvoiceStatus, number> = { const byStatus: Record<InvoiceStatus, number> = {
draft: 0, draft: 0,
validated: 0,
sent: 0, sent: 0,
paid: 0, paid: 0,
partial: 0, partial: 0,
overdue: 0, overdue: 0,
void: 0, void: 0,
refunded: 0, refunded: 0,
cancelled: 0,
voided: 0,
}; };
let totalRevenue = 0; let totalRevenue = 0;
@ -432,7 +430,7 @@ export class InvoicesService {
const pending = Number(invoice.total) - Number(invoice.paidAmount); const pending = Number(invoice.total) - Number(invoice.paidAmount);
pendingAmount += pending; pendingAmount += pending;
if (invoice.dueDate && invoice.dueDate < now) { if (invoice.dueDate < now) {
overdueAmount += pending; overdueAmount += pending;
} }
} }

View File

@ -1,334 +0,0 @@
/**
* Plan Limits Service
*
* Service for managing plan limits and usage validation
*/
import { Repository, DataSource } from 'typeorm';
import { PlanLimit, LimitType, SubscriptionPlan, TenantSubscription, UsageTracking } from '../entities/index.js';
import { logger } from '../../../shared/utils/logger.js';
export interface CreatePlanLimitDto {
planId: string;
limitKey: string;
limitName: string;
limitValue: number;
limitType?: LimitType;
allowOverage?: boolean;
overageUnitPrice?: number;
overageCurrency?: string;
}
export interface UpdatePlanLimitDto {
limitName?: string;
limitValue?: number;
allowOverage?: boolean;
overageUnitPrice?: number;
}
export interface UsageCheckResult {
allowed: boolean;
currentUsage: number;
limit: number;
remaining: number;
overageAllowed: boolean;
overageUnits?: number;
overageCost?: number;
message: string;
}
export class PlanLimitsService {
private limitRepository: Repository<PlanLimit>;
private planRepository: Repository<SubscriptionPlan>;
private subscriptionRepository: Repository<TenantSubscription>;
private usageRepository: Repository<UsageTracking>;
constructor(private dataSource: DataSource) {
this.limitRepository = dataSource.getRepository(PlanLimit);
this.planRepository = dataSource.getRepository(SubscriptionPlan);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
this.usageRepository = dataSource.getRepository(UsageTracking);
}
/**
* Create a new plan limit
*/
async create(dto: CreatePlanLimitDto): Promise<PlanLimit> {
// Verify plan exists
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
if (!plan) {
throw new Error('Plan not found');
}
// Check for duplicate limit key
const existing = await this.limitRepository.findOne({
where: { planId: dto.planId, limitKey: dto.limitKey },
});
if (existing) {
throw new Error(`Limit ${dto.limitKey} already exists for this plan`);
}
const limit = this.limitRepository.create({
planId: dto.planId,
limitKey: dto.limitKey,
limitName: dto.limitName,
limitValue: dto.limitValue,
limitType: dto.limitType || 'monthly',
allowOverage: dto.allowOverage || false,
overageUnitPrice: dto.overageUnitPrice || 0,
overageCurrency: dto.overageCurrency || 'MXN',
});
const saved = await this.limitRepository.save(limit);
logger.info('Plan limit created', {
limitId: saved.id,
planId: dto.planId,
limitKey: dto.limitKey,
});
return saved;
}
/**
* Find all limits for a plan
*/
async findByPlan(planId: string): Promise<PlanLimit[]> {
return this.limitRepository.find({
where: { planId },
order: { limitKey: 'ASC' },
});
}
/**
* Find a specific limit by key
*/
async findByKey(planId: string, limitKey: string): Promise<PlanLimit | null> {
return this.limitRepository.findOne({
where: { planId, limitKey },
});
}
/**
* Update a plan limit
*/
async update(id: string, dto: UpdatePlanLimitDto): Promise<PlanLimit> {
const limit = await this.limitRepository.findOne({ where: { id } });
if (!limit) {
throw new Error('Limit not found');
}
if (dto.limitName !== undefined) limit.limitName = dto.limitName;
if (dto.limitValue !== undefined) limit.limitValue = dto.limitValue;
if (dto.allowOverage !== undefined) limit.allowOverage = dto.allowOverage;
if (dto.overageUnitPrice !== undefined) limit.overageUnitPrice = dto.overageUnitPrice;
return this.limitRepository.save(limit);
}
/**
* Delete a plan limit
*/
async delete(id: string): Promise<void> {
const limit = await this.limitRepository.findOne({ where: { id } });
if (!limit) {
throw new Error('Limit not found');
}
await this.limitRepository.remove(limit);
logger.info('Plan limit deleted', { limitId: id });
}
/**
* Get tenant's current plan limits
*/
async getTenantLimits(tenantId: string): Promise<PlanLimit[]> {
const subscription = await this.subscriptionRepository.findOne({
where: { tenantId, status: 'active' },
});
if (!subscription) {
// Return free plan limits by default
const freePlan = await this.planRepository.findOne({
where: { code: 'FREE' },
});
if (freePlan) {
return this.findByPlan(freePlan.id);
}
return [];
}
return this.findByPlan(subscription.planId);
}
/**
* Get a specific limit for a tenant
*/
async getTenantLimit(tenantId: string, limitKey: string): Promise<number> {
const limits = await this.getTenantLimits(tenantId);
const limit = limits.find((l) => l.limitKey === limitKey);
return limit?.limitValue || 0;
}
/**
* Check if tenant can use a resource (within limits)
*/
async checkUsage(
tenantId: string,
limitKey: string,
currentUsage: number,
requestedUnits: number = 1
): Promise<UsageCheckResult> {
const limits = await this.getTenantLimits(tenantId);
const limit = limits.find((l) => l.limitKey === limitKey);
if (!limit) {
// No limit defined = unlimited
return {
allowed: true,
currentUsage,
limit: -1,
remaining: -1,
overageAllowed: false,
message: 'Sin límite definido',
};
}
const remaining = limit.limitValue - currentUsage;
const wouldExceed = currentUsage + requestedUnits > limit.limitValue;
if (!wouldExceed) {
return {
allowed: true,
currentUsage,
limit: limit.limitValue,
remaining: remaining - requestedUnits,
overageAllowed: limit.allowOverage,
message: 'Dentro del límite',
};
}
// Would exceed limit
if (limit.allowOverage) {
const overageUnits = currentUsage + requestedUnits - limit.limitValue;
const overageCost = overageUnits * Number(limit.overageUnitPrice);
return {
allowed: true,
currentUsage,
limit: limit.limitValue,
remaining: 0,
overageAllowed: true,
overageUnits,
overageCost,
message: `Se aplicará cargo por excedente: ${overageUnits} unidades`,
};
}
return {
allowed: false,
currentUsage,
limit: limit.limitValue,
remaining: Math.max(0, remaining),
overageAllowed: false,
message: `Límite alcanzado: ${limit.limitName}`,
};
}
/**
* Get current usage for a tenant and limit key
*/
async getCurrentUsage(tenantId: string, limitKey: string): Promise<number> {
// Get current period
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const usage = await this.usageRepository.findOne({
where: {
tenantId,
periodStart,
},
});
if (!usage) {
return 0;
}
// Map limit key to usage field
const usageMap: Record<string, keyof UsageTracking> = {
users: 'activeUsers',
storage_gb: 'storageUsedGb',
api_calls: 'apiCalls',
branches: 'activeBranches',
documents: 'documentsCount',
invoices: 'invoicesGenerated',
// Add more mappings as needed
};
const field = usageMap[limitKey];
if (field && usage[field] !== undefined) {
return Number(usage[field]);
}
return 0;
}
/**
* Validate all limits for a tenant
*/
async validateAllLimits(tenantId: string): Promise<{
valid: boolean;
violations: Array<{ limitKey: string; message: string }>;
}> {
const limits = await this.getTenantLimits(tenantId);
const violations: Array<{ limitKey: string; message: string }> = [];
for (const limit of limits) {
const currentUsage = await this.getCurrentUsage(tenantId, limit.limitKey);
const check = await this.checkUsage(tenantId, limit.limitKey, currentUsage);
if (!check.allowed) {
violations.push({
limitKey: limit.limitKey,
message: check.message,
});
}
}
return {
valid: violations.length === 0,
violations,
};
}
/**
* Copy limits from one plan to another
*/
async copyLimitsFromPlan(sourcePlanId: string, targetPlanId: string): Promise<PlanLimit[]> {
const sourceLimits = await this.findByPlan(sourcePlanId);
const createdLimits: PlanLimit[] = [];
for (const sourceLimit of sourceLimits) {
const limit = await this.create({
planId: targetPlanId,
limitKey: sourceLimit.limitKey,
limitName: sourceLimit.limitName,
limitValue: sourceLimit.limitValue,
limitType: sourceLimit.limitType,
allowOverage: sourceLimit.allowOverage,
overageUnitPrice: Number(sourceLimit.overageUnitPrice),
overageCurrency: sourceLimit.overageCurrency,
});
createdLimits.push(limit);
}
logger.info('Plan limits copied', {
sourcePlanId,
targetPlanId,
count: createdLimits.length,
});
return createdLimits;
}
}

View File

@ -1,462 +0,0 @@
/**
* Stripe Webhook Service
*
* Service for processing Stripe webhook events
*/
import { Repository, DataSource } from 'typeorm';
import { StripeEvent, TenantSubscription } from '../entities/index.js';
import { logger } from '../../../shared/utils/logger.js';
// Stripe event types we handle
export type StripeEventType =
| 'customer.subscription.created'
| 'customer.subscription.updated'
| 'customer.subscription.deleted'
| 'customer.subscription.trial_will_end'
| 'invoice.payment_succeeded'
| 'invoice.payment_failed'
| 'invoice.upcoming'
| 'payment_intent.succeeded'
| 'payment_intent.payment_failed'
| 'checkout.session.completed';
export interface StripeWebhookPayload {
id: string;
type: string;
api_version?: string;
data: {
object: Record<string, any>;
previous_attributes?: Record<string, any>;
};
created: number;
livemode: boolean;
}
export interface ProcessResult {
success: boolean;
eventId: string;
message: string;
error?: string;
}
export class StripeWebhookService {
private eventRepository: Repository<StripeEvent>;
private subscriptionRepository: Repository<TenantSubscription>;
constructor(private dataSource: DataSource) {
this.eventRepository = dataSource.getRepository(StripeEvent);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
}
/**
* Process an incoming Stripe webhook event
*/
async processWebhook(payload: StripeWebhookPayload): Promise<ProcessResult> {
const { id: stripeEventId, type: eventType, api_version, data } = payload;
// Check for duplicate event
const existing = await this.eventRepository.findOne({
where: { stripeEventId },
});
if (existing) {
if (existing.processed) {
return {
success: true,
eventId: existing.id,
message: 'Event already processed',
};
}
// Retry processing
return this.retryProcessing(existing);
}
// Store the event
const event = this.eventRepository.create({
stripeEventId,
eventType,
apiVersion: api_version,
data,
processed: false,
retryCount: 0,
});
await this.eventRepository.save(event);
logger.info('Stripe webhook received', { stripeEventId, eventType });
// Process the event
try {
await this.handleEvent(event, data.object);
// Mark as processed
event.processed = true;
event.processedAt = new Date();
await this.eventRepository.save(event);
logger.info('Stripe webhook processed', { stripeEventId, eventType });
return {
success: true,
eventId: event.id,
message: 'Event processed successfully',
};
} catch (error) {
const errorMessage = (error as Error).message;
event.errorMessage = errorMessage;
event.retryCount += 1;
await this.eventRepository.save(event);
logger.error('Stripe webhook processing failed', {
stripeEventId,
eventType,
error: errorMessage,
});
return {
success: false,
eventId: event.id,
message: 'Event processing failed',
error: errorMessage,
};
}
}
/**
* Handle specific event types
*/
private async handleEvent(event: StripeEvent, object: Record<string, any>): Promise<void> {
const eventType = event.eventType as StripeEventType;
switch (eventType) {
case 'customer.subscription.created':
await this.handleSubscriptionCreated(object);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(object);
break;
case 'customer.subscription.trial_will_end':
await this.handleTrialWillEnd(object);
break;
case 'invoice.payment_succeeded':
await this.handlePaymentSucceeded(object);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(object);
break;
case 'checkout.session.completed':
await this.handleCheckoutCompleted(object);
break;
default:
logger.warn('Unhandled Stripe event type', { eventType });
}
}
/**
* Handle subscription created
*/
private async handleSubscriptionCreated(subscription: Record<string, any>): Promise<void> {
const customerId = subscription.customer;
const stripeSubscriptionId = subscription.id;
const status = this.mapStripeStatus(subscription.status);
// Find tenant by Stripe customer ID
const existing = await this.subscriptionRepository.findOne({
where: { stripeCustomerId: customerId },
});
if (existing) {
existing.stripeSubscriptionId = stripeSubscriptionId;
existing.status = status;
existing.currentPeriodStart = new Date(subscription.current_period_start * 1000);
existing.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
if (subscription.trial_end) {
existing.trialEnd = new Date(subscription.trial_end * 1000);
}
await this.subscriptionRepository.save(existing);
logger.info('Subscription created/linked', {
tenantId: existing.tenantId,
stripeSubscriptionId,
});
}
}
/**
* Handle subscription updated
*/
private async handleSubscriptionUpdated(subscription: Record<string, any>): Promise<void> {
const stripeSubscriptionId = subscription.id;
const status = this.mapStripeStatus(subscription.status);
const existing = await this.subscriptionRepository.findOne({
where: { stripeSubscriptionId },
});
if (existing) {
existing.status = status;
existing.currentPeriodStart = new Date(subscription.current_period_start * 1000);
existing.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
if (subscription.cancel_at_period_end) {
existing.cancelAtPeriodEnd = true;
}
if (subscription.canceled_at) {
existing.cancelledAt = new Date(subscription.canceled_at * 1000);
}
await this.subscriptionRepository.save(existing);
logger.info('Subscription updated', {
tenantId: existing.tenantId,
stripeSubscriptionId,
status,
});
}
}
/**
* Handle subscription deleted (cancelled)
*/
private async handleSubscriptionDeleted(subscription: Record<string, any>): Promise<void> {
const stripeSubscriptionId = subscription.id;
const existing = await this.subscriptionRepository.findOne({
where: { stripeSubscriptionId },
});
if (existing) {
existing.status = 'cancelled';
existing.cancelledAt = new Date();
await this.subscriptionRepository.save(existing);
logger.info('Subscription cancelled', {
tenantId: existing.tenantId,
stripeSubscriptionId,
});
}
}
/**
* Handle trial will end (send notification)
*/
private async handleTrialWillEnd(subscription: Record<string, any>): Promise<void> {
const stripeSubscriptionId = subscription.id;
const trialEnd = new Date(subscription.trial_end * 1000);
const existing = await this.subscriptionRepository.findOne({
where: { stripeSubscriptionId },
});
if (existing) {
// TODO: Send notification to tenant
logger.info('Trial ending soon', {
tenantId: existing.tenantId,
trialEnd,
});
}
}
/**
* Handle payment succeeded
*/
private async handlePaymentSucceeded(invoice: Record<string, any>): Promise<void> {
const customerId = invoice.customer;
const amountPaid = invoice.amount_paid;
const invoiceId = invoice.id;
const subscription = await this.subscriptionRepository.findOne({
where: { stripeCustomerId: customerId },
});
if (subscription) {
// Update last payment info
subscription.lastPaymentAt = new Date();
subscription.lastPaymentAmount = amountPaid / 100; // Stripe amounts are in cents
subscription.status = 'active';
await this.subscriptionRepository.save(subscription);
logger.info('Payment succeeded', {
tenantId: subscription.tenantId,
invoiceId,
amount: amountPaid / 100,
});
}
}
/**
* Handle payment failed
*/
private async handlePaymentFailed(invoice: Record<string, any>): Promise<void> {
const customerId = invoice.customer;
const invoiceId = invoice.id;
const attemptCount = invoice.attempt_count;
const subscription = await this.subscriptionRepository.findOne({
where: { stripeCustomerId: customerId },
});
if (subscription) {
subscription.status = 'past_due';
await this.subscriptionRepository.save(subscription);
// TODO: Send payment failed notification
logger.warn('Payment failed', {
tenantId: subscription.tenantId,
invoiceId,
attemptCount,
});
}
}
/**
* Handle checkout session completed
*/
private async handleCheckoutCompleted(session: Record<string, any>): Promise<void> {
const customerId = session.customer;
const subscriptionId = session.subscription;
const metadata = session.metadata || {};
const tenantId = metadata.tenant_id;
if (tenantId) {
// Link Stripe customer to tenant
const subscription = await this.subscriptionRepository.findOne({
where: { tenantId },
});
if (subscription) {
subscription.stripeCustomerId = customerId;
subscription.stripeSubscriptionId = subscriptionId;
subscription.status = 'active';
await this.subscriptionRepository.save(subscription);
logger.info('Checkout completed', {
tenantId,
customerId,
subscriptionId,
});
}
}
}
/**
* Map Stripe subscription status to our status
*/
private mapStripeStatus(stripeStatus: string): 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended' {
const statusMap: Record<string, 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended'> = {
active: 'active',
trialing: 'trial',
past_due: 'past_due',
canceled: 'cancelled',
unpaid: 'past_due',
incomplete: 'suspended',
incomplete_expired: 'cancelled',
};
return statusMap[stripeStatus] || 'suspended';
}
/**
* Retry processing a failed event
*/
async retryProcessing(event: StripeEvent): Promise<ProcessResult> {
if (event.retryCount >= 5) {
return {
success: false,
eventId: event.id,
message: 'Max retries exceeded',
error: event.errorMessage || 'Unknown error',
};
}
try {
await this.handleEvent(event, event.data.object);
event.processed = true;
event.processedAt = new Date();
event.errorMessage = undefined;
await this.eventRepository.save(event);
return {
success: true,
eventId: event.id,
message: 'Event processed on retry',
};
} catch (error) {
event.errorMessage = (error as Error).message;
event.retryCount += 1;
await this.eventRepository.save(event);
return {
success: false,
eventId: event.id,
message: 'Retry failed',
error: event.errorMessage,
};
}
}
/**
* Get failed events for retry
*/
async getFailedEvents(limit: number = 100): Promise<StripeEvent[]> {
return this.eventRepository.find({
where: {
processed: false,
},
order: { createdAt: 'ASC' },
take: limit,
});
}
/**
* Get event by Stripe event ID
*/
async findByStripeEventId(stripeEventId: string): Promise<StripeEvent | null> {
return this.eventRepository.findOne({
where: { stripeEventId },
});
}
/**
* Get recent events
*/
async getRecentEvents(options?: {
limit?: number;
eventType?: string;
processed?: boolean;
}): Promise<StripeEvent[]> {
const query = this.eventRepository.createQueryBuilder('event');
if (options?.eventType) {
query.andWhere('event.eventType = :eventType', { eventType: options.eventType });
}
if (options?.processed !== undefined) {
query.andWhere('event.processed = :processed', { processed: options.processed });
}
return query
.orderBy('event.createdAt', 'DESC')
.take(options?.limit || 50)
.getMany();
}
}

View File

@ -135,7 +135,7 @@ export class SubscriptionsService {
throw new Error('Subscription is already cancelled'); throw new Error('Subscription is already cancelled');
} }
subscription.cancellationReason = dto.reason || ''; subscription.cancellationReason = dto.reason;
subscription.cancelledAt = new Date(); subscription.cancelledAt = new Date();
if (dto.cancelImmediately) { if (dto.cancelImmediately) {

View File

@ -258,17 +258,13 @@ export class BranchesService {
} }
const assignment = this.assignmentRepository.create({ const assignment = this.assignmentRepository.create({
userId: dto.userId, ...dto,
branchId: dto.branchId,
tenantId, tenantId,
assignmentType: (dto.assignmentType || 'primary') as any,
branchRole: dto.branchRole as any,
permissions: dto.permissions || [],
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined, validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
createdBy: assignedBy, createdBy: assignedBy,
}); } as any);
return this.assignmentRepository.save(assignment) as Promise<UserBranchAssignment>; return this.assignmentRepository.save(assignment);
} }
async unassignUser(userId: string, branchId: string): Promise<boolean> { async unassignUser(userId: string, branchId: string): Promise<boolean> {

View File

@ -2,14 +2,8 @@ import { Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js'; import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
import { countriesService } from './countries.service.js'; import { countriesService } from './countries.service.js';
import { statesService, CreateStateDto, UpdateStateDto } from './states.service.js';
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './currency-rates.service.js';
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js'; import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js'; import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
import { PaymentTermLineType } from './entities/payment-term.entity.js';
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js'; import { ValidationError } from '../../shared/errors/index.js';
@ -64,185 +58,6 @@ const updateCategorySchema = z.object({
active: z.boolean().optional(), active: z.boolean().optional(),
}); });
// Payment Terms Schemas
const paymentTermLineSchema = z.object({
sequence: z.number().int().min(1).optional(),
line_type: z.enum(['balance', 'percent', 'fixed']).optional(),
lineType: z.enum(['balance', 'percent', 'fixed']).optional(),
value_percent: z.number().min(0).max(100).optional(),
valuePercent: z.number().min(0).max(100).optional(),
value_amount: z.number().min(0).optional(),
valueAmount: z.number().min(0).optional(),
days: z.number().int().min(0).optional(),
day_of_month: z.number().int().min(1).max(31).optional(),
dayOfMonth: z.number().int().min(1).max(31).optional(),
end_of_month: z.boolean().optional(),
endOfMonth: z.boolean().optional(),
});
const createPaymentTermSchema = z.object({
code: z.string().min(1).max(50),
name: z.string().min(1).max(255),
description: z.string().optional(),
due_days: z.number().int().min(0).optional(),
dueDays: z.number().int().min(0).optional(),
discount_percent: z.number().min(0).max(100).optional(),
discountPercent: z.number().min(0).max(100).optional(),
discount_days: z.number().int().min(0).optional(),
discountDays: z.number().int().min(0).optional(),
is_immediate: z.boolean().optional(),
isImmediate: z.boolean().optional(),
lines: z.array(paymentTermLineSchema).optional(),
});
const updatePaymentTermSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
due_days: z.number().int().min(0).optional(),
dueDays: z.number().int().min(0).optional(),
discount_percent: z.number().min(0).max(100).optional().nullable(),
discountPercent: z.number().min(0).max(100).optional().nullable(),
discount_days: z.number().int().min(0).optional().nullable(),
discountDays: z.number().int().min(0).optional().nullable(),
is_immediate: z.boolean().optional(),
isImmediate: z.boolean().optional(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
lines: z.array(paymentTermLineSchema).optional(),
});
const calculateDueDateSchema = z.object({
invoice_date: z.string().datetime().optional(),
invoiceDate: z.string().datetime().optional(),
total_amount: z.number().min(0),
totalAmount: z.number().min(0).optional(),
});
// Discount Rules Schemas
const createDiscountRuleSchema = z.object({
code: z.string().min(1).max(50),
name: z.string().min(1).max(255),
description: z.string().optional(),
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discount_value: z.number().min(0),
discountValue: z.number().min(0).optional(),
max_discount_amount: z.number().min(0).optional().nullable(),
maxDiscountAmount: z.number().min(0).optional().nullable(),
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
applies_to_id: z.string().uuid().optional().nullable(),
appliesToId: z.string().uuid().optional().nullable(),
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
condition_value: z.number().optional().nullable(),
conditionValue: z.number().optional().nullable(),
start_date: z.string().datetime().optional().nullable(),
startDate: z.string().datetime().optional().nullable(),
end_date: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
priority: z.number().int().min(0).optional(),
combinable: z.boolean().optional(),
usage_limit: z.number().int().min(0).optional().nullable(),
usageLimit: z.number().int().min(0).optional().nullable(),
});
const updateDiscountRuleSchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().optional().nullable(),
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
discount_value: z.number().min(0).optional(),
discountValue: z.number().min(0).optional(),
max_discount_amount: z.number().min(0).optional().nullable(),
maxDiscountAmount: z.number().min(0).optional().nullable(),
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
applies_to_id: z.string().uuid().optional().nullable(),
appliesToId: z.string().uuid().optional().nullable(),
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
condition_value: z.number().optional().nullable(),
conditionValue: z.number().optional().nullable(),
start_date: z.string().datetime().optional().nullable(),
startDate: z.string().datetime().optional().nullable(),
end_date: z.string().datetime().optional().nullable(),
endDate: z.string().datetime().optional().nullable(),
priority: z.number().int().min(0).optional(),
combinable: z.boolean().optional(),
usage_limit: z.number().int().min(0).optional().nullable(),
usageLimit: z.number().int().min(0).optional().nullable(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
});
const applyDiscountsSchema = z.object({
product_id: z.string().uuid().optional(),
productId: z.string().uuid().optional(),
category_id: z.string().uuid().optional(),
categoryId: z.string().uuid().optional(),
customer_id: z.string().uuid().optional(),
customerId: z.string().uuid().optional(),
customer_group_id: z.string().uuid().optional(),
customerGroupId: z.string().uuid().optional(),
quantity: z.number().min(0),
unit_price: z.number().min(0),
unitPrice: z.number().min(0).optional(),
total_amount: z.number().min(0),
totalAmount: z.number().min(0).optional(),
is_first_purchase: z.boolean().optional(),
isFirstPurchase: z.boolean().optional(),
});
// States Schemas
const createStateSchema = z.object({
country_id: z.string().uuid().optional(),
countryId: z.string().uuid().optional(),
code: z.string().min(1).max(10).toUpperCase(),
name: z.string().min(1).max(255),
timezone: z.string().max(50).optional(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
}).refine((data) => data.country_id !== undefined || data.countryId !== undefined, {
message: 'country_id or countryId is required',
});
const updateStateSchema = z.object({
name: z.string().min(1).max(255).optional(),
timezone: z.string().max(50).optional().nullable(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
});
// Currency Rates Schemas
const createCurrencyRateSchema = z.object({
from_currency_code: z.string().length(3).toUpperCase().optional(),
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
to_currency_code: z.string().length(3).toUpperCase().optional(),
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
rate: z.number().positive(),
rate_date: z.string().optional(),
rateDate: z.string().optional(),
source: z.enum(['manual', 'banxico', 'xe', 'openexchange']).optional(),
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
message: 'from_currency_code or fromCurrencyCode is required',
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
message: 'to_currency_code or toCurrencyCode is required',
});
const convertCurrencySchema = z.object({
amount: z.number().min(0),
from_currency_code: z.string().length(3).toUpperCase().optional(),
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
to_currency_code: z.string().length(3).toUpperCase().optional(),
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
date: z.string().optional(),
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
message: 'from_currency_code or fromCurrencyCode is required',
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
message: 'to_currency_code or toCurrencyCode is required',
});
class CoreController { class CoreController {
// ========== CURRENCIES ========== // ========== CURRENCIES ==========
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
@ -311,261 +126,6 @@ class CoreController {
} }
} }
// ========== STATES ==========
async getStates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
countryId: req.query.country_id as string | undefined,
countryCode: req.query.country_code as string | undefined,
isActive: req.query.active === 'true' ? true : undefined,
};
const states = await statesService.findAll(filter);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async getState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const state = await statesService.findById(req.params.id);
res.json({ success: true, data: state });
} catch (error) {
next(error);
}
}
async getStatesByCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const states = await statesService.findByCountry(req.params.countryId);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async getStatesByCountryCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const states = await statesService.findByCountryCode(req.params.countryCode);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async createState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createStateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de estado inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: CreateStateDto = {
countryId: data.country_id ?? data.countryId!,
code: data.code,
name: data.name,
timezone: data.timezone,
isActive: data.is_active ?? data.isActive,
};
const state = await statesService.create(dto);
res.status(201).json({ success: true, data: state, message: 'Estado creado exitosamente' });
} catch (error) {
next(error);
}
}
async updateState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updateStateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de estado inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: UpdateStateDto = {
name: data.name,
timezone: data.timezone ?? undefined,
isActive: data.is_active ?? data.isActive,
};
const state = await statesService.update(req.params.id, dto);
res.json({ success: true, data: state, message: 'Estado actualizado exitosamente' });
} catch (error) {
next(error);
}
}
async deleteState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await statesService.delete(req.params.id);
res.json({ success: true, message: 'Estado eliminado exitosamente' });
} catch (error) {
next(error);
}
}
// ========== CURRENCY RATES ==========
async getCurrencyRates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
tenantId: req.tenantId,
fromCurrencyCode: req.query.from as string | undefined,
toCurrencyCode: req.query.to as string | undefined,
limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 100,
};
const rates = await currencyRatesService.findAll(filter);
res.json({ success: true, data: rates });
} catch (error) {
next(error);
}
}
async getCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const rate = await currencyRatesService.findById(req.params.id);
res.json({ success: true, data: rate });
} catch (error) {
next(error);
}
}
async getLatestRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const fromCode = req.params.from.toUpperCase();
const toCode = req.params.to.toUpperCase();
const dateStr = req.query.date as string | undefined;
const date = dateStr ? new Date(dateStr) : new Date();
const rate = await currencyRatesService.getRate(fromCode, toCode, date, req.tenantId);
if (rate === null) {
res.status(404).json({
success: false,
message: `No se encontró tipo de cambio para ${fromCode}/${toCode}`
});
return;
}
res.json({
success: true,
data: {
from: fromCode,
to: toCode,
rate,
date: date.toISOString().split('T')[0]
}
});
} catch (error) {
next(error);
}
}
async createCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createCurrencyRateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de tipo de cambio inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: CreateCurrencyRateDto = {
tenantId: req.tenantId,
fromCurrencyCode: data.from_currency_code ?? data.fromCurrencyCode!,
toCurrencyCode: data.to_currency_code ?? data.toCurrencyCode!,
rate: data.rate,
rateDate: data.rate_date ?? data.rateDate ? new Date(data.rate_date ?? data.rateDate!) : new Date(),
source: data.source,
createdBy: req.user?.userId,
};
const rate = await currencyRatesService.create(dto);
res.status(201).json({ success: true, data: rate, message: 'Tipo de cambio creado exitosamente' });
} catch (error) {
next(error);
}
}
async convertCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = convertCurrencySchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de conversión inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: ConvertCurrencyDto = {
amount: data.amount,
fromCurrencyCode: data.from_currency_code ?? data.fromCurrencyCode!,
toCurrencyCode: data.to_currency_code ?? data.toCurrencyCode!,
date: data.date ? new Date(data.date) : new Date(),
tenantId: req.tenantId,
};
const result = await currencyRatesService.convert(dto);
if (result === null) {
res.status(404).json({
success: false,
message: `No se encontró tipo de cambio para ${dto.fromCurrencyCode}/${dto.toCurrencyCode}`
});
return;
}
res.json({
success: true,
data: {
originalAmount: dto.amount,
convertedAmount: result.amount,
rate: result.rate,
from: dto.fromCurrencyCode,
to: dto.toCurrencyCode,
}
});
} catch (error) {
next(error);
}
}
async deleteCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await currencyRatesService.delete(req.params.id);
res.json({ success: true, message: 'Tipo de cambio eliminado exitosamente' });
} catch (error) {
next(error);
}
}
async getCurrencyRateHistory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const fromCode = req.params.from.toUpperCase();
const toCode = req.params.to.toUpperCase();
const days = req.query.days ? parseInt(req.query.days as string, 10) : 30;
const history = await currencyRatesService.getHistory(fromCode, toCode, days, req.tenantId);
res.json({ success: true, data: history });
} catch (error) {
next(error);
}
}
async getLatestRates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const baseCurrency = (req.query.base as string) || 'MXN';
const ratesMap = await currencyRatesService.getLatestRates(baseCurrency, req.tenantId);
// Convert Map to object for JSON response
const rates: Record<string, number> = {};
ratesMap.forEach((value, key) => {
rates[key] = value;
});
res.json({
success: true,
data: {
base: baseCurrency,
rates,
date: new Date().toISOString().split('T')[0],
}
});
} catch (error) {
next(error);
}
}
// ========== UOM CATEGORIES ========== // ========== UOM CATEGORIES ==========
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try { try {
@ -635,56 +195,6 @@ class CoreController {
} }
} }
async getUomByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const uom = await uomService.findByCode(req.params.code);
if (!uom) {
res.status(404).json({ success: false, message: 'Unidad de medida no encontrada' });
return;
}
res.json({ success: true, data: uom });
} catch (error) {
next(error);
}
}
async convertUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { quantity, from_uom_id, fromUomId, to_uom_id, toUomId } = req.body;
const fromId = from_uom_id ?? fromUomId;
const toId = to_uom_id ?? toUomId;
if (!quantity || !fromId || !toId) {
throw new ValidationError('Se requiere quantity, from_uom_id y to_uom_id');
}
const result = await uomService.convertQuantity(quantity, fromId, toId);
const fromUom = await uomService.findById(fromId);
const toUom = await uomService.findById(toId);
res.json({
success: true,
data: {
originalQuantity: quantity,
originalUom: fromUom.name,
convertedQuantity: result,
targetUom: toUom.name,
}
});
} catch (error) {
next(error);
}
}
async getUomConversions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const result = await uomService.getConversionTable(req.params.categoryId);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
// ========== PRODUCT CATEGORIES ========== // ========== PRODUCT CATEGORIES ==========
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> { async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try { try {
@ -742,205 +252,6 @@ class CoreController {
next(error); next(error);
} }
} }
// ========== PAYMENT TERMS ==========
async getPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const activeOnly = req.query.active === 'true';
const paymentTerms = await paymentTermsService.findAll(req.tenantId!, activeOnly);
res.json({ success: true, data: paymentTerms });
} catch (error) {
next(error);
}
}
async getPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
res.json({ success: true, data: paymentTerm });
} catch (error) {
next(error);
}
}
async createPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createPaymentTermSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
}
const dto: CreatePaymentTermDto = parseResult.data;
const paymentTerm = await paymentTermsService.create(dto, req.tenantId!, req.user?.userId);
res.status(201).json({ success: true, data: paymentTerm, message: 'Término de pago creado exitosamente' });
} catch (error) {
next(error);
}
}
async updatePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updatePaymentTermSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
}
const dto: UpdatePaymentTermDto = parseResult.data;
const paymentTerm = await paymentTermsService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
res.json({ success: true, data: paymentTerm, message: 'Término de pago actualizado exitosamente' });
} catch (error) {
next(error);
}
}
async deletePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await paymentTermsService.delete(req.params.id, req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Término de pago eliminado exitosamente' });
} catch (error) {
next(error);
}
}
async calculateDueDate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = calculateDueDateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos inválidos para cálculo', parseResult.error.errors);
}
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
const invoiceDate = parseResult.data.invoice_date ?? parseResult.data.invoiceDate ?? new Date().toISOString();
const totalAmount = parseResult.data.total_amount ?? parseResult.data.totalAmount ?? 0;
const result = paymentTermsService.calculateDueDate(paymentTerm, new Date(invoiceDate), totalAmount);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
async getStandardPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const standardTerms = paymentTermsService.getStandardTerms();
res.json({ success: true, data: standardTerms });
} catch (error) {
next(error);
}
}
async initializePaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await paymentTermsService.initializeForTenant(req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Términos de pago inicializados exitosamente' });
} catch (error) {
next(error);
}
}
// ========== DISCOUNT RULES ==========
async getDiscountRules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const activeOnly = req.query.active === 'true';
const discountRules = await discountRulesService.findAll(req.tenantId!, activeOnly);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
async getDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRule = await discountRulesService.findById(req.params.id, req.tenantId!);
res.json({ success: true, data: discountRule });
} catch (error) {
next(error);
}
}
async createDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createDiscountRuleSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
}
const dto: CreateDiscountRuleDto = parseResult.data;
const discountRule = await discountRulesService.create(dto, req.tenantId!, req.user?.userId);
res.status(201).json({ success: true, data: discountRule, message: 'Regla de descuento creada exitosamente' });
} catch (error) {
next(error);
}
}
async updateDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updateDiscountRuleSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
}
const dto: UpdateDiscountRuleDto = parseResult.data;
const discountRule = await discountRulesService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
res.json({ success: true, data: discountRule, message: 'Regla de descuento actualizada exitosamente' });
} catch (error) {
next(error);
}
}
async deleteDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await discountRulesService.delete(req.params.id, req.tenantId!, req.user?.userId);
res.json({ success: true, message: 'Regla de descuento eliminada exitosamente' });
} catch (error) {
next(error);
}
}
async applyDiscounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = applyDiscountsSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos inválidos para aplicar descuentos', parseResult.error.errors);
}
const data = parseResult.data;
const context: ApplyDiscountContext = {
productId: data.product_id ?? data.productId,
categoryId: data.category_id ?? data.categoryId,
customerId: data.customer_id ?? data.customerId,
customerGroupId: data.customer_group_id ?? data.customerGroupId,
quantity: data.quantity,
unitPrice: data.unit_price ?? data.unitPrice ?? 0,
totalAmount: data.total_amount ?? data.totalAmount ?? 0,
isFirstPurchase: data.is_first_purchase ?? data.isFirstPurchase,
};
const result = await discountRulesService.applyDiscounts(req.tenantId!, context);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
async resetDiscountRuleUsage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRule = await discountRulesService.resetUsageCount(req.params.id, req.tenantId!);
res.json({ success: true, data: discountRule, message: 'Contador de uso reiniciado exitosamente' });
} catch (error) {
next(error);
}
}
async getDiscountRulesByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRules = await discountRulesService.findByProduct(req.params.productId, req.tenantId!);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
async getDiscountRulesByCustomer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const discountRules = await discountRulesService.findByCustomer(req.params.customerId, req.tenantId!);
res.json({ success: true, data: discountRules });
} catch (error) {
next(error);
}
}
} }
export const coreController = new CoreController(); export const coreController = new CoreController();

View File

@ -21,50 +21,16 @@ router.put('/currencies/:id', requireRoles('admin', 'super_admin'), (req, res, n
router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next)); router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next));
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next)); router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
// ========== STATES ==========
router.get('/states', (req, res, next) => coreController.getStates(req, res, next));
router.get('/states/:id', (req, res, next) => coreController.getState(req, res, next));
router.get('/countries/:countryId/states', (req, res, next) => coreController.getStatesByCountry(req, res, next));
router.get('/countries/code/:countryCode/states', (req, res, next) => coreController.getStatesByCountryCode(req, res, next));
router.post('/states', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.createState(req, res, next)
);
router.put('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.updateState(req, res, next)
);
router.delete('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteState(req, res, next)
);
// ========== CURRENCY RATES ==========
router.get('/currency-rates', (req, res, next) => coreController.getCurrencyRates(req, res, next));
router.get('/currency-rates/latest', (req, res, next) => coreController.getLatestRates(req, res, next));
router.get('/currency-rates/rate/:from/:to', (req, res, next) => coreController.getLatestRate(req, res, next));
router.get('/currency-rates/history/:from/:to', (req, res, next) => coreController.getCurrencyRateHistory(req, res, next));
router.get('/currency-rates/:id', (req, res, next) => coreController.getCurrencyRate(req, res, next));
router.post('/currency-rates', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createCurrencyRate(req, res, next)
);
router.post('/currency-rates/convert', (req, res, next) => coreController.convertCurrency(req, res, next));
router.delete('/currency-rates/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteCurrencyRate(req, res, next)
);
// ========== UOM CATEGORIES ========== // ========== UOM CATEGORIES ==========
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next)); router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next)); router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
// ========== UOM ========== // ========== UOM ==========
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next)); router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
router.get('/uom/by-code/:code', (req, res, next) => coreController.getUomByCode(req, res, next));
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next)); router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) => router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.createUom(req, res, next) coreController.createUom(req, res, next)
); );
router.post('/uom/convert', (req, res, next) => coreController.convertUom(req, res, next));
router.get('/uom-categories/:categoryId/conversions', (req, res, next) =>
coreController.getUomConversions(req, res, next)
);
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) => router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.updateUom(req, res, next) coreController.updateUom(req, res, next)
); );
@ -82,47 +48,4 @@ router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (
coreController.deleteProductCategory(req, res, next) coreController.deleteProductCategory(req, res, next)
); );
// ========== PAYMENT TERMS ==========
router.get('/payment-terms', (req, res, next) => coreController.getPaymentTerms(req, res, next));
router.get('/payment-terms/standard', (req, res, next) => coreController.getStandardPaymentTerms(req, res, next));
router.get('/payment-terms/:id', (req, res, next) => coreController.getPaymentTerm(req, res, next));
router.post('/payment-terms', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createPaymentTerm(req, res, next)
);
router.post('/payment-terms/initialize', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.initializePaymentTerms(req, res, next)
);
router.post('/payment-terms/:id/calculate-due-date', (req, res, next) =>
coreController.calculateDueDate(req, res, next)
);
router.put('/payment-terms/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.updatePaymentTerm(req, res, next)
);
router.delete('/payment-terms/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deletePaymentTerm(req, res, next)
);
// ========== DISCOUNT RULES ==========
router.get('/discount-rules', (req, res, next) => coreController.getDiscountRules(req, res, next));
router.get('/discount-rules/by-product/:productId', (req, res, next) =>
coreController.getDiscountRulesByProduct(req, res, next)
);
router.get('/discount-rules/by-customer/:customerId', (req, res, next) =>
coreController.getDiscountRulesByCustomer(req, res, next)
);
router.get('/discount-rules/:id', (req, res, next) => coreController.getDiscountRule(req, res, next));
router.post('/discount-rules', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createDiscountRule(req, res, next)
);
router.post('/discount-rules/apply', (req, res, next) => coreController.applyDiscounts(req, res, next));
router.put('/discount-rules/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.updateDiscountRule(req, res, next)
);
router.post('/discount-rules/:id/reset-usage', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.resetDiscountRuleUsage(req, res, next)
);
router.delete('/discount-rules/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteDiscountRule(req, res, next)
);
export default router; export default router;

View File

@ -1,269 +0,0 @@
import { Repository, LessThanOrEqual } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { CurrencyRate, RateSource } from './entities/currency-rate.entity.js';
import { Currency } from './entities/currency.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface CreateCurrencyRateDto {
tenantId?: string;
fromCurrencyCode: string;
toCurrencyCode: string;
rate: number;
rateDate: Date;
source?: RateSource;
createdBy?: string;
}
export interface CurrencyRateFilter {
tenantId?: string;
fromCurrencyCode?: string;
toCurrencyCode?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
}
export interface ConvertCurrencyDto {
amount: number;
fromCurrencyCode: string;
toCurrencyCode: string;
date?: Date;
tenantId?: string;
}
class CurrencyRatesService {
private repository: Repository<CurrencyRate>;
private currencyRepository: Repository<Currency>;
constructor() {
this.repository = AppDataSource.getRepository(CurrencyRate);
this.currencyRepository = AppDataSource.getRepository(Currency);
}
async findAll(filter: CurrencyRateFilter = {}): Promise<CurrencyRate[]> {
logger.debug('Finding currency rates', { filter });
const query = this.repository
.createQueryBuilder('rate')
.leftJoinAndSelect('rate.fromCurrency', 'fromCurrency')
.leftJoinAndSelect('rate.toCurrency', 'toCurrency');
if (filter.tenantId) {
query.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', {
tenantId: filter.tenantId,
});
}
if (filter.fromCurrencyCode) {
query.andWhere('fromCurrency.code = :fromCode', {
fromCode: filter.fromCurrencyCode.toUpperCase(),
});
}
if (filter.toCurrencyCode) {
query.andWhere('toCurrency.code = :toCode', {
toCode: filter.toCurrencyCode.toUpperCase(),
});
}
if (filter.dateFrom) {
query.andWhere('rate.rateDate >= :dateFrom', { dateFrom: filter.dateFrom });
}
if (filter.dateTo) {
query.andWhere('rate.rateDate <= :dateTo', { dateTo: filter.dateTo });
}
query.orderBy('rate.rateDate', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
return query.getMany();
}
async findById(id: string): Promise<CurrencyRate> {
logger.debug('Finding currency rate by id', { id });
const rate = await this.repository.findOne({
where: { id },
relations: ['fromCurrency', 'toCurrency'],
});
if (!rate) {
throw new NotFoundError('Tipo de cambio no encontrado');
}
return rate;
}
async getRate(
fromCurrencyCode: string,
toCurrencyCode: string,
date: Date = new Date(),
tenantId?: string
): Promise<number | null> {
logger.debug('Getting currency rate', { fromCurrencyCode, toCurrencyCode, date, tenantId });
// Same currency = rate 1
if (fromCurrencyCode.toUpperCase() === toCurrencyCode.toUpperCase()) {
return 1;
}
// Find direct rate
const directRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (directRate) {
return Number(directRate.rate);
}
// Try inverse rate
const inverseRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (inverseRate) {
return 1 / Number(inverseRate.rate);
}
return null;
}
async convert(dto: ConvertCurrencyDto): Promise<{ amount: number; rate: number } | null> {
logger.debug('Converting currency', dto);
const rate = await this.getRate(
dto.fromCurrencyCode,
dto.toCurrencyCode,
dto.date || new Date(),
dto.tenantId
);
if (rate === null) {
return null;
}
return {
amount: dto.amount * rate,
rate,
};
}
async create(dto: CreateCurrencyRateDto): Promise<CurrencyRate> {
logger.info('Creating currency rate', { dto });
// Get currency IDs
const fromCurrency = await this.currencyRepository.findOne({
where: { code: dto.fromCurrencyCode.toUpperCase() },
});
if (!fromCurrency) {
throw new NotFoundError(`Moneda ${dto.fromCurrencyCode} no encontrada`);
}
const toCurrency = await this.currencyRepository.findOne({
where: { code: dto.toCurrencyCode.toUpperCase() },
});
if (!toCurrency) {
throw new NotFoundError(`Moneda ${dto.toCurrencyCode} no encontrada`);
}
// Check if rate already exists for this date
const existing = await this.repository.findOne({
where: {
tenantId: dto.tenantId || undefined,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rateDate: dto.rateDate,
},
});
if (existing) {
// Update existing rate
existing.rate = dto.rate;
existing.source = dto.source || 'manual';
return this.repository.save(existing);
}
const rate = this.repository.create({
tenantId: dto.tenantId || null,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rate: dto.rate,
rateDate: dto.rateDate,
source: dto.source || 'manual',
createdBy: dto.createdBy || null,
});
return this.repository.save(rate);
}
async delete(id: string): Promise<void> {
logger.info('Deleting currency rate', { id });
const rate = await this.findById(id);
await this.repository.remove(rate);
}
async getHistory(
fromCurrencyCode: string,
toCurrencyCode: string,
days: number = 30,
tenantId?: string
): Promise<CurrencyRate[]> {
logger.debug('Getting rate history', { fromCurrencyCode, toCurrencyCode, days, tenantId });
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
return this.findAll({
fromCurrencyCode,
toCurrencyCode,
dateFrom,
tenantId,
limit: days,
});
}
async getLatestRates(baseCurrencyCode: string = 'MXN', tenantId?: string): Promise<Map<string, number>> {
logger.debug('Getting latest rates', { baseCurrencyCode, tenantId });
const rates = new Map<string, number>();
const currencies = await this.currencyRepository.find({ where: { active: true } });
for (const currency of currencies) {
if (currency.code === baseCurrencyCode.toUpperCase()) {
rates.set(currency.code, 1);
continue;
}
const rate = await this.getRate(baseCurrencyCode, currency.code, new Date(), tenantId);
if (rate !== null) {
rates.set(currency.code, rate);
}
}
return rates;
}
}
export const currencyRatesService = new CurrencyRatesService();

View File

@ -1,527 +0,0 @@
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import {
DiscountRule,
DiscountType,
DiscountAppliesTo,
DiscountCondition,
} from './entities/discount-rule.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface CreateDiscountRuleDto {
code: string;
name: string;
description?: string;
discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override';
discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override';
discount_value: number;
discountValue?: number;
max_discount_amount?: number | null;
maxDiscountAmount?: number | null;
applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
applies_to_id?: string | null;
appliesToId?: string | null;
condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
condition_value?: number | null;
conditionValue?: number | null;
start_date?: Date | string | null;
startDate?: Date | string | null;
end_date?: Date | string | null;
endDate?: Date | string | null;
priority?: number;
combinable?: boolean;
usage_limit?: number | null;
usageLimit?: number | null;
}
export interface UpdateDiscountRuleDto {
name?: string;
description?: string | null;
discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override';
discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override';
discount_value?: number;
discountValue?: number;
max_discount_amount?: number | null;
maxDiscountAmount?: number | null;
applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
applies_to_id?: string | null;
appliesToId?: string | null;
condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
condition_value?: number | null;
conditionValue?: number | null;
start_date?: Date | string | null;
startDate?: Date | string | null;
end_date?: Date | string | null;
endDate?: Date | string | null;
priority?: number;
combinable?: boolean;
usage_limit?: number | null;
usageLimit?: number | null;
is_active?: boolean;
isActive?: boolean;
}
export interface ApplyDiscountContext {
productId?: string;
categoryId?: string;
customerId?: string;
customerGroupId?: string;
quantity: number;
unitPrice: number;
totalAmount: number;
isFirstPurchase?: boolean;
}
export interface DiscountResult {
ruleId: string;
ruleCode: string;
ruleName: string;
discountType: DiscountType;
discountAmount: number;
discountPercent: number;
originalAmount: number;
finalAmount: number;
}
export interface ApplyDiscountsResult {
appliedDiscounts: DiscountResult[];
totalDiscount: number;
originalAmount: number;
finalAmount: number;
}
// ============================================================================
// SERVICE
// ============================================================================
class DiscountRulesService {
private repository: Repository<DiscountRule>;
constructor() {
this.repository = AppDataSource.getRepository(DiscountRule);
}
/**
* Apply applicable discount rules to a context
*/
async applyDiscounts(
tenantId: string,
context: ApplyDiscountContext
): Promise<ApplyDiscountsResult> {
logger.debug('Applying discounts', { tenantId, context });
const applicableRules = await this.findApplicableRules(tenantId, context);
const appliedDiscounts: DiscountResult[] = [];
let runningAmount = context.totalAmount;
let totalDiscount = 0;
// Sort by priority (lower = higher priority)
const sortedRules = applicableRules.sort((a, b) => a.priority - b.priority);
for (const rule of sortedRules) {
// Check if rule can be combined with already applied discounts
if (appliedDiscounts.length > 0 && !rule.combinable) {
logger.debug('Skipping non-combinable rule', { ruleCode: rule.code });
continue;
}
// Check if previous discounts are non-combinable
const hasNonCombinable = appliedDiscounts.some(
(d) => !sortedRules.find((r) => r.id === d.ruleId)?.combinable
);
if (hasNonCombinable && !rule.combinable) {
continue;
}
// Check usage limit
if (rule.usageLimit && rule.usageCount >= rule.usageLimit) {
logger.debug('Rule usage limit reached', { ruleCode: rule.code });
continue;
}
// Calculate discount
const discountResult = this.calculateDiscount(rule, runningAmount, context);
if (discountResult.discountAmount > 0) {
appliedDiscounts.push(discountResult);
totalDiscount += discountResult.discountAmount;
runningAmount = discountResult.finalAmount;
// Increment usage count
await this.incrementUsageCount(rule.id);
}
}
return {
appliedDiscounts,
totalDiscount,
originalAmount: context.totalAmount,
finalAmount: context.totalAmount - totalDiscount,
};
}
/**
* Calculate discount for a single rule
*/
private calculateDiscount(
rule: DiscountRule,
amount: number,
context: ApplyDiscountContext
): DiscountResult {
let discountAmount = 0;
let discountPercent = 0;
switch (rule.discountType) {
case DiscountType.PERCENTAGE:
discountPercent = Number(rule.discountValue);
discountAmount = (amount * discountPercent) / 100;
break;
case DiscountType.FIXED:
discountAmount = Math.min(Number(rule.discountValue), amount);
discountPercent = (discountAmount / amount) * 100;
break;
case DiscountType.PRICE_OVERRIDE:
const newPrice = Number(rule.discountValue);
const totalNewAmount = newPrice * context.quantity;
discountAmount = Math.max(0, amount - totalNewAmount);
discountPercent = (discountAmount / amount) * 100;
break;
}
// Apply max discount cap
if (rule.maxDiscountAmount && discountAmount > Number(rule.maxDiscountAmount)) {
discountAmount = Number(rule.maxDiscountAmount);
discountPercent = (discountAmount / amount) * 100;
}
return {
ruleId: rule.id,
ruleCode: rule.code,
ruleName: rule.name,
discountType: rule.discountType,
discountAmount: Math.round(discountAmount * 100) / 100,
discountPercent: Math.round(discountPercent * 100) / 100,
originalAmount: amount,
finalAmount: Math.round((amount - discountAmount) * 100) / 100,
};
}
/**
* Find all applicable rules for a context
*/
private async findApplicableRules(
tenantId: string,
context: ApplyDiscountContext
): Promise<DiscountRule[]> {
const now = new Date();
const queryBuilder = this.repository
.createQueryBuilder('dr')
.where('dr.tenant_id = :tenantId', { tenantId })
.andWhere('dr.is_active = :isActive', { isActive: true })
.andWhere('(dr.start_date IS NULL OR dr.start_date <= :now)', { now })
.andWhere('(dr.end_date IS NULL OR dr.end_date >= :now)', { now });
const allRules = await queryBuilder.getMany();
// Filter by applies_to and condition
return allRules.filter((rule) => {
// Check applies_to
if (!this.checkAppliesTo(rule, context)) {
return false;
}
// Check condition
if (!this.checkCondition(rule, context)) {
return false;
}
return true;
});
}
/**
* Check if rule applies to the context
*/
private checkAppliesTo(rule: DiscountRule, context: ApplyDiscountContext): boolean {
switch (rule.appliesTo) {
case DiscountAppliesTo.ALL:
return true;
case DiscountAppliesTo.PRODUCT:
return rule.appliesToId === context.productId;
case DiscountAppliesTo.CATEGORY:
return rule.appliesToId === context.categoryId;
case DiscountAppliesTo.CUSTOMER:
return rule.appliesToId === context.customerId;
case DiscountAppliesTo.CUSTOMER_GROUP:
return rule.appliesToId === context.customerGroupId;
default:
return false;
}
}
/**
* Check if rule condition is met
*/
private checkCondition(rule: DiscountRule, context: ApplyDiscountContext): boolean {
switch (rule.conditionType) {
case DiscountCondition.NONE:
return true;
case DiscountCondition.MIN_QUANTITY:
return context.quantity >= Number(rule.conditionValue || 0);
case DiscountCondition.MIN_AMOUNT:
return context.totalAmount >= Number(rule.conditionValue || 0);
case DiscountCondition.DATE_RANGE:
// Already handled in query
return true;
case DiscountCondition.FIRST_PURCHASE:
return context.isFirstPurchase === true;
default:
return true;
}
}
/**
* Increment usage count for a rule
*/
private async incrementUsageCount(ruleId: string): Promise<void> {
await this.repository.increment({ id: ruleId }, 'usageCount', 1);
}
/**
* Get all discount rules for a tenant
*/
async findAll(tenantId: string, activeOnly: boolean = false): Promise<DiscountRule[]> {
logger.debug('Finding all discount rules', { tenantId, activeOnly });
const query = this.repository
.createQueryBuilder('dr')
.where('dr.tenant_id = :tenantId', { tenantId })
.orderBy('dr.priority', 'ASC')
.addOrderBy('dr.name', 'ASC');
if (activeOnly) {
query.andWhere('dr.is_active = :isActive', { isActive: true });
}
return query.getMany();
}
/**
* Get a specific discount rule by ID
*/
async findById(id: string, tenantId: string): Promise<DiscountRule> {
logger.debug('Finding discount rule by id', { id, tenantId });
const rule = await this.repository.findOne({
where: { id, tenantId },
});
if (!rule) {
throw new NotFoundError('Regla de descuento no encontrada');
}
return rule;
}
/**
* Get a specific discount rule by code
*/
async findByCode(code: string, tenantId: string): Promise<DiscountRule | null> {
logger.debug('Finding discount rule by code', { code, tenantId });
return this.repository.findOne({
where: { code, tenantId },
});
}
/**
* Create a new discount rule
*/
async create(
dto: CreateDiscountRuleDto,
tenantId: string,
userId?: string
): Promise<DiscountRule> {
logger.debug('Creating discount rule', { dto, tenantId });
// Check for existing
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new ConflictError(`Ya existe una regla de descuento con código ${dto.code}`);
}
// Normalize inputs
const discountTypeRaw = dto.discount_type ?? dto.discountType ?? 'percentage';
const discountType = discountTypeRaw as DiscountType;
const discountValue = dto.discount_value ?? dto.discountValue;
const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount ?? null;
const appliesToRaw = dto.applies_to ?? dto.appliesTo ?? 'all';
const appliesTo = appliesToRaw as DiscountAppliesTo;
const appliesToId = dto.applies_to_id ?? dto.appliesToId ?? null;
const conditionTypeRaw = dto.condition_type ?? dto.conditionType ?? 'none';
const conditionType = conditionTypeRaw as DiscountCondition;
const conditionValue = dto.condition_value ?? dto.conditionValue ?? null;
const startDate = dto.start_date ?? dto.startDate ?? null;
const endDate = dto.end_date ?? dto.endDate ?? null;
const usageLimit = dto.usage_limit ?? dto.usageLimit ?? null;
if (discountValue === undefined) {
throw new ValidationError('discount_value es requerido');
}
const rule = this.repository.create({
tenantId,
code: dto.code,
name: dto.name,
description: dto.description || null,
discountType,
discountValue,
maxDiscountAmount,
appliesTo,
appliesToId,
conditionType,
conditionValue,
startDate: startDate ? new Date(startDate) : null,
endDate: endDate ? new Date(endDate) : null,
priority: dto.priority ?? 10,
combinable: dto.combinable ?? true,
usageLimit,
createdBy: userId || null,
});
const saved = await this.repository.save(rule);
logger.info('Discount rule created', { id: saved.id, code: dto.code, tenantId });
return saved;
}
/**
* Update a discount rule
*/
async update(
id: string,
dto: UpdateDiscountRuleDto,
tenantId: string,
userId?: string
): Promise<DiscountRule> {
logger.debug('Updating discount rule', { id, dto, tenantId });
const existing = await this.findById(id, tenantId);
// Normalize inputs
const discountTypeRaw = dto.discount_type ?? dto.discountType;
const discountValue = dto.discount_value ?? dto.discountValue;
const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount;
const appliesToRaw = dto.applies_to ?? dto.appliesTo;
const appliesToId = dto.applies_to_id ?? dto.appliesToId;
const conditionTypeRaw = dto.condition_type ?? dto.conditionType;
const conditionValue = dto.condition_value ?? dto.conditionValue;
const startDate = dto.start_date ?? dto.startDate;
const endDate = dto.end_date ?? dto.endDate;
const usageLimit = dto.usage_limit ?? dto.usageLimit;
const isActive = dto.is_active ?? dto.isActive;
if (dto.name !== undefined) existing.name = dto.name;
if (dto.description !== undefined) existing.description = dto.description;
if (discountTypeRaw !== undefined) existing.discountType = discountTypeRaw as DiscountType;
if (discountValue !== undefined) existing.discountValue = discountValue;
if (maxDiscountAmount !== undefined) existing.maxDiscountAmount = maxDiscountAmount;
if (appliesToRaw !== undefined) existing.appliesTo = appliesToRaw as DiscountAppliesTo;
if (appliesToId !== undefined) existing.appliesToId = appliesToId;
if (conditionTypeRaw !== undefined) existing.conditionType = conditionTypeRaw as DiscountCondition;
if (conditionValue !== undefined) existing.conditionValue = conditionValue;
if (startDate !== undefined) existing.startDate = startDate ? new Date(startDate) : null;
if (endDate !== undefined) existing.endDate = endDate ? new Date(endDate) : null;
if (dto.priority !== undefined) existing.priority = dto.priority;
if (dto.combinable !== undefined) existing.combinable = dto.combinable;
if (usageLimit !== undefined) existing.usageLimit = usageLimit;
if (isActive !== undefined) existing.isActive = isActive;
existing.updatedBy = userId || null;
const updated = await this.repository.save(existing);
logger.info('Discount rule updated', { id, tenantId });
return updated;
}
/**
* Soft delete a discount rule
*/
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
logger.debug('Deleting discount rule', { id, tenantId });
const existing = await this.findById(id, tenantId);
existing.deletedAt = new Date();
existing.deletedBy = userId || null;
await this.repository.save(existing);
logger.info('Discount rule deleted', { id, tenantId });
}
/**
* Reset usage count for a rule
*/
async resetUsageCount(id: string, tenantId: string): Promise<DiscountRule> {
logger.debug('Resetting usage count', { id, tenantId });
const rule = await this.findById(id, tenantId);
rule.usageCount = 0;
return this.repository.save(rule);
}
/**
* Find rules by product
*/
async findByProduct(productId: string, tenantId: string): Promise<DiscountRule[]> {
return this.repository.find({
where: [
{ tenantId, appliesTo: DiscountAppliesTo.PRODUCT, appliesToId: productId, isActive: true },
{ tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true },
],
order: { priority: 'ASC' },
});
}
/**
* Find rules by customer
*/
async findByCustomer(customerId: string, tenantId: string): Promise<DiscountRule[]> {
return this.repository.find({
where: [
{ tenantId, appliesTo: DiscountAppliesTo.CUSTOMER, appliesToId: customerId, isActive: true },
{ tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true },
],
order: { priority: 'ASC' },
});
}
}
export const discountRulesService = new DiscountRulesService();

View File

@ -1,55 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Currency } from './currency.entity.js';
export type RateSource = 'manual' | 'banxico' | 'xe' | 'openexchange';
@Entity({ schema: 'core', name: 'currency_rates' })
@Index('idx_currency_rates_tenant', ['tenantId'])
@Index('idx_currency_rates_from', ['fromCurrencyId'])
@Index('idx_currency_rates_to', ['toCurrencyId'])
@Index('idx_currency_rates_date', ['rateDate'])
@Index('idx_currency_rates_lookup', ['fromCurrencyId', 'toCurrencyId', 'rateDate'])
export class CurrencyRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id', nullable: true })
tenantId: string | null;
@Column({ type: 'uuid', name: 'from_currency_id', nullable: false })
fromCurrencyId: string;
@ManyToOne(() => Currency)
@JoinColumn({ name: 'from_currency_id' })
fromCurrency: Currency;
@Column({ type: 'uuid', name: 'to_currency_id', nullable: false })
toCurrencyId: string;
@ManyToOne(() => Currency)
@JoinColumn({ name: 'to_currency_id' })
toCurrency: Currency;
@Column({ type: 'decimal', precision: 18, scale: 8, nullable: false })
rate: number;
@Column({ type: 'date', name: 'rate_date', nullable: false })
rateDate: Date;
@Column({ type: 'varchar', length: 50, default: 'manual' })
source: RateSource;
@Column({ type: 'uuid', name: 'created_by', nullable: true })
createdBy: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -1,163 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
/**
* Tipo de descuento
*/
export enum DiscountType {
PERCENTAGE = 'percentage', // Porcentaje del total
FIXED = 'fixed', // Monto fijo
PRICE_OVERRIDE = 'price_override', // Precio especial
}
/**
* Aplicación del descuento
*/
export enum DiscountAppliesTo {
ALL = 'all', // Todos los productos
CATEGORY = 'category', // Categoría específica
PRODUCT = 'product', // Producto específico
CUSTOMER = 'customer', // Cliente específico
CUSTOMER_GROUP = 'customer_group', // Grupo de clientes
}
/**
* Condición de activación
*/
export enum DiscountCondition {
NONE = 'none', // Sin condición
MIN_QUANTITY = 'min_quantity', // Cantidad mínima
MIN_AMOUNT = 'min_amount', // Monto mínimo
DATE_RANGE = 'date_range', // Rango de fechas
FIRST_PURCHASE = 'first_purchase', // Primera compra
}
/**
* Regla de descuento
*/
@Entity({ schema: 'core', name: 'discount_rules' })
@Index('idx_discount_rules_tenant_id', ['tenantId'])
@Index('idx_discount_rules_code_tenant', ['tenantId', 'code'], { unique: true })
@Index('idx_discount_rules_active', ['tenantId', 'isActive'])
@Index('idx_discount_rules_dates', ['tenantId', 'startDate', 'endDate'])
@Index('idx_discount_rules_priority', ['tenantId', 'priority'])
export class DiscountRule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: DiscountType,
default: DiscountType.PERCENTAGE,
name: 'discount_type',
})
discountType: DiscountType;
@Column({
type: 'decimal',
precision: 15,
scale: 4,
nullable: false,
name: 'discount_value',
})
discountValue: number;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: true,
name: 'max_discount_amount',
})
maxDiscountAmount: number | null;
@Column({
type: 'enum',
enum: DiscountAppliesTo,
default: DiscountAppliesTo.ALL,
name: 'applies_to',
})
appliesTo: DiscountAppliesTo;
@Column({ type: 'uuid', nullable: true, name: 'applies_to_id' })
appliesToId: string | null;
@Column({
type: 'enum',
enum: DiscountCondition,
default: DiscountCondition.NONE,
name: 'condition_type',
})
conditionType: DiscountCondition;
@Column({
type: 'decimal',
precision: 15,
scale: 4,
nullable: true,
name: 'condition_value',
})
conditionValue: number | null;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
startDate: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
endDate: Date | null;
@Column({ type: 'integer', nullable: false, default: 10 })
priority: number;
@Column({ type: 'boolean', nullable: false, default: true, name: 'combinable' })
combinable: boolean;
@Column({ type: 'integer', nullable: true, name: 'usage_limit' })
usageLimit: number | null;
@Column({ type: 'integer', nullable: false, default: 0, name: 'usage_count' })
usageCount: number;
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
isActive: boolean;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -1,10 +1,6 @@
export { Currency } from './currency.entity.js'; export { Currency } from './currency.entity.js';
export { Country } from './country.entity.js'; export { Country } from './country.entity.js';
export { State } from './state.entity.js';
export { CurrencyRate, RateSource } from './currency-rate.entity.js';
export { UomCategory } from './uom-category.entity.js'; export { UomCategory } from './uom-category.entity.js';
export { Uom, UomType } from './uom.entity.js'; export { Uom, UomType } from './uom.entity.js';
export { ProductCategory } from './product-category.entity.js'; export { ProductCategory } from './product-category.entity.js';
export { Sequence, ResetPeriod } from './sequence.entity.js'; export { Sequence, ResetPeriod } from './sequence.entity.js';
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity.js';
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity.js';

View File

@ -1,144 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
OneToMany,
} from 'typeorm';
/**
* Tipo de cálculo para la línea del término de pago
*/
export enum PaymentTermLineType {
BALANCE = 'balance', // Saldo restante
PERCENT = 'percent', // Porcentaje del total
FIXED = 'fixed', // Monto fijo
}
/**
* Línea de término de pago (para términos con múltiples vencimientos)
*/
@Entity({ schema: 'core', name: 'payment_term_lines' })
@Index('idx_payment_term_lines_term', ['paymentTermId'])
export class PaymentTermLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'payment_term_id' })
paymentTermId: string;
@Column({ type: 'integer', nullable: false, default: 1 })
sequence: number;
@Column({
type: 'enum',
enum: PaymentTermLineType,
default: PaymentTermLineType.BALANCE,
name: 'line_type',
})
lineType: PaymentTermLineType;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
name: 'value_percent',
})
valuePercent: number | null;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: true,
name: 'value_amount',
})
valueAmount: number | null;
@Column({ type: 'integer', nullable: false, default: 0 })
days: number;
@Column({ type: 'integer', nullable: true, name: 'day_of_month' })
dayOfMonth: number | null;
@Column({ type: 'boolean', nullable: false, default: false, name: 'end_of_month' })
endOfMonth: boolean;
}
/**
* Término de pago (Net 30, 50% advance + 50% on delivery, etc.)
*/
@Entity({ schema: 'core', name: 'payment_terms' })
@Index('idx_payment_terms_tenant_id', ['tenantId'])
@Index('idx_payment_terms_code_tenant', ['tenantId', 'code'], { unique: true })
@Index('idx_payment_terms_active', ['tenantId', 'isActive'])
export class PaymentTerm {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'integer', nullable: false, default: 0, name: 'due_days' })
dueDays: number;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: true,
default: 0,
name: 'discount_percent',
})
discountPercent: number | null;
@Column({ type: 'integer', nullable: true, default: 0, name: 'discount_days' })
discountDays: number | null;
@Column({ type: 'boolean', nullable: false, default: false, name: 'is_immediate' })
isImmediate: boolean;
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
isActive: boolean;
@Column({ type: 'integer', nullable: false, default: 0 })
sequence: number;
@OneToMany(() => PaymentTermLine, (line) => line.paymentTermId, { eager: true })
lines: PaymentTermLine[];
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
deletedBy: string | null;
}

View File

@ -1,45 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Country } from './country.entity.js';
@Entity({ schema: 'core', name: 'states' })
@Index('idx_states_country', ['countryId'])
@Index('idx_states_code', ['code'])
@Index('idx_states_country_code', ['countryId', 'code'], { unique: true })
export class State {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'country_id', nullable: false })
countryId: string;
@ManyToOne(() => Country, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'country_id' })
country: Country;
@Column({ type: 'varchar', length: 10, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
timezone: string | null;
@Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -3,43 +3,28 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn,
Index, Index,
OneToMany, OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { Uom } from './uom.entity.js'; import { Uom } from './uom.entity.js';
import { Tenant } from '../../auth/entities/tenant.entity.js';
@Entity({ schema: 'core', name: 'uom_categories' }) @Entity({ schema: 'core', name: 'uom_categories' })
@Index('idx_uom_categories_tenant', ['tenantId']) @Index('idx_uom_categories_name', ['name'], { unique: true })
@Index('idx_uom_categories_tenant_name', ['tenantId', 'name'], { unique: true })
export class UomCategory { export class UomCategory {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) @Column({ type: 'varchar', length: 100, nullable: false, unique: true })
tenantId: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string; name: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description: string | null; description: string | null;
// Relations // Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@OneToMany(() => Uom, (uom) => uom.category) @OneToMany(() => Uom, (uom) => uom.category)
uoms: Uom[]; uoms: Uom[];
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
} }

View File

@ -3,13 +3,11 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn,
Index, Index,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { UomCategory } from './uom-category.entity.js'; import { UomCategory } from './uom-category.entity.js';
import { Tenant } from '../../auth/entities/tenant.entity.js';
export enum UomType { export enum UomType {
REFERENCE = 'reference', REFERENCE = 'reference',
@ -18,18 +16,14 @@ export enum UomType {
} }
@Entity({ schema: 'core', name: 'uom' }) @Entity({ schema: 'core', name: 'uom' })
@Index('idx_uom_tenant', ['tenantId'])
@Index('idx_uom_category_id', ['categoryId']) @Index('idx_uom_category_id', ['categoryId'])
@Index('idx_uom_code', ['code']) @Index('idx_uom_code', ['code'])
@Index('idx_uom_active', ['active']) @Index('idx_uom_active', ['active'])
@Index('idx_uom_tenant_category_name', ['tenantId', 'categoryId', 'name'], { unique: true }) @Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true })
export class Uom { export class Uom {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: false, name: 'category_id' }) @Column({ type: 'uuid', nullable: false, name: 'category_id' })
categoryId: string; categoryId: string;
@ -70,10 +64,6 @@ export class Uom {
active: boolean; active: boolean;
// Relations // Relations
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => UomCategory, (category) => category.uoms, { @ManyToOne(() => UomCategory, (category) => category.uoms, {
nullable: false, nullable: false,
}) })
@ -81,9 +71,6 @@ export class Uom {
category: UomCategory; category: UomCategory;
// Audit fields // Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
} }

View File

@ -3,8 +3,6 @@ export * from './countries.service.js';
export * from './uom.service.js'; export * from './uom.service.js';
export * from './product-categories.service.js'; export * from './product-categories.service.js';
export * from './sequences.service.js'; export * from './sequences.service.js';
export * from './payment-terms.service.js';
export * from './discount-rules.service.js';
export * from './entities/index.js'; export * from './entities/index.js';
export * from './core.controller.js'; export * from './core.controller.js';
export { default as coreRoutes } from './core.routes.js'; export { default as coreRoutes } from './core.routes.js';

View File

@ -1,461 +0,0 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import {
PaymentTerm,
PaymentTermLine,
PaymentTermLineType,
} from './entities/payment-term.entity.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface CreatePaymentTermLineDto {
sequence?: number;
line_type?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
lineType?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
value_percent?: number;
valuePercent?: number;
value_amount?: number;
valueAmount?: number;
days?: number;
day_of_month?: number;
dayOfMonth?: number;
end_of_month?: boolean;
endOfMonth?: boolean;
}
export interface CreatePaymentTermDto {
code: string;
name: string;
description?: string;
due_days?: number;
dueDays?: number;
discount_percent?: number;
discountPercent?: number;
discount_days?: number;
discountDays?: number;
is_immediate?: boolean;
isImmediate?: boolean;
lines?: CreatePaymentTermLineDto[];
}
export interface UpdatePaymentTermDto {
name?: string;
description?: string | null;
due_days?: number;
dueDays?: number;
discount_percent?: number | null;
discountPercent?: number | null;
discount_days?: number | null;
discountDays?: number | null;
is_immediate?: boolean;
isImmediate?: boolean;
is_active?: boolean;
isActive?: boolean;
lines?: CreatePaymentTermLineDto[];
}
export interface DueDateResult {
dueDate: Date;
discountDate: Date | null;
discountAmount: number;
lines: Array<{
dueDate: Date;
amount: number;
percent: number;
}>;
}
// ============================================================================
// SERVICE
// ============================================================================
class PaymentTermsService {
private repository: Repository<PaymentTerm>;
private lineRepository: Repository<PaymentTermLine>;
constructor() {
this.repository = AppDataSource.getRepository(PaymentTerm);
this.lineRepository = AppDataSource.getRepository(PaymentTermLine);
}
/**
* Calculate due date(s) based on payment term
*/
calculateDueDate(
paymentTerm: PaymentTerm,
invoiceDate: Date,
totalAmount: number
): DueDateResult {
logger.debug('Calculating due date', {
termCode: paymentTerm.code,
invoiceDate,
totalAmount,
});
const baseDate = new Date(invoiceDate);
const lines: DueDateResult['lines'] = [];
// If immediate payment
if (paymentTerm.isImmediate) {
return {
dueDate: baseDate,
discountDate: null,
discountAmount: 0,
lines: [{ dueDate: baseDate, amount: totalAmount, percent: 100 }],
};
}
// If payment term has lines, use them
if (paymentTerm.lines && paymentTerm.lines.length > 0) {
let remainingAmount = totalAmount;
let lastDueDate = baseDate;
for (const line of paymentTerm.lines.sort((a, b) => a.sequence - b.sequence)) {
let lineAmount = 0;
let linePercent = 0;
if (line.lineType === PaymentTermLineType.BALANCE) {
lineAmount = remainingAmount;
linePercent = (lineAmount / totalAmount) * 100;
} else if (line.lineType === PaymentTermLineType.PERCENT && line.valuePercent) {
linePercent = Number(line.valuePercent);
lineAmount = (totalAmount * linePercent) / 100;
} else if (line.lineType === PaymentTermLineType.FIXED && line.valueAmount) {
lineAmount = Math.min(Number(line.valueAmount), remainingAmount);
linePercent = (lineAmount / totalAmount) * 100;
}
const lineDueDate = this.calculateLineDueDate(baseDate, line);
lastDueDate = lineDueDate;
lines.push({
dueDate: lineDueDate,
amount: lineAmount,
percent: linePercent,
});
remainingAmount -= lineAmount;
}
// Calculate discount date if applicable
let discountDate: Date | null = null;
let discountAmount = 0;
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
discountDate = new Date(baseDate);
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
}
return {
dueDate: lastDueDate,
discountDate,
discountAmount,
lines,
};
}
// Simple due days calculation
const dueDate = new Date(baseDate);
dueDate.setDate(dueDate.getDate() + paymentTerm.dueDays);
let discountDate: Date | null = null;
let discountAmount = 0;
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
discountDate = new Date(baseDate);
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
}
return {
dueDate,
discountDate,
discountAmount,
lines: [{ dueDate, amount: totalAmount, percent: 100 }],
};
}
/**
* Calculate due date for a specific line
*/
private calculateLineDueDate(baseDate: Date, line: PaymentTermLine): Date {
const result = new Date(baseDate);
result.setDate(result.getDate() + line.days);
// If specific day of month
if (line.dayOfMonth) {
result.setDate(line.dayOfMonth);
// If the calculated date is before base + days, move to next month
const minDate = new Date(baseDate);
minDate.setDate(minDate.getDate() + line.days);
if (result < minDate) {
result.setMonth(result.getMonth() + 1);
}
}
// If end of month
if (line.endOfMonth) {
result.setMonth(result.getMonth() + 1);
result.setDate(0); // Last day of previous month
}
return result;
}
/**
* Get all payment terms for a tenant
*/
async findAll(tenantId: string, activeOnly: boolean = false): Promise<PaymentTerm[]> {
logger.debug('Finding all payment terms', { tenantId, activeOnly });
const query = this.repository
.createQueryBuilder('pt')
.leftJoinAndSelect('pt.lines', 'lines')
.where('pt.tenant_id = :tenantId', { tenantId })
.orderBy('pt.sequence', 'ASC')
.addOrderBy('pt.name', 'ASC');
if (activeOnly) {
query.andWhere('pt.is_active = :isActive', { isActive: true });
}
return query.getMany();
}
/**
* Get a specific payment term by ID
*/
async findById(id: string, tenantId: string): Promise<PaymentTerm> {
logger.debug('Finding payment term by id', { id, tenantId });
const paymentTerm = await this.repository.findOne({
where: { id, tenantId },
relations: ['lines'],
});
if (!paymentTerm) {
throw new NotFoundError('Término de pago no encontrado');
}
return paymentTerm;
}
/**
* Get a specific payment term by code
*/
async findByCode(code: string, tenantId: string): Promise<PaymentTerm | null> {
logger.debug('Finding payment term by code', { code, tenantId });
return this.repository.findOne({
where: { code, tenantId },
relations: ['lines'],
});
}
/**
* Create a new payment term
*/
async create(
dto: CreatePaymentTermDto,
tenantId: string,
userId?: string
): Promise<PaymentTerm> {
logger.debug('Creating payment term', { dto, tenantId });
// Check for existing
const existing = await this.findByCode(dto.code, tenantId);
if (existing) {
throw new ConflictError(`Ya existe un término de pago con código ${dto.code}`);
}
// Normalize inputs (accept both snake_case and camelCase)
const dueDays = dto.due_days ?? dto.dueDays ?? 0;
const discountPercent = dto.discount_percent ?? dto.discountPercent ?? null;
const discountDays = dto.discount_days ?? dto.discountDays ?? null;
const isImmediate = dto.is_immediate ?? dto.isImmediate ?? false;
const paymentTerm = this.repository.create({
tenantId,
code: dto.code,
name: dto.name,
description: dto.description || null,
dueDays,
discountPercent,
discountDays,
isImmediate,
createdBy: userId || null,
});
const saved = await this.repository.save(paymentTerm);
// Create lines if provided
if (dto.lines && dto.lines.length > 0) {
await this.createLines(saved.id, dto.lines);
// Reload with lines
return this.findById(saved.id, tenantId);
}
logger.info('Payment term created', { id: saved.id, code: dto.code, tenantId });
return saved;
}
/**
* Create payment term lines
*/
private async createLines(
paymentTermId: string,
lines: CreatePaymentTermLineDto[]
): Promise<void> {
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const lineTypeRaw = line.line_type ?? line.lineType ?? 'balance';
const lineType = lineTypeRaw as PaymentTermLineType;
const valuePercent = line.value_percent ?? line.valuePercent ?? null;
const valueAmount = line.value_amount ?? line.valueAmount ?? null;
const dayOfMonth = line.day_of_month ?? line.dayOfMonth ?? null;
const endOfMonth = line.end_of_month ?? line.endOfMonth ?? false;
const lineEntity = this.lineRepository.create({
paymentTermId,
sequence: line.sequence ?? index + 1,
lineType,
valuePercent,
valueAmount,
days: line.days ?? 0,
dayOfMonth,
endOfMonth,
});
await this.lineRepository.save(lineEntity);
}
}
/**
* Update a payment term
*/
async update(
id: string,
dto: UpdatePaymentTermDto,
tenantId: string,
userId?: string
): Promise<PaymentTerm> {
logger.debug('Updating payment term', { id, dto, tenantId });
const existing = await this.findById(id, tenantId);
// Normalize inputs
const dueDays = dto.due_days ?? dto.dueDays;
const discountPercent = dto.discount_percent ?? dto.discountPercent;
const discountDays = dto.discount_days ?? dto.discountDays;
const isImmediate = dto.is_immediate ?? dto.isImmediate;
const isActive = dto.is_active ?? dto.isActive;
if (dto.name !== undefined) {
existing.name = dto.name;
}
if (dto.description !== undefined) {
existing.description = dto.description;
}
if (dueDays !== undefined) {
existing.dueDays = dueDays;
}
if (discountPercent !== undefined) {
existing.discountPercent = discountPercent;
}
if (discountDays !== undefined) {
existing.discountDays = discountDays;
}
if (isImmediate !== undefined) {
existing.isImmediate = isImmediate;
}
if (isActive !== undefined) {
existing.isActive = isActive;
}
existing.updatedBy = userId || null;
const updated = await this.repository.save(existing);
// Update lines if provided
if (dto.lines !== undefined) {
// Remove existing lines
await this.lineRepository.delete({ paymentTermId: id });
// Create new lines
if (dto.lines.length > 0) {
await this.createLines(id, dto.lines);
}
}
logger.info('Payment term updated', { id, tenantId });
return this.findById(id, tenantId);
}
/**
* Soft delete a payment term
*/
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
logger.debug('Deleting payment term', { id, tenantId });
const existing = await this.findById(id, tenantId);
existing.deletedAt = new Date();
existing.deletedBy = userId || null;
await this.repository.save(existing);
logger.info('Payment term deleted', { id, tenantId });
}
/**
* Get common/standard payment terms
*/
getStandardTerms(): Array<{ code: string; name: string; dueDays: number; discountPercent?: number; discountDays?: number }> {
return [
{ code: 'IMMEDIATE', name: 'Pago Inmediato', dueDays: 0 },
{ code: 'NET15', name: 'Neto 15 días', dueDays: 15 },
{ code: 'NET30', name: 'Neto 30 días', dueDays: 30 },
{ code: 'NET45', name: 'Neto 45 días', dueDays: 45 },
{ code: 'NET60', name: 'Neto 60 días', dueDays: 60 },
{ code: 'NET90', name: 'Neto 90 días', dueDays: 90 },
{ code: '2/10NET30', name: '2% 10 días, Neto 30', dueDays: 30, discountPercent: 2, discountDays: 10 },
{ code: '1/10NET30', name: '1% 10 días, Neto 30', dueDays: 30, discountPercent: 1, discountDays: 10 },
];
}
/**
* Initialize standard payment terms for a tenant
*/
async initializeForTenant(tenantId: string, userId?: string): Promise<void> {
logger.debug('Initializing payment terms for tenant', { tenantId });
const standardTerms = this.getStandardTerms();
for (const term of standardTerms) {
const existing = await this.findByCode(term.code, tenantId);
if (!existing) {
await this.create(
{
code: term.code,
name: term.name,
dueDays: term.dueDays,
discountPercent: term.discountPercent,
discountDays: term.discountDays,
isImmediate: term.dueDays === 0,
},
tenantId,
userId
);
}
}
logger.info('Payment terms initialized for tenant', { tenantId, count: standardTerms.length });
}
}
export const paymentTermsService = new PaymentTermsService();

View File

@ -1,148 +0,0 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { State } from './entities/state.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface CreateStateDto {
countryId: string;
code: string;
name: string;
timezone?: string;
isActive?: boolean;
}
export interface UpdateStateDto {
name?: string;
timezone?: string;
isActive?: boolean;
}
export interface StateFilter {
countryId?: string;
countryCode?: string;
isActive?: boolean;
}
class StatesService {
private repository: Repository<State>;
constructor() {
this.repository = AppDataSource.getRepository(State);
}
async findAll(filter: StateFilter = {}): Promise<State[]> {
logger.debug('Finding all states', { filter });
const query = this.repository
.createQueryBuilder('state')
.leftJoinAndSelect('state.country', 'country');
if (filter.countryId) {
query.andWhere('state.countryId = :countryId', { countryId: filter.countryId });
}
if (filter.countryCode) {
query.andWhere('country.code = :countryCode', { countryCode: filter.countryCode.toUpperCase() });
}
if (filter.isActive !== undefined) {
query.andWhere('state.isActive = :isActive', { isActive: filter.isActive });
}
query.orderBy('state.name', 'ASC');
return query.getMany();
}
async findById(id: string): Promise<State> {
logger.debug('Finding state by id', { id });
const state = await this.repository.findOne({
where: { id },
relations: ['country'],
});
if (!state) {
throw new NotFoundError('Estado no encontrado');
}
return state;
}
async findByCode(countryId: string, code: string): Promise<State | null> {
logger.debug('Finding state by code', { countryId, code });
return this.repository.findOne({
where: { countryId, code: code.toUpperCase() },
relations: ['country'],
});
}
async findByCountry(countryId: string): Promise<State[]> {
logger.debug('Finding states by country', { countryId });
return this.repository.find({
where: { countryId, isActive: true },
relations: ['country'],
order: { name: 'ASC' },
});
}
async findByCountryCode(countryCode: string): Promise<State[]> {
logger.debug('Finding states by country code', { countryCode });
return this.repository
.createQueryBuilder('state')
.leftJoinAndSelect('state.country', 'country')
.where('country.code = :countryCode', { countryCode: countryCode.toUpperCase() })
.andWhere('state.isActive = :isActive', { isActive: true })
.orderBy('state.name', 'ASC')
.getMany();
}
async create(dto: CreateStateDto): Promise<State> {
logger.info('Creating state', { dto });
// Check if state already exists for this country
const existing = await this.findByCode(dto.countryId, dto.code);
if (existing) {
throw new Error(`Estado con código ${dto.code} ya existe para este país`);
}
const state = this.repository.create({
...dto,
code: dto.code.toUpperCase(),
isActive: dto.isActive ?? true,
});
return this.repository.save(state);
}
async update(id: string, dto: UpdateStateDto): Promise<State> {
logger.info('Updating state', { id, dto });
const state = await this.findById(id);
Object.assign(state, dto);
return this.repository.save(state);
}
async delete(id: string): Promise<void> {
logger.info('Deleting state', { id });
const state = await this.findById(id);
await this.repository.remove(state);
}
async setActive(id: string, isActive: boolean): Promise<State> {
logger.info('Setting state active status', { id, isActive });
const state = await this.findById(id);
state.isActive = isActive;
return this.repository.save(state);
}
}
export const statesService = new StatesService();

View File

@ -157,93 +157,6 @@ class UomService {
return updated; return updated;
} }
/**
* Convert quantity from one UoM to another
* Both UoMs must be in the same category
*/
async convertQuantity(quantity: number, fromUomId: string, toUomId: string): Promise<number> {
logger.debug('Converting quantity', { quantity, fromUomId, toUomId });
// Same UoM = no conversion needed
if (fromUomId === toUomId) {
return quantity;
}
const fromUom = await this.findById(fromUomId);
const toUom = await this.findById(toUomId);
// Validate same category
if (fromUom.categoryId !== toUom.categoryId) {
throw new Error('No se pueden convertir unidades de diferentes categorías');
}
// Convert: first to reference unit, then to target unit
// quantity * fromFactor = reference quantity
// reference quantity / toFactor = target quantity
const result = (quantity * fromUom.factor) / toUom.factor;
logger.debug('Conversion result', {
quantity,
fromUom: fromUom.name,
toUom: toUom.name,
result
});
return result;
}
/**
* Get the reference UoM for a category
*/
async getReferenceUom(categoryId: string): Promise<Uom | null> {
logger.debug('Getting reference UoM', { categoryId });
return this.repository.findOne({
where: {
categoryId,
uomType: 'reference' as UomType,
active: true,
},
relations: ['category'],
});
}
/**
* Find UoM by code
*/
async findByCode(code: string): Promise<Uom | null> {
logger.debug('Finding UoM by code', { code });
return this.repository.findOne({
where: { code },
relations: ['category'],
});
}
/**
* Get all UoMs in a category with their conversion factors
*/
async getConversionTable(categoryId: string): Promise<{
referenceUom: Uom;
conversions: Array<{ uom: Uom; toReference: number; fromReference: number }>;
}> {
logger.debug('Getting conversion table', { categoryId });
const referenceUom = await this.getReferenceUom(categoryId);
if (!referenceUom) {
throw new NotFoundError('No se encontró unidad de referencia para esta categoría');
}
const uoms = await this.findAll(categoryId, true);
const conversions = uoms.map(uom => ({
uom,
toReference: uom.factor, // Multiply by this to get reference unit
fromReference: 1 / uom.factor, // Multiply by this to get this unit from reference
}));
return { referenceUom, conversions };
}
} }
export const uomService = new UomService(); export const uomService = new UomService();

View File

@ -1,309 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockLead } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { leadsService } from '../leads.service.js';
import { NotFoundError, ValidationError, ConflictError } from '../../../shared/errors/index.js';
describe('LeadsService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return leads with pagination', async () => {
const mockLeads = [
createMockLead({ id: '1', name: 'Lead 1' }),
createMockLead({ id: '2', name: 'Lead 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockLeads);
const result = await leadsService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { status: 'new' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.status = $'),
expect.arrayContaining([tenantId, 'new'])
);
});
it('should filter by stage_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { stage_id: 'stage-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.stage_id = $'),
expect.arrayContaining([tenantId, 'stage-uuid'])
);
});
it('should filter by source', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { source: 'website' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.source = $'),
expect.arrayContaining([tenantId, 'website'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await leadsService.findAll(tenantId, { search: 'John' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('l.name ILIKE'),
expect.arrayContaining([tenantId, '%John%'])
);
});
});
describe('findById', () => {
it('should return lead when found', async () => {
const mockLead = createMockLead();
mockQueryOne.mockResolvedValue(mockLead);
const result = await leadsService.findById('lead-uuid-1', tenantId);
expect(result).toEqual(mockLead);
});
it('should throw NotFoundError when lead not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
leadsService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'New Lead',
contact_name: 'Jane Doe',
email: 'jane@test.com',
};
it('should create lead successfully', async () => {
const createdLead = createMockLead({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(createdLead) // INSERT
.mockResolvedValueOnce(createdLead); // findById
const result = await leadsService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
});
describe('update', () => {
it('should update lead successfully', async () => {
const existingLead = createMockLead({ status: 'new' });
mockQueryOne.mockResolvedValue(existingLead);
mockQuery.mockResolvedValue([]);
await leadsService.update(
'lead-uuid-1',
{ name: 'Updated Lead' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.leads SET'),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.update('lead-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('moveStage', () => {
it('should move lead to new stage', async () => {
const lead = createMockLead({ status: 'new' });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1'),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.moveStage('lead-uuid-1', 'new-stage-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('convert', () => {
it('should convert lead to opportunity', async () => {
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
mockQueryOne.mockResolvedValue(lead);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // existing partner check
.mockResolvedValueOnce({ rows: [{ id: 'new-partner-uuid' }] }) // create partner
.mockResolvedValueOnce({ rows: [{ id: 'stage-uuid' }] }) // get default stage
.mockResolvedValueOnce({ rows: [{ id: 'opportunity-uuid' }] }) // create opportunity
.mockResolvedValueOnce(undefined) // update lead
.mockResolvedValueOnce(undefined); // COMMIT
const result = await leadsService.convert('lead-uuid-1', tenantId, userId);
expect(result.opportunity_id).toBe('opportunity-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when lead is already converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const lead = createMockLead({ status: 'qualified', email: 'test@example.com' });
mockQueryOne.mockResolvedValue(lead);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error'));
await expect(
leadsService.convert('lead-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('markLost', () => {
it('should mark lead as lost', async () => {
const lead = createMockLead({ status: 'qualified' });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Too expensive', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'lost'"),
expect.any(Array)
);
});
it('should throw ValidationError when lead is converted', async () => {
const convertedLead = createMockLead({ status: 'converted' });
mockQueryOne.mockResolvedValue(convertedLead);
await expect(
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when lead is already lost', async () => {
const lostLead = createMockLead({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostLead);
await expect(
leadsService.markLost('lead-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('delete', () => {
it('should delete lead without opportunity', async () => {
const lead = createMockLead({ opportunity_id: null });
mockQueryOne.mockResolvedValue(lead);
mockQuery.mockResolvedValue([]);
await leadsService.delete('lead-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.leads'),
expect.any(Array)
);
});
it('should throw ConflictError when lead has opportunity', async () => {
const lead = createMockLead({ opportunity_id: 'opportunity-uuid' });
mockQueryOne.mockResolvedValue(lead);
await expect(
leadsService.delete('lead-uuid-1', tenantId)
).rejects.toThrow(ConflictError);
});
});
});

View File

@ -1,361 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockOpportunity, createMockStage } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
const mockGetClient = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
getClient: () => mockGetClient(),
}));
// Import after mocking
import { opportunitiesService } from '../opportunities.service.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
describe('OpportunitiesService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
const mockClient = {
query: jest.fn(),
release: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetClient.mockResolvedValue(mockClient);
});
describe('findAll', () => {
it('should return opportunities with pagination', async () => {
const mockOpportunities = [
createMockOpportunity({ id: '1', name: 'Opp 1' }),
createMockOpportunity({ id: '2', name: 'Opp 2' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockOpportunities);
const result = await opportunitiesService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { status: 'open' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.status = $'),
expect.arrayContaining([tenantId, 'open'])
);
});
it('should filter by partner_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { partner_id: 'partner-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.partner_id = $'),
expect.arrayContaining([tenantId, 'partner-uuid'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await opportunitiesService.findAll(tenantId, { search: 'Test' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('o.name ILIKE'),
expect.arrayContaining([tenantId, '%Test%'])
);
});
});
describe('findById', () => {
it('should return opportunity when found', async () => {
const mockOpp = createMockOpportunity();
mockQueryOne.mockResolvedValue(mockOpp);
const result = await opportunitiesService.findById('opp-uuid-1', tenantId);
expect(result).toEqual(mockOpp);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
opportunitiesService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
name: 'New Opportunity',
partner_id: 'partner-uuid',
};
it('should create opportunity successfully', async () => {
const createdOpp = createMockOpportunity({ ...createDto });
mockQueryOne
.mockResolvedValueOnce(createdOpp) // INSERT
.mockResolvedValueOnce(createdOpp); // findById
const result = await opportunitiesService.create(createDto, tenantId, userId);
expect(result.name).toBe(createDto.name);
});
});
describe('update', () => {
it('should update opportunity successfully', async () => {
const existingOpp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(existingOpp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.update(
'opp-uuid-1',
{ name: 'Updated Opportunity' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.opportunities SET'),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.update('opp-uuid-1', { name: 'Test' }, tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('moveStage', () => {
it('should move opportunity to new stage', async () => {
const opp = createMockOpportunity({ status: 'open' });
const stage = createMockStage({ id: 'new-stage-uuid', probability: 50 });
mockQueryOne
.mockResolvedValueOnce(opp) // findById
.mockResolvedValueOnce(stage); // get stage
mockQuery.mockResolvedValue([]);
await opportunitiesService.moveStage('opp-uuid-1', 'new-stage-uuid', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('stage_id = $1'),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.moveStage('opp-uuid-1', 'stage-uuid', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw NotFoundError when stage not found', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne
.mockResolvedValueOnce(opp) // findById
.mockResolvedValueOnce(null); // stage not found
await expect(
opportunitiesService.moveStage('opp-uuid-1', 'nonexistent-stage', tenantId, userId)
).rejects.toThrow(NotFoundError);
});
});
describe('markWon', () => {
it('should mark opportunity as won', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.markWon('opp-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'won'"),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const lostOpp = createMockOpportunity({ status: 'lost' });
mockQueryOne.mockResolvedValue(lostOpp);
await expect(
opportunitiesService.markWon('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('markLost', () => {
it('should mark opportunity as lost', async () => {
const opp = createMockOpportunity({ status: 'open' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'lost'"),
expect.any(Array)
);
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.markLost('opp-uuid-1', 'reason-uuid', 'Notes', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('createQuotation', () => {
it('should create quotation from opportunity', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
mockQueryOne.mockResolvedValue(opp);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockResolvedValueOnce({ rows: [{ next_num: 1 }] }) // sequence
.mockResolvedValueOnce({ rows: [{ id: 'currency-uuid' }] }) // currency
.mockResolvedValueOnce({ rows: [{ id: 'quotation-uuid' }] }) // create quotation
.mockResolvedValueOnce(undefined) // update opportunity
.mockResolvedValueOnce(undefined); // COMMIT
const result = await opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId);
expect(result.quotation_id).toBe('quotation-uuid');
expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
});
it('should throw ValidationError when opportunity is not open', async () => {
const wonOpp = createMockOpportunity({ status: 'won' });
mockQueryOne.mockResolvedValue(wonOpp);
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when quotation already exists', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: 'existing-quotation' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should rollback on error', async () => {
const opp = createMockOpportunity({ status: 'open', quotation_id: null });
mockQueryOne.mockResolvedValue(opp);
mockClient.query
.mockResolvedValueOnce(undefined) // BEGIN
.mockRejectedValueOnce(new Error('DB Error'));
await expect(
opportunitiesService.createQuotation('opp-uuid-1', tenantId, userId)
).rejects.toThrow('DB Error');
expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('delete', () => {
it('should delete opportunity without quotation or order', async () => {
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: null });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.delete('opp-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.opportunities'),
expect.any(Array)
);
});
it('should throw ValidationError when has quotation', async () => {
const opp = createMockOpportunity({ quotation_id: 'quotation-uuid' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.delete('opp-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when has order', async () => {
const opp = createMockOpportunity({ order_id: 'order-uuid' });
mockQueryOne.mockResolvedValue(opp);
await expect(
opportunitiesService.delete('opp-uuid-1', tenantId)
).rejects.toThrow(ValidationError);
});
it('should update lead when deleting opportunity with lead', async () => {
const opp = createMockOpportunity({ quotation_id: null, order_id: null, lead_id: 'lead-uuid' });
mockQueryOne.mockResolvedValue(opp);
mockQuery.mockResolvedValue([]);
await opportunitiesService.delete('opp-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.leads SET opportunity_id = NULL'),
expect.any(Array)
);
});
});
describe('getPipeline', () => {
it('should return pipeline with stages and opportunities', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'Qualification', sequence: 1 }),
createMockStage({ id: '2', name: 'Proposal', sequence: 2 }),
];
const mockOpps = [
createMockOpportunity({ id: '1', stage_id: '1', expected_revenue: 5000 }),
createMockOpportunity({ id: '2', stage_id: '2', expected_revenue: 10000 }),
];
mockQuery
.mockResolvedValueOnce(mockStages) // stages
.mockResolvedValueOnce(mockOpps); // opportunities
const result = await opportunitiesService.getPipeline(tenantId);
expect(result.stages).toHaveLength(2);
expect(result.totals.total_opportunities).toBe(2);
});
});
});

View File

@ -1,286 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockStage, createMockLostReason } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
}));
// Import after mocking
import { stagesService } from '../stages.service.js';
import { NotFoundError, ConflictError } from '../../../shared/errors/index.js';
describe('StagesService', () => {
const tenantId = 'test-tenant-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('Lead Stages', () => {
describe('getLeadStages', () => {
it('should return active lead stages', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'New' }),
createMockStage({ id: '2', name: 'Qualified' }),
];
mockQuery.mockResolvedValue(mockStages);
const result = await stagesService.getLeadStages(tenantId);
expect(result).toHaveLength(2);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('active = TRUE'),
[tenantId]
);
});
it('should include inactive stages when requested', async () => {
mockQuery.mockResolvedValue([]);
await stagesService.getLeadStages(tenantId, true);
expect(mockQuery).toHaveBeenCalledWith(
expect.not.stringContaining('active = TRUE'),
[tenantId]
);
});
});
describe('getLeadStageById', () => {
it('should return stage when found', async () => {
const mockStage = createMockStage();
mockQueryOne.mockResolvedValue(mockStage);
const result = await stagesService.getLeadStageById('stage-uuid', tenantId);
expect(result).toEqual(mockStage);
});
it('should throw NotFoundError when not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
stagesService.getLeadStageById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('createLeadStage', () => {
it('should create lead stage successfully', async () => {
const newStage = createMockStage({ name: 'New Stage' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newStage); // INSERT
const result = await stagesService.createLeadStage({ name: 'New Stage' }, tenantId);
expect(result.name).toBe('New Stage');
});
it('should throw ConflictError when name exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
stagesService.createLeadStage({ name: 'Existing Stage' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('updateLeadStage', () => {
it('should update lead stage successfully', async () => {
const existingStage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(existingStage) // getById
.mockResolvedValueOnce(null) // unique name check
.mockResolvedValueOnce({ ...existingStage, name: 'Updated' }); // getById after update
mockQuery.mockResolvedValue([]);
const result = await stagesService.updateLeadStage('stage-uuid', { name: 'Updated' }, tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE crm.lead_stages SET'),
expect.any(Array)
);
});
it('should throw ConflictError when name exists for another stage', async () => {
const existingStage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(existingStage) // getById
.mockResolvedValueOnce({ id: 'other-uuid' }); // name exists
await expect(
stagesService.updateLeadStage('stage-uuid', { name: 'Duplicate' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('deleteLeadStage', () => {
it('should delete stage without leads', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '0' }); // in use check
mockQuery.mockResolvedValue([]);
await stagesService.deleteLeadStage('stage-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.lead_stages'),
expect.any(Array)
);
});
it('should throw ConflictError when stage has leads', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '5' }); // in use
await expect(
stagesService.deleteLeadStage('stage-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
describe('Opportunity Stages', () => {
describe('getOpportunityStages', () => {
it('should return active opportunity stages', async () => {
const mockStages = [
createMockStage({ id: '1', name: 'Qualification' }),
createMockStage({ id: '2', name: 'Proposal' }),
];
mockQuery.mockResolvedValue(mockStages);
const result = await stagesService.getOpportunityStages(tenantId);
expect(result).toHaveLength(2);
});
});
describe('createOpportunityStage', () => {
it('should create opportunity stage successfully', async () => {
const newStage = createMockStage({ name: 'New Stage' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newStage); // INSERT
const result = await stagesService.createOpportunityStage({ name: 'New Stage' }, tenantId);
expect(result.name).toBe('New Stage');
});
});
describe('deleteOpportunityStage', () => {
it('should delete stage without opportunities', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '0' }); // in use check
mockQuery.mockResolvedValue([]);
await stagesService.deleteOpportunityStage('stage-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.opportunity_stages'),
expect.any(Array)
);
});
it('should throw ConflictError when stage has opportunities', async () => {
const stage = createMockStage();
mockQueryOne
.mockResolvedValueOnce(stage) // getById
.mockResolvedValueOnce({ count: '3' }); // in use
await expect(
stagesService.deleteOpportunityStage('stage-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
describe('Lost Reasons', () => {
describe('getLostReasons', () => {
it('should return active lost reasons', async () => {
const mockReasons = [
createMockLostReason({ id: '1', name: 'Too expensive' }),
createMockLostReason({ id: '2', name: 'Competitor' }),
];
mockQuery.mockResolvedValue(mockReasons);
const result = await stagesService.getLostReasons(tenantId);
expect(result).toHaveLength(2);
});
});
describe('createLostReason', () => {
it('should create lost reason successfully', async () => {
const newReason = createMockLostReason({ name: 'New Reason' });
mockQueryOne
.mockResolvedValueOnce(null) // unique check
.mockResolvedValueOnce(newReason); // INSERT
const result = await stagesService.createLostReason({ name: 'New Reason' }, tenantId);
expect(result.name).toBe('New Reason');
});
it('should throw ConflictError when name exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
stagesService.createLostReason({ name: 'Existing' }, tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('deleteLostReason', () => {
it('should delete reason not in use', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '0' }) // leads check
.mockResolvedValueOnce({ count: '0' }); // opportunities check
mockQuery.mockResolvedValue([]);
await stagesService.deleteLostReason('reason-uuid', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM crm.lost_reasons'),
expect.any(Array)
);
});
it('should throw ConflictError when reason is in use by leads', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '2' }); // leads check
await expect(
stagesService.deleteLostReason('reason-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
it('should throw ConflictError when reason is in use by opportunities', async () => {
const reason = createMockLostReason();
mockQueryOne
.mockResolvedValueOnce(reason) // getById
.mockResolvedValueOnce({ count: '0' }) // leads check
.mockResolvedValueOnce({ count: '3' }); // opportunities check
await expect(
stagesService.deleteLostReason('reason-uuid', tenantId)
).rejects.toThrow(ConflictError);
});
});
});
});

View File

@ -1,571 +0,0 @@
import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export type ActivityType = 'call' | 'meeting' | 'email' | 'task' | 'note' | 'other';
export type ActivityStatus = 'scheduled' | 'done' | 'cancelled';
export interface Activity {
id: string;
tenant_id: string;
company_id: string;
company_name?: string;
activity_type: ActivityType;
name: string;
description?: string;
user_id?: string;
user_name?: string;
// Polymorphic relations
res_model?: string; // 'opportunity', 'lead', 'partner'
res_id?: string;
res_name?: string;
partner_id?: string;
partner_name?: string;
scheduled_date?: Date;
date_done?: Date;
duration_hours?: number;
status: ActivityStatus;
priority: number;
notes?: string;
created_at: Date;
created_by?: string;
}
export interface CreateActivityDto {
company_id: string;
activity_type: ActivityType;
name: string;
description?: string;
user_id?: string;
res_model?: string;
res_id?: string;
partner_id?: string;
scheduled_date?: string;
duration_hours?: number;
priority?: number;
notes?: string;
}
export interface UpdateActivityDto {
activity_type?: ActivityType;
name?: string;
description?: string | null;
user_id?: string | null;
partner_id?: string | null;
scheduled_date?: string | null;
duration_hours?: number | null;
priority?: number;
notes?: string | null;
}
export interface ActivityFilters {
company_id?: string;
activity_type?: ActivityType;
status?: ActivityStatus;
user_id?: string;
partner_id?: string;
res_model?: string;
res_id?: string;
date_from?: string;
date_to?: string;
search?: string;
page?: number;
limit?: number;
}
export interface ActivitySummary {
total_activities: number;
scheduled: number;
done: number;
cancelled: number;
overdue: number;
by_type: Record<ActivityType, number>;
}
// ============================================================================
// SERVICE
// ============================================================================
class ActivitiesService {
async findAll(tenantId: string, filters: ActivityFilters = {}): Promise<{ data: Activity[]; total: number }> {
const { company_id, activity_type, status, user_id, partner_id, res_model, res_id, date_from, date_to, search, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE a.tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND a.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (activity_type) {
whereClause += ` AND a.activity_type = $${paramIndex++}`;
params.push(activity_type);
}
if (status) {
whereClause += ` AND a.status = $${paramIndex++}`;
params.push(status);
}
if (user_id) {
whereClause += ` AND a.user_id = $${paramIndex++}`;
params.push(user_id);
}
if (partner_id) {
whereClause += ` AND a.partner_id = $${paramIndex++}`;
params.push(partner_id);
}
if (res_model) {
whereClause += ` AND a.res_model = $${paramIndex++}`;
params.push(res_model);
}
if (res_id) {
whereClause += ` AND a.res_id = $${paramIndex++}`;
params.push(res_id);
}
if (date_from) {
whereClause += ` AND a.scheduled_date >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND a.scheduled_date <= $${paramIndex++}`;
params.push(date_to);
}
if (search) {
whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.description ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM crm.activities a ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<Activity>(
`SELECT a.*,
c.name as company_name,
u.name as user_name,
p.name as partner_name
FROM crm.activities a
LEFT JOIN auth.companies c ON a.company_id = c.id
LEFT JOIN auth.users u ON a.user_id = u.id
LEFT JOIN core.partners p ON a.partner_id = p.id
${whereClause}
ORDER BY
CASE WHEN a.status = 'scheduled' THEN 0 ELSE 1 END,
a.scheduled_date ASC NULLS LAST,
a.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findById(id: string, tenantId: string): Promise<Activity> {
const activity = await queryOne<Activity>(
`SELECT a.*,
c.name as company_name,
u.name as user_name,
p.name as partner_name
FROM crm.activities a
LEFT JOIN auth.companies c ON a.company_id = c.id
LEFT JOIN auth.users u ON a.user_id = u.id
LEFT JOIN core.partners p ON a.partner_id = p.id
WHERE a.id = $1 AND a.tenant_id = $2`,
[id, tenantId]
);
if (!activity) {
throw new NotFoundError('Actividad no encontrada');
}
// Get resource name if linked
if (activity.res_model && activity.res_id) {
activity.res_name = await this.getResourceName(activity.res_model, activity.res_id, tenantId);
}
return activity;
}
async create(dto: CreateActivityDto, tenantId: string, userId: string): Promise<Activity> {
const activity = await queryOne<Activity>(
`INSERT INTO crm.activities (
tenant_id, company_id, activity_type, name, description,
user_id, res_model, res_id, partner_id, scheduled_date,
duration_hours, priority, notes, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`,
[
tenantId, dto.company_id, dto.activity_type, dto.name, dto.description,
dto.user_id, dto.res_model, dto.res_id, dto.partner_id, dto.scheduled_date,
dto.duration_hours, dto.priority || 1, dto.notes, userId
]
);
logger.info('Activity created', {
activityId: activity?.id,
activityType: dto.activity_type,
resModel: dto.res_model,
resId: dto.res_id,
});
// Update date_last_activity on related opportunity/lead
if (dto.res_model && dto.res_id) {
await this.updateLastActivityDate(dto.res_model, dto.res_id, tenantId);
}
return this.findById(activity!.id, tenantId);
}
async update(id: string, dto: UpdateActivityDto, tenantId: string, userId: string): Promise<Activity> {
const existing = await this.findById(id, tenantId);
if (existing.status === 'done') {
throw new ValidationError('No se pueden editar actividades completadas');
}
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.activity_type !== undefined) {
updateFields.push(`activity_type = $${paramIndex++}`);
values.push(dto.activity_type);
}
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(dto.description);
}
if (dto.user_id !== undefined) {
updateFields.push(`user_id = $${paramIndex++}`);
values.push(dto.user_id);
}
if (dto.partner_id !== undefined) {
updateFields.push(`partner_id = $${paramIndex++}`);
values.push(dto.partner_id);
}
if (dto.scheduled_date !== undefined) {
updateFields.push(`scheduled_date = $${paramIndex++}`);
values.push(dto.scheduled_date);
}
if (dto.duration_hours !== undefined) {
updateFields.push(`duration_hours = $${paramIndex++}`);
values.push(dto.duration_hours);
}
if (dto.priority !== undefined) {
updateFields.push(`priority = $${paramIndex++}`);
values.push(dto.priority);
}
if (dto.notes !== undefined) {
updateFields.push(`notes = $${paramIndex++}`);
values.push(dto.notes);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE crm.activities SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findById(id, tenantId);
}
async markDone(id: string, tenantId: string, userId: string, notes?: string): Promise<Activity> {
const activity = await this.findById(id, tenantId);
if (activity.status === 'done') {
throw new ValidationError('La actividad ya está completada');
}
if (activity.status === 'cancelled') {
throw new ValidationError('No se puede completar una actividad cancelada');
}
await query(
`UPDATE crm.activities SET
status = 'done',
date_done = CURRENT_TIMESTAMP,
notes = COALESCE($1, notes),
updated_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND tenant_id = $4`,
[notes, userId, id, tenantId]
);
// Update date_last_activity on related opportunity/lead
if (activity.res_model && activity.res_id) {
await this.updateLastActivityDate(activity.res_model, activity.res_id, tenantId);
}
logger.info('Activity marked as done', {
activityId: id,
activityType: activity.activity_type,
});
return this.findById(id, tenantId);
}
async cancel(id: string, tenantId: string, userId: string): Promise<Activity> {
const activity = await this.findById(id, tenantId);
if (activity.status === 'done') {
throw new ValidationError('No se puede cancelar una actividad completada');
}
if (activity.status === 'cancelled') {
throw new ValidationError('La actividad ya está cancelada');
}
await query(
`UPDATE crm.activities SET
status = 'cancelled',
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND tenant_id = $3`,
[userId, id, tenantId]
);
return this.findById(id, tenantId);
}
async delete(id: string, tenantId: string): Promise<void> {
const activity = await this.findById(id, tenantId);
if (activity.status === 'done') {
throw new ValidationError('No se pueden eliminar actividades completadas');
}
await query(
`DELETE FROM crm.activities WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
}
/**
* Get activities for a specific resource (opportunity, lead, partner)
*/
async getResourceActivities(
resModel: string,
resId: string,
tenantId: string,
status?: ActivityStatus
): Promise<Activity[]> {
let whereClause = 'WHERE a.res_model = $1 AND a.res_id = $2 AND a.tenant_id = $3';
const params: any[] = [resModel, resId, tenantId];
if (status) {
whereClause += ' AND a.status = $4';
params.push(status);
}
return query<Activity>(
`SELECT a.*,
u.name as user_name,
p.name as partner_name
FROM crm.activities a
LEFT JOIN auth.users u ON a.user_id = u.id
LEFT JOIN core.partners p ON a.partner_id = p.id
${whereClause}
ORDER BY a.scheduled_date ASC NULLS LAST, a.created_at DESC`,
params
);
}
/**
* Get activity summary for dashboard
*/
async getActivitySummary(
tenantId: string,
userId?: string,
dateFrom?: string,
dateTo?: string
): Promise<ActivitySummary> {
let whereClause = 'WHERE tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
if (userId) {
whereClause += ` AND user_id = $${paramIndex++}`;
params.push(userId);
}
if (dateFrom) {
whereClause += ` AND scheduled_date >= $${paramIndex++}`;
params.push(dateFrom);
}
if (dateTo) {
whereClause += ` AND scheduled_date <= $${paramIndex++}`;
params.push(dateTo);
}
const result = await queryOne<{
total: string;
scheduled: string;
done: string;
cancelled: string;
overdue: string;
}>(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'scheduled') as scheduled,
COUNT(*) FILTER (WHERE status = 'done') as done,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled,
COUNT(*) FILTER (WHERE status = 'scheduled' AND scheduled_date < CURRENT_DATE) as overdue
FROM crm.activities
${whereClause}`,
params
);
const byTypeResult = await query<{ activity_type: ActivityType; count: string }>(
`SELECT activity_type, COUNT(*) as count
FROM crm.activities
${whereClause}
GROUP BY activity_type`,
params
);
const byType: Record<ActivityType, number> = {
call: 0,
meeting: 0,
email: 0,
task: 0,
note: 0,
other: 0,
};
for (const row of byTypeResult) {
byType[row.activity_type] = parseInt(row.count, 10);
}
return {
total_activities: parseInt(result?.total || '0', 10),
scheduled: parseInt(result?.scheduled || '0', 10),
done: parseInt(result?.done || '0', 10),
cancelled: parseInt(result?.cancelled || '0', 10),
overdue: parseInt(result?.overdue || '0', 10),
by_type: byType,
};
}
/**
* Schedule a follow-up activity after completing one
*/
async scheduleFollowUp(
completedActivityId: string,
followUpDto: CreateActivityDto,
tenantId: string,
userId: string
): Promise<Activity> {
const completedActivity = await this.findById(completedActivityId, tenantId);
// Inherit resource info from completed activity if not specified
const dto = {
...followUpDto,
res_model: followUpDto.res_model || completedActivity.res_model,
res_id: followUpDto.res_id || completedActivity.res_id,
partner_id: followUpDto.partner_id || completedActivity.partner_id,
};
return this.create(dto, tenantId, userId);
}
/**
* Get overdue activities count for notifications
*/
async getOverdueCount(tenantId: string, userId?: string): Promise<number> {
let whereClause = 'WHERE tenant_id = $1 AND status = \'scheduled\' AND scheduled_date < CURRENT_DATE';
const params: any[] = [tenantId];
if (userId) {
whereClause += ' AND user_id = $2';
params.push(userId);
}
const result = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM crm.activities ${whereClause}`,
params
);
return parseInt(result?.count || '0', 10);
}
private async getResourceName(resModel: string, resId: string, tenantId: string): Promise<string> {
let tableName: string;
switch (resModel) {
case 'opportunity':
tableName = 'crm.opportunities';
break;
case 'lead':
tableName = 'crm.leads';
break;
case 'partner':
tableName = 'core.partners';
break;
default:
return '';
}
const result = await queryOne<{ name: string }>(
`SELECT name FROM ${tableName} WHERE id = $1 AND tenant_id = $2`,
[resId, tenantId]
);
return result?.name || '';
}
private async updateLastActivityDate(resModel: string, resId: string, tenantId: string): Promise<void> {
let tableName: string;
switch (resModel) {
case 'opportunity':
tableName = 'crm.opportunities';
break;
case 'lead':
tableName = 'crm.leads';
break;
default:
return;
}
await query(
`UPDATE ${tableName} SET date_last_activity = CURRENT_TIMESTAMP WHERE id = $1 AND tenant_id = $2`,
[resId, tenantId]
);
}
}
export const activitiesService = new ActivitiesService();

View File

@ -1,452 +0,0 @@
import { query, queryOne } from '../../config/database.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface ForecastPeriod {
period: string; // YYYY-MM or YYYY-QN
expected_revenue: number;
weighted_revenue: number;
opportunity_count: number;
avg_probability: number;
won_revenue?: number;
won_count?: number;
lost_revenue?: number;
lost_count?: number;
}
export interface SalesForecast {
total_pipeline: number;
weighted_pipeline: number;
expected_close_this_month: number;
expected_close_this_quarter: number;
opportunities_count: number;
avg_deal_size: number;
avg_probability: number;
periods: ForecastPeriod[];
}
export interface WinLossAnalysis {
period: string;
won_count: number;
won_revenue: number;
lost_count: number;
lost_revenue: number;
win_rate: number;
avg_won_deal_size: number;
avg_lost_deal_size: number;
}
export interface PipelineMetrics {
total_opportunities: number;
total_value: number;
by_stage: {
stage_id: string;
stage_name: string;
sequence: number;
count: number;
value: number;
weighted_value: number;
avg_probability: number;
}[];
by_user: {
user_id: string;
user_name: string;
count: number;
value: number;
weighted_value: number;
}[];
avg_days_in_stage: number;
avg_sales_cycle_days: number;
}
export interface ForecastFilters {
company_id?: string;
user_id?: string;
sales_team_id?: string;
date_from?: string;
date_to?: string;
}
// ============================================================================
// SERVICE
// ============================================================================
class ForecastingService {
/**
* Get sales forecast for the pipeline
*/
async getSalesForecast(
tenantId: string,
filters: ForecastFilters = {}
): Promise<SalesForecast> {
const { company_id, user_id, sales_team_id } = filters;
let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`;
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND o.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (user_id) {
whereClause += ` AND o.user_id = $${paramIndex++}`;
params.push(user_id);
}
if (sales_team_id) {
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
params.push(sales_team_id);
}
// Get overall metrics
const metrics = await queryOne<{
total_pipeline: string;
weighted_pipeline: string;
count: string;
avg_probability: string;
}>(
`SELECT
COALESCE(SUM(expected_revenue), 0) as total_pipeline,
COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_pipeline,
COUNT(*) as count,
COALESCE(AVG(probability), 0) as avg_probability
FROM crm.opportunities o
${whereClause}`,
params
);
// Get expected close this month
const thisMonthParams = [...params];
const thisMonth = await queryOne<{ expected: string }>(
`SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected
FROM crm.opportunities o
${whereClause}
AND date_deadline >= DATE_TRUNC('month', CURRENT_DATE)
AND date_deadline < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'`,
thisMonthParams
);
// Get expected close this quarter
const thisQuarterParams = [...params];
const thisQuarter = await queryOne<{ expected: string }>(
`SELECT COALESCE(SUM(expected_revenue * probability / 100), 0) as expected
FROM crm.opportunities o
${whereClause}
AND date_deadline >= DATE_TRUNC('quarter', CURRENT_DATE)
AND date_deadline < DATE_TRUNC('quarter', CURRENT_DATE) + INTERVAL '3 months'`,
thisQuarterParams
);
// Get periods (next 6 months)
const periods = await query<ForecastPeriod>(
`SELECT
TO_CHAR(DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month')), 'YYYY-MM') as period,
COALESCE(SUM(expected_revenue), 0) as expected_revenue,
COALESCE(SUM(expected_revenue * probability / 100), 0) as weighted_revenue,
COUNT(*) as opportunity_count,
COALESCE(AVG(probability), 0) as avg_probability
FROM crm.opportunities o
${whereClause}
AND (date_deadline IS NULL OR date_deadline >= CURRENT_DATE)
AND (date_deadline IS NULL OR date_deadline < CURRENT_DATE + INTERVAL '6 months')
GROUP BY DATE_TRUNC('month', COALESCE(date_deadline, CURRENT_DATE + INTERVAL '1 month'))
ORDER BY period`,
params
);
const totalPipeline = parseFloat(metrics?.total_pipeline || '0');
const count = parseInt(metrics?.count || '0', 10);
return {
total_pipeline: totalPipeline,
weighted_pipeline: parseFloat(metrics?.weighted_pipeline || '0'),
expected_close_this_month: parseFloat(thisMonth?.expected || '0'),
expected_close_this_quarter: parseFloat(thisQuarter?.expected || '0'),
opportunities_count: count,
avg_deal_size: count > 0 ? totalPipeline / count : 0,
avg_probability: parseFloat(metrics?.avg_probability || '0'),
periods,
};
}
/**
* Get win/loss analysis for reporting
*/
async getWinLossAnalysis(
tenantId: string,
filters: ForecastFilters = {},
periodType: 'month' | 'quarter' | 'year' = 'month'
): Promise<WinLossAnalysis[]> {
const { company_id, user_id, sales_team_id, date_from, date_to } = filters;
let whereClause = `WHERE o.tenant_id = $1 AND o.status IN ('won', 'lost')`;
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND o.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (user_id) {
whereClause += ` AND o.user_id = $${paramIndex++}`;
params.push(user_id);
}
if (sales_team_id) {
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
params.push(sales_team_id);
}
if (date_from) {
whereClause += ` AND o.date_closed >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND o.date_closed <= $${paramIndex++}`;
params.push(date_to);
}
const periodTrunc = periodType === 'year' ? 'year' : periodType === 'quarter' ? 'quarter' : 'month';
const periodFormat = periodType === 'year' ? 'YYYY' : periodType === 'quarter' ? 'YYYY-"Q"Q' : 'YYYY-MM';
return query<WinLossAnalysis>(
`SELECT
TO_CHAR(DATE_TRUNC('${periodTrunc}', date_closed), '${periodFormat}') as period,
COUNT(*) FILTER (WHERE status = 'won') as won_count,
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue,
COUNT(*) FILTER (WHERE status = 'lost') as lost_count,
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) as lost_revenue,
CASE
WHEN COUNT(*) > 0
THEN ROUND(COUNT(*) FILTER (WHERE status = 'won')::numeric / COUNT(*) * 100, 2)
ELSE 0
END as win_rate,
CASE
WHEN COUNT(*) FILTER (WHERE status = 'won') > 0
THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) / COUNT(*) FILTER (WHERE status = 'won')
ELSE 0
END as avg_won_deal_size,
CASE
WHEN COUNT(*) FILTER (WHERE status = 'lost') > 0
THEN COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'lost'), 0) / COUNT(*) FILTER (WHERE status = 'lost')
ELSE 0
END as avg_lost_deal_size
FROM crm.opportunities o
${whereClause}
GROUP BY DATE_TRUNC('${periodTrunc}', date_closed)
ORDER BY period DESC`,
params
);
}
/**
* Get pipeline metrics for dashboard
*/
async getPipelineMetrics(
tenantId: string,
filters: ForecastFilters = {}
): Promise<PipelineMetrics> {
const { company_id, user_id, sales_team_id } = filters;
let whereClause = `WHERE o.tenant_id = $1 AND o.status = 'open'`;
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND o.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (user_id) {
whereClause += ` AND o.user_id = $${paramIndex++}`;
params.push(user_id);
}
if (sales_team_id) {
whereClause += ` AND o.sales_team_id = $${paramIndex++}`;
params.push(sales_team_id);
}
// Get totals
const totals = await queryOne<{ count: string; total: string }>(
`SELECT COUNT(*) as count, COALESCE(SUM(expected_revenue), 0) as total
FROM crm.opportunities o ${whereClause}`,
params
);
// Get by stage
const byStage = await query<{
stage_id: string;
stage_name: string;
sequence: number;
count: string;
value: string;
weighted_value: string;
avg_probability: string;
}>(
`SELECT
s.id as stage_id,
s.name as stage_name,
s.sequence,
COUNT(o.id) as count,
COALESCE(SUM(o.expected_revenue), 0) as value,
COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value,
COALESCE(AVG(o.probability), 0) as avg_probability
FROM crm.stages s
LEFT JOIN crm.opportunities o ON o.stage_id = s.id AND o.status = 'open' AND o.tenant_id = $1
WHERE s.tenant_id = $1 AND s.active = true
GROUP BY s.id, s.name, s.sequence
ORDER BY s.sequence`,
[tenantId]
);
// Get by user
const byUser = await query<{
user_id: string;
user_name: string;
count: string;
value: string;
weighted_value: string;
}>(
`SELECT
u.id as user_id,
u.name as user_name,
COUNT(o.id) as count,
COALESCE(SUM(o.expected_revenue), 0) as value,
COALESCE(SUM(o.expected_revenue * o.probability / 100), 0) as weighted_value
FROM crm.opportunities o
JOIN auth.users u ON o.user_id = u.id
${whereClause}
GROUP BY u.id, u.name
ORDER BY weighted_value DESC`,
params
);
// Get average sales cycle
const cycleStats = await queryOne<{ avg_days: string }>(
`SELECT AVG(EXTRACT(EPOCH FROM (date_closed - created_at)) / 86400) as avg_days
FROM crm.opportunities o
WHERE o.tenant_id = $1 AND o.status = 'won' AND date_closed IS NOT NULL`,
[tenantId]
);
return {
total_opportunities: parseInt(totals?.count || '0', 10),
total_value: parseFloat(totals?.total || '0'),
by_stage: byStage.map(s => ({
stage_id: s.stage_id,
stage_name: s.stage_name,
sequence: s.sequence,
count: parseInt(s.count, 10),
value: parseFloat(s.value),
weighted_value: parseFloat(s.weighted_value),
avg_probability: parseFloat(s.avg_probability),
})),
by_user: byUser.map(u => ({
user_id: u.user_id,
user_name: u.user_name,
count: parseInt(u.count, 10),
value: parseFloat(u.value),
weighted_value: parseFloat(u.weighted_value),
})),
avg_days_in_stage: 0, // Would need stage history tracking
avg_sales_cycle_days: parseFloat(cycleStats?.avg_days || '0'),
};
}
/**
* Get user performance metrics
*/
async getUserPerformance(
tenantId: string,
userId: string,
dateFrom?: string,
dateTo?: string
): Promise<{
open_opportunities: number;
pipeline_value: number;
won_deals: number;
won_revenue: number;
lost_deals: number;
win_rate: number;
activities_done: number;
avg_deal_size: number;
}> {
let whereClause = `WHERE o.tenant_id = $1 AND o.user_id = $2`;
const params: any[] = [tenantId, userId];
let paramIndex = 3;
if (dateFrom) {
whereClause += ` AND o.created_at >= $${paramIndex++}`;
params.push(dateFrom);
}
if (dateTo) {
whereClause += ` AND o.created_at <= $${paramIndex++}`;
params.push(dateTo);
}
const metrics = await queryOne<{
open_count: string;
pipeline: string;
won_count: string;
won_revenue: string;
lost_count: string;
}>(
`SELECT
COUNT(*) FILTER (WHERE status = 'open') as open_count,
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'open'), 0) as pipeline,
COUNT(*) FILTER (WHERE status = 'won') as won_count,
COALESCE(SUM(expected_revenue) FILTER (WHERE status = 'won'), 0) as won_revenue,
COUNT(*) FILTER (WHERE status = 'lost') as lost_count
FROM crm.opportunities o
${whereClause}`,
params
);
// Get activities count
let activityWhere = `WHERE tenant_id = $1 AND user_id = $2 AND status = 'done'`;
const activityParams: any[] = [tenantId, userId];
let actParamIndex = 3;
if (dateFrom) {
activityWhere += ` AND date_done >= $${actParamIndex++}`;
activityParams.push(dateFrom);
}
if (dateTo) {
activityWhere += ` AND date_done <= $${actParamIndex++}`;
activityParams.push(dateTo);
}
const activityCount = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM crm.activities ${activityWhere}`,
activityParams
);
const wonCount = parseInt(metrics?.won_count || '0', 10);
const lostCount = parseInt(metrics?.lost_count || '0', 10);
const wonRevenue = parseFloat(metrics?.won_revenue || '0');
const totalDeals = wonCount + lostCount;
return {
open_opportunities: parseInt(metrics?.open_count || '0', 10),
pipeline_value: parseFloat(metrics?.pipeline || '0'),
won_deals: wonCount,
won_revenue: wonRevenue,
lost_deals: lostCount,
win_rate: totalDeals > 0 ? (wonCount / totalDeals) * 100 : 0,
activities_done: parseInt(activityCount?.count || '0', 10),
avg_deal_size: wonCount > 0 ? wonRevenue / wonCount : 0,
};
}
}
export const forecastingService = new ForecastingService();

View File

@ -1,7 +1,5 @@
export * from './leads.service.js'; export * from './leads.service.js';
export * from './opportunities.service.js'; export * from './opportunities.service.js';
export * from './stages.service.js'; export * from './stages.service.js';
export * from './activities.service.js';
export * from './forecasting.service.js';
export * from './crm.controller.js'; export * from './crm.controller.js';
export { default as crmRoutes } from './crm.routes.js'; export { default as crmRoutes } from './crm.routes.js';

View File

@ -1,53 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Flag } from './flag.entity.js';
/**
* FlagEvaluation Entity
* Maps to flags.flag_evaluations DDL table
* Historial de evaluaciones de feature flags para analytics
* Propagated from template-saas HU-REFACT-005
*/
@Entity({ schema: 'flags', name: 'flag_evaluations' })
@Index('idx_flag_evaluations_flag', ['flagId'])
@Index('idx_flag_evaluations_tenant', ['tenantId'])
@Index('idx_flag_evaluations_date', ['evaluatedAt'])
export class FlagEvaluation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'flag_id' })
flagId: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'user_id' })
userId: string | null;
@Column({ type: 'boolean', nullable: false })
result: boolean;
@Column({ type: 'varchar', length: 100, nullable: true })
variant: string | null;
@Column({ type: 'jsonb', default: {}, name: 'evaluation_context' })
evaluationContext: Record<string, any>;
@Column({ type: 'varchar', length: 100, nullable: true, name: 'evaluation_reason' })
evaluationReason: string | null;
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP', name: 'evaluated_at' })
evaluatedAt: Date;
// Relaciones
@ManyToOne(() => Flag, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'flag_id' })
flag: Flag;
}

View File

@ -1,3 +1,2 @@
export { Flag } from './flag.entity'; export { Flag } from './flag.entity';
export { TenantOverride } from './tenant-override.entity'; export { TenantOverride } from './tenant-override.entity';
export { FlagEvaluation } from './flag-evaluation.entity';

View File

@ -2,7 +2,7 @@ import { Router } from 'express';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { FeatureFlagsService } from './services'; import { FeatureFlagsService } from './services';
import { FeatureFlagsController } from './controllers'; import { FeatureFlagsController } from './controllers';
import { Flag, TenantOverride, FlagEvaluation } from './entities'; import { Flag, TenantOverride } from './entities';
export interface FeatureFlagsModuleOptions { export interface FeatureFlagsModuleOptions {
dataSource: DataSource; dataSource: DataSource;
@ -39,6 +39,6 @@ export class FeatureFlagsModule {
} }
static getEntities(): Function[] { static getEntities(): Function[] {
return [Flag, TenantOverride, FlagEvaluation]; return [Flag, TenantOverride];
} }
} }

View File

@ -1,272 +0,0 @@
/**
* @fileoverview Unit tests for AccountsService
* Tests cover CRUD operations, validation, and error handling
*/
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Account } from '../entities/account.entity';
import { AccountType } from '../entities/account-type.entity';
// Mock the AppDataSource before importing the service
jest.mock('../../../config/typeorm.js', () => ({
AppDataSource: {
getRepository: jest.fn(),
},
}));
// Mock logger
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
// Import after mocking
import { AppDataSource } from '../../../config/typeorm.js';
describe('AccountsService', () => {
let mockAccountRepository: Partial<Repository<Account>>;
let mockAccountTypeRepository: Partial<Repository<AccountType>>;
let mockQueryBuilder: Partial<SelectQueryBuilder<Account>>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockCompanyId = '550e8400-e29b-41d4-a716-446655440002';
const mockAccountTypeId = '550e8400-e29b-41d4-a716-446655440003';
const mockAccountType: Partial<AccountType> = {
id: mockAccountTypeId,
code: 'ASSET',
name: 'Assets',
description: 'Asset accounts',
};
const mockAccount: Partial<Account> = {
id: '550e8400-e29b-41d4-a716-446655440010',
tenantId: mockTenantId,
companyId: mockCompanyId,
code: '1000',
name: 'Cash and Bank',
accountTypeId: mockAccountTypeId,
parentId: null,
currencyId: null,
isReconcilable: true,
isDeprecated: false,
notes: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
beforeEach(() => {
jest.clearAllMocks();
// Setup mock query builder
mockQueryBuilder = {
leftJoin: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockAccount], 1]),
getMany: jest.fn().mockResolvedValue([mockAccount]),
getOne: jest.fn().mockResolvedValue(mockAccount),
getCount: jest.fn().mockResolvedValue(1),
};
// Setup mock repositories
mockAccountRepository = {
create: jest.fn().mockReturnValue(mockAccount),
save: jest.fn().mockResolvedValue(mockAccount),
findOne: jest.fn().mockResolvedValue(mockAccount),
find: jest.fn().mockResolvedValue([mockAccount]),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};
mockAccountTypeRepository = {
find: jest.fn().mockResolvedValue([mockAccountType]),
findOne: jest.fn().mockResolvedValue(mockAccountType),
};
// Configure AppDataSource mock
(AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => {
if (entity === Account || entity.name === 'Account') {
return mockAccountRepository;
}
if (entity === AccountType || entity.name === 'AccountType') {
return mockAccountTypeRepository;
}
return {};
});
});
describe('AccountTypes Operations', () => {
it('should return all account types', async () => {
// Import dynamically to get fresh instance with mocks
const { accountsService } = await import('../accounts.service.js');
const result = await accountsService.findAllAccountTypes();
expect(mockAccountTypeRepository.find).toHaveBeenCalledWith({
order: { code: 'ASC' },
});
expect(result).toEqual([mockAccountType]);
});
it('should return account type by ID', async () => {
const { accountsService } = await import('../accounts.service.js');
const result = await accountsService.findAccountTypeById(mockAccountTypeId);
expect(mockAccountTypeRepository.findOne).toHaveBeenCalledWith({
where: { id: mockAccountTypeId },
});
expect(result).toEqual(mockAccountType);
});
it('should throw NotFoundError when account type not found', async () => {
mockAccountTypeRepository.findOne = jest.fn().mockResolvedValue(null);
const { accountsService } = await import('../accounts.service.js');
await expect(
accountsService.findAccountTypeById('non-existent-id')
).rejects.toThrow('Tipo de cuenta no encontrado');
});
});
describe('Account CRUD Operations', () => {
it('should find all accounts with filters', async () => {
const { accountsService } = await import('../accounts.service.js');
const result = await accountsService.findAll(mockTenantId, {
companyId: mockCompanyId,
page: 1,
limit: 50,
});
expect(mockAccountRepository.createQueryBuilder).toHaveBeenCalled();
expect(result.data).toBeDefined();
expect(result.total).toBe(1);
});
it('should create a new account', async () => {
const createDto = {
companyId: mockCompanyId,
code: '1100',
name: 'Bank Account',
accountTypeId: mockAccountTypeId,
isReconcilable: true,
};
const { accountsService } = await import('../accounts.service.js');
// Service signature: create(dto, tenantId, userId)
const result = await accountsService.create(createDto, mockTenantId, 'mock-user-id');
expect(mockAccountRepository.create).toHaveBeenCalled();
expect(mockAccountRepository.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should find account by ID', async () => {
const { accountsService } = await import('../accounts.service.js');
// Service signature: findById(id, tenantId)
const result = await accountsService.findById(
mockAccount.id as string,
mockTenantId
);
expect(mockAccountRepository.createQueryBuilder).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should throw NotFoundError when account not found', async () => {
mockQueryBuilder.getOne = jest.fn().mockResolvedValue(null);
const { accountsService } = await import('../accounts.service.js');
await expect(
accountsService.findById('non-existent-id', mockTenantId)
).rejects.toThrow();
});
it('should update an account', async () => {
const updateDto = {
name: 'Updated Bank Account',
};
const { accountsService } = await import('../accounts.service.js');
// Service signature: update(id, dto, tenantId, userId)
const result = await accountsService.update(
mockAccount.id as string,
updateDto,
mockTenantId,
'mock-user-id'
);
expect(mockAccountRepository.createQueryBuilder).toHaveBeenCalled();
expect(mockAccountRepository.save).toHaveBeenCalled();
});
it('should soft delete an account', async () => {
const { accountsService } = await import('../accounts.service.js');
// Service signature: delete(id, tenantId, userId)
await accountsService.delete(mockAccount.id as string, mockTenantId, 'mock-user-id');
// Service uses .update() for soft delete, not .softDelete()
expect(mockAccountRepository.update).toHaveBeenCalled();
});
});
describe('Validation', () => {
it('should validate duplicate account code', async () => {
// Simulate existing account with same code
mockAccountRepository.findOne = jest.fn()
.mockResolvedValueOnce(null) // First call for verification
.mockResolvedValueOnce(mockAccount); // Second call finds duplicate
const createDto = {
companyId: mockCompanyId,
code: '1000', // Duplicate code
name: 'Another Account',
accountTypeId: mockAccountTypeId,
};
const { accountsService } = await import('../accounts.service.js');
// This should handle duplicate validation
// Exact behavior depends on service implementation
expect(mockAccountRepository.findOne).toBeDefined();
});
});
// TODO: Method removed, update test
// describe('Chart of Accounts', () => {
// it('should get hierarchical chart of accounts', async () => {
// const mockHierarchicalAccounts = [
// { ...mockAccount, children: [] },
// ];
//
// mockAccountRepository.find = jest.fn().mockResolvedValue([mockAccount]);
//
// const { accountsService } = await import('../accounts.service.js');
//
// const result = await accountsService.getChartOfAccounts(
// mockTenantId,
// mockCompanyId
// );
//
// expect(mockAccountRepository.find).toHaveBeenCalled();
// expect(result).toBeDefined();
// });
// });
});

View File

@ -1,145 +0,0 @@
/**
* DTO para crear un extracto bancario con sus lineas
*/
export interface CreateBankStatementLineDto {
/** Fecha de la transaccion (YYYY-MM-DD) */
transaction_date: string;
/** Fecha valor opcional (YYYY-MM-DD) */
value_date?: string;
/** Descripcion del movimiento */
description?: string;
/** Referencia del movimiento (numero de cheque, transferencia, etc.) */
reference?: string;
/** Monto del movimiento (positivo=deposito, negativo=retiro) */
amount: number;
/** ID del partner si se conoce */
partner_id?: string;
/** Notas adicionales */
notes?: string;
}
/**
* DTO para crear un extracto bancario completo
*/
export interface CreateBankStatementDto {
/** ID de la compania */
company_id?: string;
/** ID de la cuenta bancaria (cuenta contable tipo banco) */
bank_account_id?: string;
/** Fecha del extracto (YYYY-MM-DD) */
statement_date: string;
/** Saldo de apertura */
opening_balance: number;
/** Saldo de cierre */
closing_balance: number;
/** Lineas del extracto */
lines: CreateBankStatementLineDto[];
}
/**
* DTO para actualizar un extracto bancario
*/
export interface UpdateBankStatementDto {
/** ID de la cuenta bancaria */
bank_account_id?: string;
/** Fecha del extracto */
statement_date?: string;
/** Saldo de apertura */
opening_balance?: number;
/** Saldo de cierre */
closing_balance?: number;
}
/**
* DTO para agregar lineas a un extracto existente
*/
export interface AddBankStatementLinesDto {
/** Lineas a agregar */
lines: CreateBankStatementLineDto[];
}
/**
* Filtros para buscar extractos bancarios
*/
export interface BankStatementFilters {
/** ID de la compania */
company_id?: string;
/** ID de la cuenta bancaria */
bank_account_id?: string;
/** Estado del extracto */
status?: 'draft' | 'reconciling' | 'reconciled';
/** Fecha desde (YYYY-MM-DD) */
date_from?: string;
/** Fecha hasta (YYYY-MM-DD) */
date_to?: string;
/** Pagina actual */
page?: number;
/** Limite de resultados */
limit?: number;
}
/**
* Respuesta con extracto y sus lineas
*/
export interface BankStatementWithLines {
id: string;
tenant_id: string;
company_id: string | null;
bank_account_id: string | null;
bank_account_name?: string;
statement_date: Date;
opening_balance: number;
closing_balance: number;
calculated_balance?: number;
status: 'draft' | 'reconciling' | 'reconciled';
imported_at: Date | null;
imported_by: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
created_at: Date;
reconciliation_progress?: number;
lines: BankStatementLineResponse[];
}
/**
* Respuesta de linea de extracto
*/
export interface BankStatementLineResponse {
id: string;
statement_id: string;
transaction_date: Date;
value_date: Date | null;
description: string | null;
reference: string | null;
amount: number;
is_reconciled: boolean;
reconciled_entry_id: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
partner_id: string | null;
partner_name?: string;
notes: string | null;
created_at: Date;
/** Posibles matches encontrados por auto-reconcile */
suggested_matches?: SuggestedMatch[];
}
/**
* Match sugerido para una linea
*/
export interface SuggestedMatch {
/** ID de la linea de asiento */
entry_line_id: string;
/** ID del asiento */
entry_id: string;
/** Referencia del asiento */
entry_ref: string | null;
/** Fecha del asiento */
entry_date: Date;
/** Monto de la linea */
amount: number;
/** Tipo de match */
match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner';
/** Confianza del match (0-100) */
confidence: number;
}

View File

@ -1,6 +0,0 @@
/**
* DTOs para el modulo de conciliacion bancaria
*/
export * from './create-bank-statement.dto.js';
export * from './reconcile-line.dto.js';

View File

@ -1,171 +0,0 @@
/**
* DTO para conciliar una linea de extracto con una linea de asiento
*/
export interface ReconcileLineDto {
/** ID de la linea de asiento contable a conciliar */
entry_line_id: string;
}
/**
* DTO para conciliar multiples lineas en batch
*/
export interface BatchReconcileDto {
/** Array de pares linea-extracto con linea-asiento */
reconciliations: {
/** ID de la linea de extracto */
statement_line_id: string;
/** ID de la linea de asiento */
entry_line_id: string;
}[];
}
/**
* DTO para crear un asiento y conciliar automaticamente
* Util cuando no existe asiento previo
*/
export interface CreateAndReconcileDto {
/** ID de la cuenta contable destino */
account_id: string;
/** ID del diario a usar */
journal_id: string;
/** ID del partner (opcional) */
partner_id?: string;
/** Referencia para el asiento */
ref?: string;
/** Notas adicionales */
notes?: string;
}
/**
* Resultado de operacion de conciliacion
*/
export interface ReconcileResult {
/** Exito de la operacion */
success: boolean;
/** ID de la linea de extracto */
statement_line_id: string;
/** ID de la linea de asiento conciliada */
entry_line_id: string | null;
/** Mensaje de error si fallo */
error?: string;
}
/**
* Resultado de auto-reconciliacion
*/
export interface AutoReconcileResult {
/** Total de lineas procesadas */
total_lines: number;
/** Lineas conciliadas automaticamente */
reconciled_count: number;
/** Lineas que no pudieron conciliarse */
unreconciled_count: number;
/** Detalle de conciliaciones realizadas */
reconciled_lines: {
statement_line_id: string;
entry_line_id: string;
match_type: string;
confidence: number;
}[];
/** Lineas con sugerencias pero sin match automatico */
lines_with_suggestions: {
statement_line_id: string;
suggestions: number;
}[];
}
/**
* DTO para buscar lineas de asiento candidatas a conciliar
*/
export interface FindMatchCandidatesDto {
/** Monto a buscar */
amount: number;
/** Fecha aproximada */
date?: string;
/** Referencia a buscar */
reference?: string;
/** ID del partner */
partner_id?: string;
/** Tolerancia de monto (porcentaje, ej: 0.01 = 1%) */
amount_tolerance?: number;
/** Tolerancia de dias */
date_tolerance_days?: number;
/** Limite de resultados */
limit?: number;
}
/**
* Respuesta de busqueda de candidatos
*/
export interface MatchCandidate {
/** ID de la linea de asiento */
id: string;
/** ID del asiento */
entry_id: string;
/** Nombre/numero del asiento */
entry_name: string;
/** Referencia del asiento */
entry_ref: string | null;
/** Fecha del asiento */
entry_date: Date;
/** ID de la cuenta */
account_id: string;
/** Codigo de la cuenta */
account_code: string;
/** Nombre de la cuenta */
account_name: string;
/** Monto al debe */
debit: number;
/** Monto al haber */
credit: number;
/** Monto neto (debit - credit) */
net_amount: number;
/** Descripcion de la linea */
description: string | null;
/** ID del partner */
partner_id: string | null;
/** Nombre del partner */
partner_name: string | null;
/** Tipo de match */
match_type: 'exact_amount' | 'amount_date' | 'reference' | 'partner' | 'rule';
/** Confianza del match */
confidence: number;
}
/**
* DTO para crear/actualizar regla de conciliacion
*/
export interface CreateReconciliationRuleDto {
/** Nombre de la regla */
name: string;
/** ID de la compania (opcional) */
company_id?: string;
/** Tipo de match */
match_type: 'exact_amount' | 'reference_contains' | 'partner_name';
/** Valor a matchear */
match_value: string;
/** Cuenta destino para auto-crear asiento */
auto_account_id?: string;
/** Prioridad (mayor = primero) */
priority?: number;
/** Activa o no */
is_active?: boolean;
}
/**
* DTO para actualizar regla de conciliacion
*/
export interface UpdateReconciliationRuleDto {
/** Nombre de la regla */
name?: string;
/** Tipo de match */
match_type?: 'exact_amount' | 'reference_contains' | 'partner_name';
/** Valor a matchear */
match_value?: string;
/** Cuenta destino */
auto_account_id?: string | null;
/** Prioridad */
priority?: number;
/** Activa o no */
is_active?: boolean;
}

View File

@ -1,75 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
/**
* Account Mapping Entity
*
* Maps document types and operations to GL accounts.
* Used by GL Posting Service to automatically create journal entries.
*
* Example mappings:
* - Customer Invoice -> AR Account (debit), Sales Revenue (credit)
* - Supplier Invoice -> AP Account (credit), Expense Account (debit)
* - Payment Received -> Cash Account (debit), AR Account (credit)
*/
export enum AccountMappingType {
CUSTOMER_INVOICE = 'customer_invoice',
SUPPLIER_INVOICE = 'supplier_invoice',
CUSTOMER_PAYMENT = 'customer_payment',
SUPPLIER_PAYMENT = 'supplier_payment',
SALES_REVENUE = 'sales_revenue',
PURCHASE_EXPENSE = 'purchase_expense',
TAX_PAYABLE = 'tax_payable',
TAX_RECEIVABLE = 'tax_receivable',
INVENTORY_ASSET = 'inventory_asset',
COST_OF_GOODS_SOLD = 'cost_of_goods_sold',
}
@Entity({ name: 'account_mappings', schema: 'financial' })
@Index('idx_account_mappings_tenant_id', ['tenantId'])
@Index('idx_account_mappings_company_id', ['companyId'])
@Index('idx_account_mappings_type', ['mappingType'])
@Index('idx_account_mappings_unique', ['tenantId', 'companyId', 'mappingType'], { unique: true })
export class AccountMapping {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'company_id', type: 'uuid' })
companyId: string;
@Column({ name: 'mapping_type', type: 'varchar', length: 50 })
mappingType: AccountMappingType | string;
@Column({ name: 'account_id', type: 'uuid' })
accountId: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string | null;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string | null;
}

View File

@ -1,93 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Account } from './account.entity.js';
/**
* Tipo de regla de match para conciliacion automatica
*/
export type ReconciliationMatchType = 'exact_amount' | 'reference_contains' | 'partner_name';
/**
* Entity: BankReconciliationRule
* Reglas para conciliacion automatica de movimientos bancarios
* Schema: financial
* Table: bank_reconciliation_rules
*/
@Entity({ schema: 'financial', name: 'bank_reconciliation_rules' })
@Index('idx_bank_reconciliation_rules_tenant_id', ['tenantId'])
@Index('idx_bank_reconciliation_rules_company_id', ['companyId'])
@Index('idx_bank_reconciliation_rules_is_active', ['isActive'])
@Index('idx_bank_reconciliation_rules_match_type', ['matchType'])
@Index('idx_bank_reconciliation_rules_priority', ['priority'])
export class BankReconciliationRule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({
type: 'varchar',
length: 50,
nullable: false,
name: 'match_type',
})
matchType: ReconciliationMatchType;
@Column({ type: 'varchar', length: 255, nullable: false, name: 'match_value' })
matchValue: string;
@Column({ type: 'uuid', nullable: true, name: 'auto_account_id' })
autoAccountId: string | null;
@Column({
type: 'boolean',
default: true,
nullable: false,
name: 'is_active',
})
isActive: boolean;
@Column({
type: 'integer',
default: 0,
nullable: false,
})
priority: number;
// Relations
@ManyToOne(() => Account, { nullable: true })
@JoinColumn({ name: 'auto_account_id' })
autoAccount: Account | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp with time zone',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -1,93 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { BankStatement } from './bank-statement.entity.js';
import { JournalEntryLine } from './journal-entry-line.entity.js';
/**
* Entity: BankStatementLine
* Lineas/movimientos del extracto bancario
* Schema: financial
* Table: bank_statement_lines
*/
@Entity({ schema: 'financial', name: 'bank_statement_lines' })
@Index('idx_bank_statement_lines_statement_id', ['statementId'])
@Index('idx_bank_statement_lines_tenant_id', ['tenantId'])
@Index('idx_bank_statement_lines_transaction_date', ['transactionDate'])
@Index('idx_bank_statement_lines_is_reconciled', ['isReconciled'])
@Index('idx_bank_statement_lines_reconciled_entry_id', ['reconciledEntryId'])
@Index('idx_bank_statement_lines_partner_id', ['partnerId'])
export class BankStatementLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'statement_id' })
statementId: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'date', nullable: false, name: 'transaction_date' })
transactionDate: Date;
@Column({ type: 'date', nullable: true, name: 'value_date' })
valueDate: Date | null;
@Column({ type: 'varchar', length: 500, nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
reference: string | null;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
nullable: false,
})
amount: number;
@Column({
type: 'boolean',
default: false,
nullable: false,
name: 'is_reconciled',
})
isReconciled: boolean;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_entry_id' })
reconciledEntryId: string | null;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' })
reconciledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_by' })
reconciledBy: string | null;
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
partnerId: string | null;
@Column({ type: 'text', nullable: true })
notes: string | null;
// Relations
@ManyToOne(() => BankStatement, (statement) => statement.lines, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'statement_id' })
statement: BankStatement;
@ManyToOne(() => JournalEntryLine, { nullable: true })
@JoinColumn({ name: 'reconciled_entry_id' })
reconciledEntry: JournalEntryLine | null;
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
}

View File

@ -1,111 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Account } from './account.entity.js';
/**
* Estado del extracto bancario
*/
export type BankStatementStatus = 'draft' | 'reconciling' | 'reconciled';
/**
* Entity: BankStatement
* Extractos bancarios importados para conciliacion
* Schema: financial
* Table: bank_statements
*/
@Entity({ schema: 'financial', name: 'bank_statements' })
@Index('idx_bank_statements_tenant_id', ['tenantId'])
@Index('idx_bank_statements_company_id', ['companyId'])
@Index('idx_bank_statements_bank_account_id', ['bankAccountId'])
@Index('idx_bank_statements_statement_date', ['statementDate'])
@Index('idx_bank_statements_status', ['status'])
export class BankStatement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
tenantId: string;
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
companyId: string | null;
@Column({ type: 'uuid', nullable: true, name: 'bank_account_id' })
bankAccountId: string | null;
@Column({ type: 'date', nullable: false, name: 'statement_date' })
statementDate: Date;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
default: 0,
nullable: false,
name: 'opening_balance',
})
openingBalance: number;
@Column({
type: 'decimal',
precision: 15,
scale: 2,
default: 0,
nullable: false,
name: 'closing_balance',
})
closingBalance: number;
@Column({
type: 'varchar',
length: 20,
default: 'draft',
nullable: false,
})
status: BankStatementStatus;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'imported_at' })
importedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'imported_by' })
importedBy: string | null;
@Column({ type: 'timestamp with time zone', nullable: true, name: 'reconciled_at' })
reconciledAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'reconciled_by' })
reconciledBy: string | null;
// Relations
@ManyToOne(() => Account)
@JoinColumn({ name: 'bank_account_id' })
bankAccount: Account | null;
@OneToMany('BankStatementLine', 'statement')
lines: import('./bank-statement-line.entity.js').BankStatementLine[];
// Audit fields
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date;
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
createdBy: string | null;
@UpdateDateColumn({
name: 'updated_at',
type: 'timestamp with time zone',
nullable: true,
})
updatedAt: Date | null;
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
updatedBy: string | null;
}

View File

@ -1,7 +1,6 @@
// Account entities // Account entities
export { AccountType, AccountTypeEnum } from './account-type.entity.js'; export { AccountType, AccountTypeEnum } from './account-type.entity.js';
export { Account } from './account.entity.js'; export { Account } from './account.entity.js';
export { AccountMapping, AccountMappingType } from './account-mapping.entity.js';
// Journal entities // Journal entities
export { Journal, JournalType } from './journal.entity.js'; export { Journal, JournalType } from './journal.entity.js';

View File

@ -9,39 +9,37 @@ import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.se
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
import { ValidationError } from '../../shared/errors/index.js'; import { ValidationError } from '../../shared/errors/index.js';
// Schemas - Accounts use camelCase DTOs // Schemas
const createAccountSchema = z.object({ const createAccountSchema = z.object({
companyId: z.string().uuid(), company_id: z.string().uuid(),
code: z.string().min(1).max(50), code: z.string().min(1).max(50),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
accountTypeId: z.string().uuid(), account_type_id: z.string().uuid(),
parentId: z.string().uuid().optional(), parent_id: z.string().uuid().optional(),
currencyId: z.string().uuid().optional(), currency_id: z.string().uuid().optional(),
isReconcilable: z.boolean().default(false), is_reconcilable: z.boolean().default(false),
notes: z.string().optional(), notes: z.string().optional(),
}); });
const updateAccountSchema = z.object({ const updateAccountSchema = z.object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
parentId: z.string().uuid().optional().nullable(), parent_id: z.string().uuid().optional().nullable(),
currencyId: z.string().uuid().optional().nullable(), currency_id: z.string().uuid().optional().nullable(),
isReconcilable: z.boolean().optional(), is_reconcilable: z.boolean().optional(),
isDeprecated: z.boolean().optional(), is_deprecated: z.boolean().optional(),
notes: z.string().optional().nullable(), notes: z.string().optional().nullable(),
}); });
const accountQuerySchema = z.object({ const accountQuerySchema = z.object({
companyId: z.string().uuid().optional(), company_id: z.string().uuid().optional(),
accountTypeId: z.string().uuid().optional(), account_type_id: z.string().uuid().optional(),
parentId: z.string().optional(), parent_id: z.string().optional(),
isDeprecated: z.coerce.boolean().optional(), is_deprecated: z.coerce.boolean().optional(),
search: z.string().optional(), search: z.string().optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(50), limit: z.coerce.number().int().positive().max(100).default(50),
}); });
// Journals and Journal Entries use snake_case DTOs
const createJournalSchema = z.object({ const createJournalSchema = z.object({
company_id: z.string().uuid(), company_id: z.string().uuid(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),

View File

@ -1,711 +0,0 @@
import { query, queryOne, getClient, PoolClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { AccountMappingType } from './entities/account-mapping.entity.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
// ============================================================================
// TYPES
// ============================================================================
export interface AccountMapping {
id: string;
tenant_id: string;
company_id: string;
mapping_type: AccountMappingType | string;
account_id: string;
account_code?: string;
account_name?: string;
description: string | null;
is_active: boolean;
}
export interface JournalEntryLineInput {
account_id: string;
partner_id?: string;
debit: number;
credit: number;
description?: string;
ref?: string;
}
export interface PostingResult {
journal_entry_id: string;
journal_entry_name: string;
total_debit: number;
total_credit: number;
lines_count: number;
}
export interface InvoiceForPosting {
id: string;
tenant_id: string;
company_id: string;
partner_id: string;
partner_name?: string;
invoice_type: 'customer' | 'supplier';
number: string;
invoice_date: Date;
amount_untaxed: number;
amount_tax: number;
amount_total: number;
journal_id?: string;
lines: InvoiceLineForPosting[];
}
export interface InvoiceLineForPosting {
id: string;
product_id?: string;
description: string;
quantity: number;
price_unit: number;
amount_untaxed: number;
amount_tax: number;
amount_total: number;
account_id?: string;
tax_ids: string[];
}
// ============================================================================
// SERVICE
// ============================================================================
class GLPostingService {
/**
* Get account mapping for a specific type and company
*/
async getMapping(
mappingType: AccountMappingType | string,
tenantId: string,
companyId: string
): Promise<AccountMapping | null> {
const mapping = await queryOne<AccountMapping>(
`SELECT am.*, a.code as account_code, a.name as account_name
FROM financial.account_mappings am
LEFT JOIN financial.accounts a ON am.account_id = a.id
WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.mapping_type = $3 AND am.is_active = true`,
[tenantId, companyId, mappingType]
);
return mapping;
}
/**
* Get all active mappings for a company
*/
async getMappings(tenantId: string, companyId: string): Promise<AccountMapping[]> {
return query<AccountMapping>(
`SELECT am.*, a.code as account_code, a.name as account_name
FROM financial.account_mappings am
LEFT JOIN financial.accounts a ON am.account_id = a.id
WHERE am.tenant_id = $1 AND am.company_id = $2 AND am.is_active = true
ORDER BY am.mapping_type`,
[tenantId, companyId]
);
}
/**
* Create or update an account mapping
*/
async setMapping(
mappingType: AccountMappingType | string,
accountId: string,
tenantId: string,
companyId: string,
description?: string,
userId?: string
): Promise<AccountMapping> {
const result = await queryOne<AccountMapping>(
`INSERT INTO financial.account_mappings (tenant_id, company_id, mapping_type, account_id, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tenant_id, company_id, mapping_type)
DO UPDATE SET account_id = $4, description = $5, updated_by = $6, updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[tenantId, companyId, mappingType, accountId, description, userId]
);
return result!;
}
/**
* Create a journal entry from a validated invoice
*
* For customer invoice (sale):
* - Debit: Accounts Receivable (partner balance)
* - Credit: Sales Revenue (per line or default mapping)
* - Credit: Tax Payable (if taxes apply)
*
* For supplier invoice (bill):
* - Credit: Accounts Payable (partner balance)
* - Debit: Purchase Expense (per line or default mapping)
* - Debit: Tax Receivable (if taxes apply)
*/
async createInvoicePosting(
invoice: InvoiceForPosting,
userId: string
): Promise<PostingResult> {
const { tenant_id: tenantId, company_id: companyId } = invoice;
logger.info('Creating GL posting for invoice', {
invoiceId: invoice.id,
invoiceNumber: invoice.number,
invoiceType: invoice.invoice_type,
});
// Validate invoice has lines
if (!invoice.lines || invoice.lines.length === 0) {
throw new ValidationError('La factura debe tener al menos una línea para contabilizar');
}
// Get required account mappings based on invoice type
const isCustomerInvoice = invoice.invoice_type === 'customer';
// Get receivable/payable account
const partnerAccountType = isCustomerInvoice
? AccountMappingType.CUSTOMER_INVOICE
: AccountMappingType.SUPPLIER_INVOICE;
const partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId);
if (!partnerMapping) {
throw new ValidationError(
`No hay cuenta configurada para ${isCustomerInvoice ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}. Configure account_mappings.`
);
}
// Get default revenue/expense account
const revenueExpenseType = isCustomerInvoice
? AccountMappingType.SALES_REVENUE
: AccountMappingType.PURCHASE_EXPENSE;
const revenueExpenseMapping = await this.getMapping(revenueExpenseType, tenantId, companyId);
// Get tax accounts if there are taxes
let taxPayableMapping: AccountMapping | null = null;
let taxReceivableMapping: AccountMapping | null = null;
if (invoice.amount_tax > 0) {
if (isCustomerInvoice) {
taxPayableMapping = await this.getMapping(AccountMappingType.TAX_PAYABLE, tenantId, companyId);
if (!taxPayableMapping) {
throw new ValidationError('No hay cuenta configurada para IVA por Pagar');
}
} else {
taxReceivableMapping = await this.getMapping(AccountMappingType.TAX_RECEIVABLE, tenantId, companyId);
if (!taxReceivableMapping) {
throw new ValidationError('No hay cuenta configurada para IVA por Recuperar');
}
}
}
// Build journal entry lines
const jeLines: JournalEntryLineInput[] = [];
// Line 1: Partner account (AR/AP)
if (isCustomerInvoice) {
// Customer invoice: Debit AR
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: invoice.partner_id,
debit: invoice.amount_total,
credit: 0,
description: `Factura ${invoice.number} - ${invoice.partner_name || 'Cliente'}`,
ref: invoice.number,
});
} else {
// Supplier invoice: Credit AP
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: invoice.partner_id,
debit: 0,
credit: invoice.amount_total,
description: `Factura ${invoice.number} - ${invoice.partner_name || 'Proveedor'}`,
ref: invoice.number,
});
}
// Lines for each invoice line (revenue/expense)
for (const line of invoice.lines) {
// Use line's account_id if specified, otherwise use default mapping
const lineAccountId = line.account_id || revenueExpenseMapping?.account_id;
if (!lineAccountId) {
throw new ValidationError(
`No hay cuenta de ${isCustomerInvoice ? 'ingresos' : 'gastos'} configurada para la línea: ${line.description}`
);
}
if (isCustomerInvoice) {
// Customer invoice: Credit Revenue
jeLines.push({
account_id: lineAccountId,
debit: 0,
credit: line.amount_untaxed,
description: line.description,
ref: invoice.number,
});
} else {
// Supplier invoice: Debit Expense
jeLines.push({
account_id: lineAccountId,
debit: line.amount_untaxed,
credit: 0,
description: line.description,
ref: invoice.number,
});
}
}
// Tax line if applicable
if (invoice.amount_tax > 0) {
if (isCustomerInvoice && taxPayableMapping) {
// Customer invoice: Credit Tax Payable
jeLines.push({
account_id: taxPayableMapping.account_id,
debit: 0,
credit: invoice.amount_tax,
description: `IVA - Factura ${invoice.number}`,
ref: invoice.number,
});
} else if (!isCustomerInvoice && taxReceivableMapping) {
// Supplier invoice: Debit Tax Receivable
jeLines.push({
account_id: taxReceivableMapping.account_id,
debit: invoice.amount_tax,
credit: 0,
description: `IVA - Factura ${invoice.number}`,
ref: invoice.number,
});
}
}
// Validate balance
const totalDebit = jeLines.reduce((sum, l) => sum + l.debit, 0);
const totalCredit = jeLines.reduce((sum, l) => sum + l.credit, 0);
if (Math.abs(totalDebit - totalCredit) > 0.01) {
logger.error('Journal entry not balanced', {
invoiceId: invoice.id,
totalDebit,
totalCredit,
difference: totalDebit - totalCredit,
});
throw new ValidationError(
`El asiento contable no está balanceado. Débitos: ${totalDebit.toFixed(2)}, Créditos: ${totalCredit.toFixed(2)}`
);
}
// Get journal (use invoice's journal or find default)
let journalId = invoice.journal_id;
if (!journalId) {
const journalType = isCustomerInvoice ? 'sale' : 'purchase';
const defaultJournal = await queryOne<{ id: string }>(
`SELECT id FROM financial.journals
WHERE tenant_id = $1 AND company_id = $2 AND type = $3 AND is_active = true
LIMIT 1`,
[tenantId, companyId, journalType]
);
if (!defaultJournal) {
throw new ValidationError(
`No hay diario de ${isCustomerInvoice ? 'ventas' : 'compras'} configurado`
);
}
journalId = defaultJournal.id;
}
// Create journal entry
const client = await getClient();
try {
await client.query('BEGIN');
// Generate journal entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create entry header
const entryResult = await client.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8)
RETURNING id, name`,
[
tenantId,
companyId,
journalId,
jeName,
invoice.number,
invoice.invoice_date,
`Asiento automático - Factura ${invoice.number}`,
userId,
]
);
const journalEntry = entryResult.rows[0];
// Create entry lines
for (const line of jeLines) {
await client.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
journalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
// Update journal entry posted_at
await client.query(
`UPDATE financial.journal_entries SET posted_at = CURRENT_TIMESTAMP, posted_by = $1 WHERE id = $2`,
[userId, journalEntry.id]
);
await client.query('COMMIT');
logger.info('GL posting created successfully', {
invoiceId: invoice.id,
journalEntryId: journalEntry.id,
journalEntryName: journalEntry.name,
totalDebit,
totalCredit,
linesCount: jeLines.length,
});
return {
journal_entry_id: journalEntry.id,
journal_entry_name: journalEntry.name,
total_debit: totalDebit,
total_credit: totalCredit,
lines_count: jeLines.length,
};
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error creating GL posting', {
invoiceId: invoice.id,
error: (error as Error).message,
});
throw error;
} finally {
client.release();
}
}
/**
* Create a journal entry from a posted payment
*
* For inbound payment (from customer):
* - Debit: Cash/Bank account
* - Credit: Accounts Receivable
*
* For outbound payment (to supplier):
* - Credit: Cash/Bank account
* - Debit: Accounts Payable
*/
async createPaymentPosting(
payment: {
id: string;
tenant_id: string;
company_id: string;
partner_id: string;
partner_name?: string;
payment_type: 'inbound' | 'outbound';
amount: number;
payment_date: Date;
ref?: string;
journal_id: string;
},
userId: string,
client?: PoolClient
): Promise<PostingResult> {
const { tenant_id: tenantId, company_id: companyId } = payment;
const isInbound = payment.payment_type === 'inbound';
logger.info('Creating GL posting for payment', {
paymentId: payment.id,
paymentType: payment.payment_type,
amount: payment.amount,
});
// Get cash/bank account from journal
const journal = await queryOne<{ default_debit_account_id: string; default_credit_account_id: string }>(
`SELECT default_debit_account_id, default_credit_account_id FROM financial.journals WHERE id = $1`,
[payment.journal_id]
);
if (!journal) {
throw new ValidationError('Diario de pago no encontrado');
}
const cashAccountId = isInbound ? journal.default_debit_account_id : journal.default_credit_account_id;
if (!cashAccountId) {
throw new ValidationError('El diario no tiene cuenta de efectivo/banco configurada');
}
// Get AR/AP account
const partnerAccountType = isInbound
? AccountMappingType.CUSTOMER_PAYMENT
: AccountMappingType.SUPPLIER_PAYMENT;
let partnerMapping = await this.getMapping(partnerAccountType, tenantId, companyId);
// Fall back to invoice mapping if payment-specific not configured
if (!partnerMapping) {
const fallbackType = isInbound
? AccountMappingType.CUSTOMER_INVOICE
: AccountMappingType.SUPPLIER_INVOICE;
partnerMapping = await this.getMapping(fallbackType, tenantId, companyId);
}
if (!partnerMapping) {
throw new ValidationError(
`No hay cuenta configurada para ${isInbound ? 'Cuentas por Cobrar' : 'Cuentas por Pagar'}`
);
}
// Build journal entry lines
const jeLines: JournalEntryLineInput[] = [];
const paymentRef = payment.ref || `PAY-${payment.id.substring(0, 8)}`;
if (isInbound) {
// Inbound: Debit Cash, Credit AR
jeLines.push({
account_id: cashAccountId,
debit: payment.amount,
credit: 0,
description: `Pago recibido - ${payment.partner_name || 'Cliente'}`,
ref: paymentRef,
});
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: payment.partner_id,
debit: 0,
credit: payment.amount,
description: `Pago recibido - ${payment.partner_name || 'Cliente'}`,
ref: paymentRef,
});
} else {
// Outbound: Credit Cash, Debit AP
jeLines.push({
account_id: cashAccountId,
debit: 0,
credit: payment.amount,
description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`,
ref: paymentRef,
});
jeLines.push({
account_id: partnerMapping.account_id,
partner_id: payment.partner_id,
debit: payment.amount,
credit: 0,
description: `Pago emitido - ${payment.partner_name || 'Proveedor'}`,
ref: paymentRef,
});
}
// Create journal entry
const ownClient = !client;
const dbClient = client || await getClient();
try {
if (ownClient) {
await dbClient.query('BEGIN');
}
// Generate journal entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create entry header
const entryResult = await dbClient.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'posted', $8, CURRENT_TIMESTAMP, $8)
RETURNING id, name`,
[
tenantId,
companyId,
payment.journal_id,
jeName,
paymentRef,
payment.payment_date,
`Asiento automático - Pago ${paymentRef}`,
userId,
]
);
const journalEntry = entryResult.rows[0];
// Create entry lines
for (const line of jeLines) {
await dbClient.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
journalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
if (ownClient) {
await dbClient.query('COMMIT');
}
logger.info('Payment GL posting created successfully', {
paymentId: payment.id,
journalEntryId: journalEntry.id,
journalEntryName: journalEntry.name,
});
return {
journal_entry_id: journalEntry.id,
journal_entry_name: journalEntry.name,
total_debit: payment.amount,
total_credit: payment.amount,
lines_count: 2,
};
} catch (error) {
if (ownClient) {
await dbClient.query('ROLLBACK');
}
throw error;
} finally {
if (ownClient) {
dbClient.release();
}
}
}
/**
* Reverse a journal entry (create a contra entry)
*/
async reversePosting(
journalEntryId: string,
tenantId: string,
reason: string,
userId: string
): Promise<PostingResult> {
// Get original entry
const originalEntry = await queryOne<{
id: string;
company_id: string;
journal_id: string;
name: string;
ref: string;
date: Date;
}>(
`SELECT id, company_id, journal_id, name, ref, date
FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`,
[journalEntryId, tenantId]
);
if (!originalEntry) {
throw new NotFoundError('Asiento contable no encontrado');
}
// Get original lines
const originalLines = await query<JournalEntryLineInput & { id: string }>(
`SELECT account_id, partner_id, debit, credit, description, ref
FROM financial.journal_entry_lines WHERE entry_id = $1`,
[journalEntryId]
);
// Reverse debits and credits
const reversedLines: JournalEntryLineInput[] = originalLines.map(line => ({
account_id: line.account_id,
partner_id: line.partner_id,
debit: line.credit, // Swap
credit: line.debit, // Swap
description: `Reverso: ${line.description || ''}`,
ref: line.ref,
}));
const client = await getClient();
try {
await client.query('BEGIN');
// Generate new entry number
const jeName = await sequencesService.getNextNumber(SEQUENCE_CODES.JOURNAL_ENTRY, tenantId);
// Create reversal entry
const entryResult = await client.query(
`INSERT INTO financial.journal_entries (
tenant_id, company_id, journal_id, name, ref, date, notes, status, created_by, posted_at, posted_by
)
VALUES ($1, $2, $3, $4, $5, CURRENT_DATE, $6, 'posted', $7, CURRENT_TIMESTAMP, $7)
RETURNING id, name`,
[
tenantId,
originalEntry.company_id,
originalEntry.journal_id,
jeName,
`REV-${originalEntry.name}`,
`Reverso de ${originalEntry.name}: ${reason}`,
userId,
]
);
const reversalEntry = entryResult.rows[0];
// Create reversal lines
for (const line of reversedLines) {
await client.query(
`INSERT INTO financial.journal_entry_lines (
entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
reversalEntry.id,
tenantId,
line.account_id,
line.partner_id,
line.debit,
line.credit,
line.description,
line.ref,
]
);
}
// Mark original as cancelled
await client.query(
`UPDATE financial.journal_entries SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1 WHERE id = $2`,
[userId, journalEntryId]
);
await client.query('COMMIT');
const totalDebit = reversedLines.reduce((sum, l) => sum + l.debit, 0);
logger.info('GL posting reversed', {
originalEntryId: journalEntryId,
reversalEntryId: reversalEntry.id,
reason,
});
return {
journal_entry_id: reversalEntry.id,
journal_entry_name: reversalEntry.name,
total_debit: totalDebit,
total_credit: totalDebit,
lines_count: reversedLines.length,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
export const glPostingService = new GLPostingService();

View File

@ -4,6 +4,5 @@ export * from './journal-entries.service.js';
export * from './invoices.service.js'; export * from './invoices.service.js';
export * from './payments.service.js'; export * from './payments.service.js';
export * from './taxes.service.js'; export * from './taxes.service.js';
export * from './gl-posting.service.js';
export * from './financial.controller.js'; export * from './financial.controller.js';
export { default as financialRoutes } from './financial.routes.js'; export { default as financialRoutes } from './financial.routes.js';

View File

@ -1,9 +1,6 @@
import { query, queryOne, getClient } from '../../config/database.js'; import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
import { taxesService } from './taxes.service.js'; import { taxesService } from './taxes.service.js';
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
import { logger } from '../../shared/utils/logger.js';
export interface InvoiceLine { export interface InvoiceLine {
id: string; id: string;
@ -412,24 +409,10 @@ class InvoicesService {
values.push(dto.account_id); values.push(dto.account_id);
} }
// Recalculate amounts using taxesService // Recalculate amounts
const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? []; const amountUntaxed = quantity * priceUnit;
const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase'; const amountTax = 0; // TODO: Calculate taxes
const amountTotal = amountUntaxed + amountTax;
const taxResult = await taxesService.calculateTaxes(
{
quantity,
priceUnit,
discount: 0,
taxIds,
},
tenantId,
transactionType
);
const amountUntaxed = taxResult.amountUntaxed;
const amountTax = taxResult.amountTax;
const amountTotal = taxResult.amountTotal;
updateFields.push(`amount_untaxed = $${paramIndex++}`); updateFields.push(`amount_untaxed = $${paramIndex++}`);
values.push(amountUntaxed); values.push(amountUntaxed);
@ -485,96 +468,29 @@ class InvoicesService {
throw new ValidationError('La factura debe tener al menos una línea'); throw new ValidationError('La factura debe tener al menos una línea');
} }
logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type }); // Generate invoice number
const prefix = invoice.invoice_type === 'customer' ? 'INV' : 'BILL';
const seqResult = await queryOne<{ next_num: number }>(
`SELECT COALESCE(MAX(CAST(SUBSTRING(number FROM 5) AS INTEGER)), 0) + 1 as next_num
FROM financial.invoices WHERE tenant_id = $1 AND number LIKE '${prefix}-%'`,
[tenantId]
);
const invoiceNumber = `${prefix}-${String(seqResult?.next_num || 1).padStart(6, '0')}`;
// Generate invoice number using sequences service await query(
const sequenceCode = invoice.invoice_type === 'customer' `UPDATE financial.invoices SET
? SEQUENCE_CODES.INVOICE_CUSTOMER number = $1,
: SEQUENCE_CODES.INVOICE_SUPPLIER; status = 'open',
const invoiceNumber = await sequencesService.getNextNumber(sequenceCode, tenantId); amount_residual = amount_total,
validated_at = CURRENT_TIMESTAMP,
validated_by = $2,
updated_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND tenant_id = $4`,
[invoiceNumber, userId, id, tenantId]
);
const client = await getClient(); return this.findById(id, tenantId);
try {
await client.query('BEGIN');
// Update invoice status and number
await client.query(
`UPDATE financial.invoices SET
number = $1,
status = 'open',
amount_residual = amount_total,
validated_at = CURRENT_TIMESTAMP,
validated_by = $2,
updated_by = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND tenant_id = $4`,
[invoiceNumber, userId, id, tenantId]
);
await client.query('COMMIT');
// Get updated invoice with number for GL posting
const validatedInvoice = await this.findById(id, tenantId);
// Create journal entry for the invoice (GL posting)
try {
const invoiceForPosting: InvoiceForPosting = {
id: validatedInvoice.id,
tenant_id: validatedInvoice.tenant_id,
company_id: validatedInvoice.company_id,
partner_id: validatedInvoice.partner_id,
partner_name: validatedInvoice.partner_name,
invoice_type: validatedInvoice.invoice_type,
number: validatedInvoice.number!,
invoice_date: validatedInvoice.invoice_date,
amount_untaxed: Number(validatedInvoice.amount_untaxed),
amount_tax: Number(validatedInvoice.amount_tax),
amount_total: Number(validatedInvoice.amount_total),
journal_id: validatedInvoice.journal_id,
lines: (validatedInvoice.lines || []).map(line => ({
id: line.id,
product_id: line.product_id,
description: line.description,
quantity: Number(line.quantity),
price_unit: Number(line.price_unit),
amount_untaxed: Number(line.amount_untaxed),
amount_tax: Number(line.amount_tax),
amount_total: Number(line.amount_total),
account_id: line.account_id,
tax_ids: line.tax_ids || [],
})),
};
const postingResult = await glPostingService.createInvoicePosting(invoiceForPosting, userId);
// Link journal entry to invoice
await query(
`UPDATE financial.invoices SET journal_entry_id = $1 WHERE id = $2`,
[postingResult.journal_entry_id, id]
);
logger.info('Invoice validated with GL posting', {
invoiceId: id,
invoiceNumber,
journalEntryId: postingResult.journal_entry_id,
journalEntryName: postingResult.journal_entry_name,
});
} catch (postingError) {
// Log error but don't fail the validation - GL posting can be done manually
logger.error('Failed to create automatic GL posting', {
invoiceId: id,
error: (postingError as Error).message,
});
// The invoice is still valid, just without automatic GL entry
}
return this.findById(id, tenantId);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} }
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> { async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
@ -592,31 +508,6 @@ class InvoicesService {
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados'); throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
} }
logger.info('Cancelling invoice', { invoiceId: id, invoiceNumber: invoice.number });
// Reverse journal entry if exists
if (invoice.journal_entry_id) {
try {
await glPostingService.reversePosting(
invoice.journal_entry_id,
tenantId,
`Cancelación de factura ${invoice.number}`,
userId
);
logger.info('Journal entry reversed for cancelled invoice', {
invoiceId: id,
journalEntryId: invoice.journal_entry_id,
});
} catch (error) {
logger.error('Failed to reverse journal entry', {
invoiceId: id,
journalEntryId: invoice.journal_entry_id,
error: (error as Error).message,
});
// Continue with cancellation even if reversal fails
}
}
await query( await query(
`UPDATE financial.invoices SET `UPDATE financial.invoices SET
status = 'cancelled', status = 'cancelled',

View File

@ -1,810 +0,0 @@
import { query, queryOne, getClient } from '../../../config/database.js';
import { NotFoundError, ValidationError } from '../../../shared/errors/index.js';
import {
CreateBankStatementDto,
BankStatementFilters,
BankStatementWithLines,
BankStatementLineResponse,
SuggestedMatch,
} from '../dto/create-bank-statement.dto.js';
import {
ReconcileResult,
AutoReconcileResult,
MatchCandidate,
FindMatchCandidatesDto,
CreateReconciliationRuleDto,
UpdateReconciliationRuleDto,
} from '../dto/reconcile-line.dto.js';
/**
* Representacion de extracto bancario
*/
export interface BankStatement {
id: string;
tenant_id: string;
company_id: string | null;
bank_account_id: string | null;
bank_account_name?: string;
statement_date: Date;
opening_balance: number;
closing_balance: number;
status: 'draft' | 'reconciling' | 'reconciled';
imported_at: Date | null;
imported_by: string | null;
reconciled_at: Date | null;
reconciled_by: string | null;
created_at: Date;
}
/**
* Representacion de regla de conciliacion
*/
export interface ReconciliationRule {
id: string;
tenant_id: string;
company_id: string | null;
name: string;
match_type: 'exact_amount' | 'reference_contains' | 'partner_name';
match_value: string;
auto_account_id: string | null;
auto_account_name?: string;
is_active: boolean;
priority: number;
created_at: Date;
}
/**
* Servicio para conciliacion bancaria
*/
class BankReconciliationService {
// ==========================================
// EXTRACTOS BANCARIOS
// ==========================================
/**
* Importar un extracto bancario con sus lineas
*/
async importStatement(
dto: CreateBankStatementDto,
tenantId: string,
userId: string
): Promise<BankStatement> {
// Validaciones
if (!dto.lines || dto.lines.length === 0) {
throw new ValidationError('El extracto debe tener al menos una linea');
}
// Validar que el balance cuadre
const calculatedClosing = dto.opening_balance + dto.lines.reduce((sum, line) => sum + line.amount, 0);
if (Math.abs(calculatedClosing - dto.closing_balance) > 0.01) {
throw new ValidationError(
`El balance no cuadra. Apertura (${dto.opening_balance}) + Movimientos (${dto.lines.reduce((s, l) => s + l.amount, 0)}) = ${calculatedClosing}, pero cierre declarado es ${dto.closing_balance}`
);
}
const client = await getClient();
try {
await client.query('BEGIN');
// Crear el extracto
const statementResult = await client.query<BankStatement>(
`INSERT INTO financial.bank_statements (
tenant_id, company_id, bank_account_id, statement_date,
opening_balance, closing_balance, status,
imported_at, imported_by, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', CURRENT_TIMESTAMP, $7, $7)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.bank_account_id || null,
dto.statement_date,
dto.opening_balance,
dto.closing_balance,
userId,
]
);
const statement = statementResult.rows[0];
// Insertar las lineas
for (const line of dto.lines) {
await client.query(
`INSERT INTO financial.bank_statement_lines (
statement_id, tenant_id, transaction_date, value_date,
description, reference, amount, partner_id, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
statement.id,
tenantId,
line.transaction_date,
line.value_date || null,
line.description || null,
line.reference || null,
line.amount,
line.partner_id || null,
line.notes || null,
]
);
}
await client.query('COMMIT');
return statement;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Obtener lista de extractos con filtros
*/
async findAll(
tenantId: string,
filters: BankStatementFilters = {}
): Promise<{ data: BankStatement[]; total: number }> {
const { company_id, bank_account_id, status, date_from, date_to, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE bs.tenant_id = $1';
const params: unknown[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND bs.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (bank_account_id) {
whereClause += ` AND bs.bank_account_id = $${paramIndex++}`;
params.push(bank_account_id);
}
if (status) {
whereClause += ` AND bs.status = $${paramIndex++}`;
params.push(status);
}
if (date_from) {
whereClause += ` AND bs.statement_date >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND bs.statement_date <= $${paramIndex++}`;
params.push(date_to);
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM financial.bank_statements bs ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<BankStatement>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
${whereClause}
ORDER BY bs.statement_date DESC, bs.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
/**
* Obtener un extracto con todas sus lineas
*/
async getStatementWithLines(id: string, tenantId: string): Promise<BankStatementWithLines> {
const statement = await queryOne<BankStatementWithLines>(
`SELECT bs.*,
a.name as bank_account_name,
financial.get_reconciliation_progress(bs.id) as reconciliation_progress
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
// Obtener lineas
const lines = await query<BankStatementLineResponse>(
`SELECT bsl.*,
p.name as partner_name
FROM financial.bank_statement_lines bsl
LEFT JOIN core.partners p ON bsl.partner_id = p.id
WHERE bsl.statement_id = $1
ORDER BY bsl.transaction_date, bsl.created_at`,
[id]
);
// Calcular balance calculado
const calculatedBalance =
Number(statement.opening_balance) + lines.reduce((sum, line) => sum + Number(line.amount), 0);
statement.lines = lines;
statement.calculated_balance = calculatedBalance;
return statement;
}
/**
* Eliminar un extracto (solo en estado draft)
*/
async deleteStatement(id: string, tenantId: string): Promise<void> {
const statement = await queryOne<BankStatement>(
`SELECT * FROM financial.bank_statements WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
if (statement.status !== 'draft') {
throw new ValidationError('Solo se pueden eliminar extractos en estado borrador');
}
await query(`DELETE FROM financial.bank_statements WHERE id = $1`, [id]);
}
// ==========================================
// CONCILIACION
// ==========================================
/**
* Ejecutar auto-conciliacion de un extracto
* Busca matches automaticos por monto, fecha y referencia
*/
async autoReconcile(statementId: string, tenantId: string, userId: string): Promise<AutoReconcileResult> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta completamente conciliado');
}
// Cambiar estado a reconciling si esta en draft
if (statement.status === 'draft') {
await query(
`UPDATE financial.bank_statements SET status = 'reconciling', updated_by = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
}
const result: AutoReconcileResult = {
total_lines: statement.lines.length,
reconciled_count: 0,
unreconciled_count: 0,
reconciled_lines: [],
lines_with_suggestions: [],
};
// Obtener reglas activas
const rules = await query<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules
WHERE tenant_id = $1 AND is_active = true
ORDER BY priority DESC`,
[tenantId]
);
// Procesar cada linea no conciliada
for (const line of statement.lines) {
if (line.is_reconciled) {
continue;
}
// Buscar candidatos a match
const candidates = await this.findMatchCandidates(
{
amount: Math.abs(Number(line.amount)),
date: line.transaction_date.toString(),
reference: line.reference || undefined,
partner_id: line.partner_id || undefined,
amount_tolerance: 0,
date_tolerance_days: 3,
limit: 5,
},
tenantId,
statement.bank_account_id || undefined
);
// Aplicar reglas personalizadas
for (const rule of rules) {
const ruleMatch = this.applyRule(rule, line);
if (ruleMatch) {
// Si la regla tiene cuenta auto, se podria crear asiento automatico
// Por ahora solo marcamos como sugerencia
}
}
// Si hay un match con confianza >= 90%, conciliar automaticamente
const exactMatch = candidates.find((c) => c.confidence >= 90);
if (exactMatch) {
try {
await this.reconcileLine(line.id, exactMatch.id, tenantId, userId);
result.reconciled_count++;
result.reconciled_lines.push({
statement_line_id: line.id,
entry_line_id: exactMatch.id,
match_type: exactMatch.match_type,
confidence: exactMatch.confidence,
});
} catch {
// Si falla, agregar a sugerencias
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
}
} else if (candidates.length > 0) {
result.lines_with_suggestions.push({
statement_line_id: line.id,
suggestions: candidates.length,
});
result.unreconciled_count++;
} else {
result.unreconciled_count++;
}
}
return result;
}
/**
* Buscar lineas de asiento candidatas a conciliar
*/
async findMatchCandidates(
dto: FindMatchCandidatesDto,
tenantId: string,
bankAccountId?: string
): Promise<MatchCandidate[]> {
const { amount, date, reference, partner_id, amount_tolerance = 0, date_tolerance_days = 3, limit = 10 } = dto;
let whereClause = `
WHERE jel.tenant_id = $1
AND je.status = 'posted'
AND NOT EXISTS (
SELECT 1 FROM financial.bank_statement_lines bsl
WHERE bsl.reconciled_entry_id = jel.id
)
`;
const params: unknown[] = [tenantId];
let paramIndex = 2;
// Filtrar por cuenta bancaria si se especifica
if (bankAccountId) {
whereClause += ` AND jel.account_id = $${paramIndex++}`;
params.push(bankAccountId);
}
// Filtrar por monto con tolerancia
const amountMin = amount * (1 - amount_tolerance);
const amountMax = amount * (1 + amount_tolerance);
whereClause += ` AND (
(jel.debit BETWEEN $${paramIndex} AND $${paramIndex + 1})
OR (jel.credit BETWEEN $${paramIndex} AND $${paramIndex + 1})
)`;
params.push(amountMin, amountMax);
paramIndex += 2;
// Filtrar por fecha con tolerancia
if (date) {
whereClause += ` AND je.date BETWEEN ($${paramIndex}::date - interval '${date_tolerance_days} days') AND ($${paramIndex}::date + interval '${date_tolerance_days} days')`;
params.push(date);
paramIndex++;
}
// Filtrar por partner si se especifica
if (partner_id) {
whereClause += ` AND jel.partner_id = $${paramIndex++}`;
params.push(partner_id);
}
params.push(limit);
const candidates = await query<MatchCandidate>(
`SELECT
jel.id,
jel.entry_id,
je.name as entry_name,
je.ref as entry_ref,
je.date as entry_date,
jel.account_id,
a.code as account_code,
a.name as account_name,
jel.debit,
jel.credit,
(jel.debit - jel.credit) as net_amount,
jel.description,
jel.partner_id,
p.name as partner_name
FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
INNER JOIN financial.accounts a ON jel.account_id = a.id
LEFT JOIN core.partners p ON jel.partner_id = p.id
${whereClause}
ORDER BY je.date DESC
LIMIT $${paramIndex}`,
params
);
// Calcular confianza y tipo de match para cada candidato
return candidates.map((c) => {
let confidence = 50; // Base
let matchType: MatchCandidate['match_type'] = 'exact_amount';
const candidateAmount = Math.abs(Number(c.debit) - Number(c.credit));
// Match exacto de monto
if (Math.abs(candidateAmount - amount) < 0.01) {
confidence += 30;
matchType = 'exact_amount';
}
// Match de fecha exacta
if (date && c.entry_date.toString().substring(0, 10) === date.substring(0, 10)) {
confidence += 15;
matchType = 'amount_date';
}
// Match de referencia
if (reference && c.entry_ref && c.entry_ref.toLowerCase().includes(reference.toLowerCase())) {
confidence += 20;
matchType = 'reference';
}
// Match de partner
if (partner_id && c.partner_id === partner_id) {
confidence += 15;
matchType = 'partner';
}
return {
...c,
match_type: matchType,
confidence: Math.min(100, confidence),
};
});
}
/**
* Conciliar manualmente una linea de extracto con una linea de asiento
*/
async reconcileLine(
lineId: string,
entryLineId: string,
tenantId: string,
userId: string
): Promise<ReconcileResult> {
// Verificar que la linea de extracto existe y no esta conciliada
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (line.is_reconciled) {
throw new ValidationError('La linea ya esta conciliada');
}
// Verificar que la linea de asiento existe y no esta conciliada con otra linea
const entryLine = await queryOne<{ id: string; debit: number; credit: number }>(
`SELECT jel.* FROM financial.journal_entry_lines jel
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
WHERE jel.id = $1 AND jel.tenant_id = $2 AND je.status = 'posted'`,
[entryLineId, tenantId]
);
if (!entryLine) {
throw new NotFoundError('Linea de asiento no encontrada o no publicada');
}
// Verificar que no este ya conciliada
const alreadyReconciled = await queryOne<{ id: string }>(
`SELECT id FROM financial.bank_statement_lines WHERE reconciled_entry_id = $1`,
[entryLineId]
);
if (alreadyReconciled) {
throw new ValidationError('La linea de asiento ya esta conciliada con otra linea de extracto');
}
// Actualizar la linea de extracto
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = true,
reconciled_entry_id = $1,
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $2
WHERE id = $3`,
[entryLineId, userId, lineId]
);
return {
success: true,
statement_line_id: lineId,
entry_line_id: entryLineId,
};
}
/**
* Deshacer la conciliacion de una linea
*/
async unreconcileLine(lineId: string, tenantId: string): Promise<void> {
const line = await queryOne<BankStatementLineResponse>(
`SELECT * FROM financial.bank_statement_lines WHERE id = $1 AND tenant_id = $2`,
[lineId, tenantId]
);
if (!line) {
throw new NotFoundError('Linea de extracto no encontrada');
}
if (!line.is_reconciled) {
throw new ValidationError('La linea no esta conciliada');
}
await query(
`UPDATE financial.bank_statement_lines SET
is_reconciled = false,
reconciled_entry_id = NULL,
reconciled_at = NULL,
reconciled_by = NULL
WHERE id = $1`,
[lineId]
);
}
/**
* Cerrar un extracto completamente conciliado
*/
async closeStatement(statementId: string, tenantId: string, userId: string): Promise<BankStatement> {
const statement = await this.getStatementWithLines(statementId, tenantId);
if (statement.status === 'reconciled') {
throw new ValidationError('El extracto ya esta cerrado');
}
// Verificar que todas las lineas esten conciliadas
const unreconciledLines = statement.lines.filter((l) => !l.is_reconciled);
if (unreconciledLines.length > 0) {
throw new ValidationError(
`No se puede cerrar el extracto. Hay ${unreconciledLines.length} linea(s) sin conciliar`
);
}
await query(
`UPDATE financial.bank_statements SET
status = 'reconciled',
reconciled_at = CURRENT_TIMESTAMP,
reconciled_by = $1,
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[userId, statementId]
);
return this.findById(statementId, tenantId);
}
/**
* Obtener un extracto por ID
*/
async findById(id: string, tenantId: string): Promise<BankStatement> {
const statement = await queryOne<BankStatement>(
`SELECT bs.*, a.name as bank_account_name
FROM financial.bank_statements bs
LEFT JOIN financial.accounts a ON bs.bank_account_id = a.id
WHERE bs.id = $1 AND bs.tenant_id = $2`,
[id, tenantId]
);
if (!statement) {
throw new NotFoundError('Extracto bancario no encontrado');
}
return statement;
}
// ==========================================
// REGLAS DE CONCILIACION
// ==========================================
/**
* Crear una regla de conciliacion
*/
async createRule(dto: CreateReconciliationRuleDto, tenantId: string, userId: string): Promise<ReconciliationRule> {
const result = await queryOne<ReconciliationRule>(
`INSERT INTO financial.bank_reconciliation_rules (
tenant_id, company_id, name, match_type, match_value,
auto_account_id, priority, is_active, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
tenantId,
dto.company_id || null,
dto.name,
dto.match_type,
dto.match_value,
dto.auto_account_id || null,
dto.priority || 0,
dto.is_active !== false,
userId,
]
);
return result!;
}
/**
* Obtener reglas de conciliacion
*/
async findRules(tenantId: string, companyId?: string): Promise<ReconciliationRule[]> {
let whereClause = 'WHERE r.tenant_id = $1';
const params: unknown[] = [tenantId];
if (companyId) {
whereClause += ' AND (r.company_id = $2 OR r.company_id IS NULL)';
params.push(companyId);
}
return query<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
${whereClause}
ORDER BY r.priority DESC, r.name`,
params
);
}
/**
* Actualizar una regla de conciliacion
*/
async updateRule(
id: string,
dto: UpdateReconciliationRuleDto,
tenantId: string,
userId: string
): Promise<ReconciliationRule> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
const updateFields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.match_type !== undefined) {
updateFields.push(`match_type = $${paramIndex++}`);
values.push(dto.match_type);
}
if (dto.match_value !== undefined) {
updateFields.push(`match_value = $${paramIndex++}`);
values.push(dto.match_value);
}
if (dto.auto_account_id !== undefined) {
updateFields.push(`auto_account_id = $${paramIndex++}`);
values.push(dto.auto_account_id);
}
if (dto.priority !== undefined) {
updateFields.push(`priority = $${paramIndex++}`);
values.push(dto.priority);
}
if (dto.is_active !== undefined) {
updateFields.push(`is_active = $${paramIndex++}`);
values.push(dto.is_active);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE financial.bank_reconciliation_rules SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findRuleById(id, tenantId);
}
/**
* Obtener regla por ID
*/
async findRuleById(id: string, tenantId: string): Promise<ReconciliationRule> {
const rule = await queryOne<ReconciliationRule>(
`SELECT r.*, a.name as auto_account_name
FROM financial.bank_reconciliation_rules r
LEFT JOIN financial.accounts a ON r.auto_account_id = a.id
WHERE r.id = $1 AND r.tenant_id = $2`,
[id, tenantId]
);
if (!rule) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
return rule;
}
/**
* Eliminar una regla
*/
async deleteRule(id: string, tenantId: string): Promise<void> {
const existing = await queryOne<ReconciliationRule>(
`SELECT * FROM financial.bank_reconciliation_rules WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
if (!existing) {
throw new NotFoundError('Regla de conciliacion no encontrada');
}
await query(`DELETE FROM financial.bank_reconciliation_rules WHERE id = $1`, [id]);
}
// ==========================================
// HELPERS
// ==========================================
/**
* Aplicar una regla a una linea de extracto
*/
private applyRule(
rule: ReconciliationRule,
line: BankStatementLineResponse
): boolean {
switch (rule.match_type) {
case 'exact_amount':
return Math.abs(Number(line.amount)) === parseFloat(rule.match_value);
case 'reference_contains':
return line.reference?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
case 'partner_name':
// Esto requeriria el nombre del partner, que ya esta en partner_name
return line.partner_name?.toLowerCase().includes(rule.match_value.toLowerCase()) || false;
default:
return false;
}
}
}
export const bankReconciliationService = new BankReconciliationService();

View File

@ -1,47 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { PersonType } from './fiscal-regime.entity.js';
@Entity({ schema: 'fiscal', name: 'cfdi_uses' })
@Index('idx_cfdi_uses_code', ['code'], { unique: true })
@Index('idx_cfdi_uses_applies', ['appliesTo'])
export class CfdiUse {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: PersonType,
nullable: false,
default: PersonType.BOTH,
name: 'applies_to',
})
appliesTo: PersonType;
@Column({ type: 'simple-array', nullable: true, name: 'allowed_regimes' })
allowedRegimes: string[] | null;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,49 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum PersonType {
NATURAL = 'natural', // Persona fisica
LEGAL = 'legal', // Persona moral
BOTH = 'both',
}
@Entity({ schema: 'fiscal', name: 'fiscal_regimes' })
@Index('idx_fiscal_regimes_code', ['code'], { unique: true })
@Index('idx_fiscal_regimes_applies', ['appliesTo'])
export class FiscalRegime {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: PersonType,
nullable: false,
default: PersonType.BOTH,
name: 'applies_to',
})
appliesTo: PersonType;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,6 +0,0 @@
export * from './tax-category.entity.js';
export * from './fiscal-regime.entity.js';
export * from './cfdi-use.entity.js';
export * from './payment-method.entity.js';
export * from './payment-type.entity.js';
export * from './withholding-type.entity.js';

View File

@ -1,36 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'fiscal', name: 'payment_methods' })
@Index('idx_payment_methods_code', ['code'], { unique: true })
export class PaymentMethod {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false, nullable: false, name: 'requires_bank_info' })
requiresBankInfo: boolean;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,33 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'fiscal', name: 'payment_types' })
@Index('idx_payment_types_code', ['code'], { unique: true })
export class PaymentType {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,52 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum TaxNature {
TAX = 'tax',
WITHHOLDING = 'withholding',
BOTH = 'both',
}
@Entity({ schema: 'fiscal', name: 'tax_categories' })
@Index('idx_tax_categories_code', ['code'], { unique: true })
@Index('idx_tax_categories_sat', ['satCode'])
export class TaxCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 20, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'enum',
enum: TaxNature,
nullable: false,
default: TaxNature.TAX,
name: 'tax_nature',
})
taxNature: TaxNature;
@Column({ type: 'varchar', length: 10, nullable: true, name: 'sat_code' })
satCode: string | null;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,54 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { TaxCategory } from './tax-category.entity.js';
@Entity({ schema: 'fiscal', name: 'withholding_types' })
@Index('idx_withholding_types_code', ['code'], { unique: true })
@Index('idx_withholding_types_category', ['taxCategoryId'])
export class WithholdingType {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 20, nullable: false, unique: true })
code: string;
@Column({ type: 'varchar', length: 100, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: false,
default: 0,
name: 'default_rate',
})
defaultRate: number;
@Column({ type: 'uuid', nullable: true, name: 'tax_category_id' })
taxCategoryId: string | null;
@ManyToOne(() => TaxCategory, { nullable: true })
@JoinColumn({ name: 'tax_category_id' })
taxCategory: TaxCategory | null;
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -1,426 +0,0 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import {
TaxCategory,
FiscalRegime,
CfdiUse,
PaymentMethod,
PaymentType,
WithholdingType,
PersonType,
} from './entities/index.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
// ==========================================
// TAX CATEGORIES SERVICE
// ==========================================
export interface TaxCategoryFilter {
taxNature?: string;
active?: boolean;
}
class TaxCategoriesService {
private repository: Repository<TaxCategory>;
constructor() {
this.repository = AppDataSource.getRepository(TaxCategory);
}
async findAll(filter: TaxCategoryFilter = {}): Promise<TaxCategory[]> {
logger.debug('Finding all tax categories', { filter });
const qb = this.repository.createQueryBuilder('tc');
if (filter.taxNature) {
qb.andWhere('tc.tax_nature = :taxNature', { taxNature: filter.taxNature });
}
if (filter.active !== undefined) {
qb.andWhere('tc.is_active = :active', { active: filter.active });
}
qb.orderBy('tc.name', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<TaxCategory> {
logger.debug('Finding tax category by id', { id });
const category = await this.repository.findOne({ where: { id } });
if (!category) {
throw new NotFoundError('Categoría de impuesto no encontrada');
}
return category;
}
async findByCode(code: string): Promise<TaxCategory | null> {
logger.debug('Finding tax category by code', { code });
return this.repository.findOne({
where: { code: code.toUpperCase() },
});
}
async findBySatCode(satCode: string): Promise<TaxCategory | null> {
logger.debug('Finding tax category by SAT code', { satCode });
return this.repository.findOne({
where: { satCode },
});
}
}
// ==========================================
// FISCAL REGIMES SERVICE
// ==========================================
export interface FiscalRegimeFilter {
appliesTo?: PersonType;
active?: boolean;
}
class FiscalRegimesService {
private repository: Repository<FiscalRegime>;
constructor() {
this.repository = AppDataSource.getRepository(FiscalRegime);
}
async findAll(filter: FiscalRegimeFilter = {}): Promise<FiscalRegime[]> {
logger.debug('Finding all fiscal regimes', { filter });
const qb = this.repository.createQueryBuilder('fr');
if (filter.appliesTo) {
qb.andWhere('(fr.applies_to = :appliesTo OR fr.applies_to = :both)', {
appliesTo: filter.appliesTo,
both: PersonType.BOTH,
});
}
if (filter.active !== undefined) {
qb.andWhere('fr.is_active = :active', { active: filter.active });
}
qb.orderBy('fr.code', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<FiscalRegime> {
logger.debug('Finding fiscal regime by id', { id });
const regime = await this.repository.findOne({ where: { id } });
if (!regime) {
throw new NotFoundError('Régimen fiscal no encontrado');
}
return regime;
}
async findByCode(code: string): Promise<FiscalRegime | null> {
logger.debug('Finding fiscal regime by code', { code });
return this.repository.findOne({
where: { code },
});
}
async findForPersonType(personType: PersonType): Promise<FiscalRegime[]> {
logger.debug('Finding fiscal regimes for person type', { personType });
return this.repository
.createQueryBuilder('fr')
.where('fr.applies_to = :personType OR fr.applies_to = :both', {
personType,
both: PersonType.BOTH,
})
.andWhere('fr.is_active = true')
.orderBy('fr.code', 'ASC')
.getMany();
}
}
// ==========================================
// CFDI USES SERVICE
// ==========================================
export interface CfdiUseFilter {
appliesTo?: PersonType;
active?: boolean;
}
class CfdiUsesService {
private repository: Repository<CfdiUse>;
constructor() {
this.repository = AppDataSource.getRepository(CfdiUse);
}
async findAll(filter: CfdiUseFilter = {}): Promise<CfdiUse[]> {
logger.debug('Finding all CFDI uses', { filter });
const qb = this.repository.createQueryBuilder('cu');
if (filter.appliesTo) {
qb.andWhere('(cu.applies_to = :appliesTo OR cu.applies_to = :both)', {
appliesTo: filter.appliesTo,
both: PersonType.BOTH,
});
}
if (filter.active !== undefined) {
qb.andWhere('cu.is_active = :active', { active: filter.active });
}
qb.orderBy('cu.code', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<CfdiUse> {
logger.debug('Finding CFDI use by id', { id });
const cfdiUse = await this.repository.findOne({ where: { id } });
if (!cfdiUse) {
throw new NotFoundError('Uso de CFDI no encontrado');
}
return cfdiUse;
}
async findByCode(code: string): Promise<CfdiUse | null> {
logger.debug('Finding CFDI use by code', { code });
return this.repository.findOne({
where: { code: code.toUpperCase() },
});
}
async findForPersonType(personType: PersonType): Promise<CfdiUse[]> {
logger.debug('Finding CFDI uses for person type', { personType });
return this.repository
.createQueryBuilder('cu')
.where('cu.applies_to = :personType OR cu.applies_to = :both', {
personType,
both: PersonType.BOTH,
})
.andWhere('cu.is_active = true')
.orderBy('cu.code', 'ASC')
.getMany();
}
async findForRegime(regimeCode: string): Promise<CfdiUse[]> {
logger.debug('Finding CFDI uses for regime', { regimeCode });
// Get all active CFDI uses and filter by allowed regimes
const all = await this.repository
.createQueryBuilder('cu')
.where('cu.is_active = true')
.orderBy('cu.code', 'ASC')
.getMany();
return all.filter(
(cu) => !cu.allowedRegimes || cu.allowedRegimes.length === 0 || cu.allowedRegimes.includes(regimeCode)
);
}
}
// ==========================================
// PAYMENT METHODS SERVICE
// ==========================================
export interface PaymentMethodFilter {
requiresBankInfo?: boolean;
active?: boolean;
}
class PaymentMethodsService {
private repository: Repository<PaymentMethod>;
constructor() {
this.repository = AppDataSource.getRepository(PaymentMethod);
}
async findAll(filter: PaymentMethodFilter = {}): Promise<PaymentMethod[]> {
logger.debug('Finding all payment methods', { filter });
const qb = this.repository.createQueryBuilder('pm');
if (filter.requiresBankInfo !== undefined) {
qb.andWhere('pm.requires_bank_info = :requiresBankInfo', {
requiresBankInfo: filter.requiresBankInfo,
});
}
if (filter.active !== undefined) {
qb.andWhere('pm.is_active = :active', { active: filter.active });
}
qb.orderBy('pm.code', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<PaymentMethod> {
logger.debug('Finding payment method by id', { id });
const method = await this.repository.findOne({ where: { id } });
if (!method) {
throw new NotFoundError('Forma de pago no encontrada');
}
return method;
}
async findByCode(code: string): Promise<PaymentMethod | null> {
logger.debug('Finding payment method by code', { code });
return this.repository.findOne({
where: { code },
});
}
}
// ==========================================
// PAYMENT TYPES SERVICE
// ==========================================
export interface PaymentTypeFilter {
active?: boolean;
}
class PaymentTypesService {
private repository: Repository<PaymentType>;
constructor() {
this.repository = AppDataSource.getRepository(PaymentType);
}
async findAll(filter: PaymentTypeFilter = {}): Promise<PaymentType[]> {
logger.debug('Finding all payment types', { filter });
const qb = this.repository.createQueryBuilder('pt');
if (filter.active !== undefined) {
qb.andWhere('pt.is_active = :active', { active: filter.active });
}
qb.orderBy('pt.code', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<PaymentType> {
logger.debug('Finding payment type by id', { id });
const type = await this.repository.findOne({ where: { id } });
if (!type) {
throw new NotFoundError('Método de pago no encontrado');
}
return type;
}
async findByCode(code: string): Promise<PaymentType | null> {
logger.debug('Finding payment type by code', { code });
return this.repository.findOne({
where: { code: code.toUpperCase() },
});
}
}
// ==========================================
// WITHHOLDING TYPES SERVICE
// ==========================================
export interface WithholdingTypeFilter {
taxCategoryId?: string;
active?: boolean;
}
class WithholdingTypesService {
private repository: Repository<WithholdingType>;
constructor() {
this.repository = AppDataSource.getRepository(WithholdingType);
}
async findAll(filter: WithholdingTypeFilter = {}): Promise<WithholdingType[]> {
logger.debug('Finding all withholding types', { filter });
const qb = this.repository
.createQueryBuilder('wt')
.leftJoinAndSelect('wt.taxCategory', 'taxCategory');
if (filter.taxCategoryId) {
qb.andWhere('wt.tax_category_id = :taxCategoryId', {
taxCategoryId: filter.taxCategoryId,
});
}
if (filter.active !== undefined) {
qb.andWhere('wt.is_active = :active', { active: filter.active });
}
qb.orderBy('wt.code', 'ASC');
return qb.getMany();
}
async findById(id: string): Promise<WithholdingType> {
logger.debug('Finding withholding type by id', { id });
const type = await this.repository.findOne({
where: { id },
relations: ['taxCategory'],
});
if (!type) {
throw new NotFoundError('Tipo de retención no encontrado');
}
return type;
}
async findByCode(code: string): Promise<WithholdingType | null> {
logger.debug('Finding withholding type by code', { code });
return this.repository.findOne({
where: { code },
relations: ['taxCategory'],
});
}
async findByTaxCategory(taxCategoryId: string): Promise<WithholdingType[]> {
logger.debug('Finding withholding types by tax category', { taxCategoryId });
return this.repository.find({
where: { taxCategoryId, isActive: true },
relations: ['taxCategory'],
order: { code: 'ASC' },
});
}
}
// ==========================================
// SERVICE EXPORTS
// ==========================================
export const taxCategoriesService = new TaxCategoriesService();
export const fiscalRegimesService = new FiscalRegimesService();
export const cfdiUsesService = new CfdiUsesService();
export const paymentMethodsService = new PaymentMethodsService();
export const paymentTypesService = new PaymentTypesService();
export const withholdingTypesService = new WithholdingTypesService();

View File

@ -1,281 +0,0 @@
import { Response, NextFunction } from 'express';
import {
taxCategoriesService,
fiscalRegimesService,
cfdiUsesService,
paymentMethodsService,
paymentTypesService,
withholdingTypesService,
} from './fiscal-catalogs.service.js';
import { PersonType } from './entities/fiscal-regime.entity.js';
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
class FiscalController {
// ========== TAX CATEGORIES ==========
async getTaxCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
taxNature: req.query.tax_nature as string | undefined,
active: req.query.active === 'true' ? true : undefined,
};
const categories = await taxCategoriesService.findAll(filter);
res.json({ success: true, data: categories });
} catch (error) {
next(error);
}
}
async getTaxCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const category = await taxCategoriesService.findById(req.params.id);
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
}
async getTaxCategoryByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const category = await taxCategoriesService.findByCode(req.params.code);
if (!category) {
res.status(404).json({ success: false, message: 'Categoría de impuesto no encontrada' });
return;
}
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
}
async getTaxCategoryBySatCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const category = await taxCategoriesService.findBySatCode(req.params.satCode);
if (!category) {
res.status(404).json({ success: false, message: 'Categoría de impuesto no encontrada' });
return;
}
res.json({ success: true, data: category });
} catch (error) {
next(error);
}
}
// ========== FISCAL REGIMES ==========
async getFiscalRegimes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
appliesTo: req.query.applies_to as PersonType | undefined,
active: req.query.active === 'true' ? true : undefined,
};
const regimes = await fiscalRegimesService.findAll(filter);
res.json({ success: true, data: regimes });
} catch (error) {
next(error);
}
}
async getFiscalRegime(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const regime = await fiscalRegimesService.findById(req.params.id);
res.json({ success: true, data: regime });
} catch (error) {
next(error);
}
}
async getFiscalRegimeByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const regime = await fiscalRegimesService.findByCode(req.params.code);
if (!regime) {
res.status(404).json({ success: false, message: 'Régimen fiscal no encontrado' });
return;
}
res.json({ success: true, data: regime });
} catch (error) {
next(error);
}
}
async getFiscalRegimesForPersonType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const personType = req.params.personType as PersonType;
const regimes = await fiscalRegimesService.findForPersonType(personType);
res.json({ success: true, data: regimes });
} catch (error) {
next(error);
}
}
// ========== CFDI USES ==========
async getCfdiUses(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
appliesTo: req.query.applies_to as PersonType | undefined,
active: req.query.active === 'true' ? true : undefined,
};
const uses = await cfdiUsesService.findAll(filter);
res.json({ success: true, data: uses });
} catch (error) {
next(error);
}
}
async getCfdiUse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const use = await cfdiUsesService.findById(req.params.id);
res.json({ success: true, data: use });
} catch (error) {
next(error);
}
}
async getCfdiUseByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const use = await cfdiUsesService.findByCode(req.params.code);
if (!use) {
res.status(404).json({ success: false, message: 'Uso de CFDI no encontrado' });
return;
}
res.json({ success: true, data: use });
} catch (error) {
next(error);
}
}
async getCfdiUsesForPersonType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const personType = req.params.personType as PersonType;
const uses = await cfdiUsesService.findForPersonType(personType);
res.json({ success: true, data: uses });
} catch (error) {
next(error);
}
}
async getCfdiUsesForRegime(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const uses = await cfdiUsesService.findForRegime(req.params.regimeCode);
res.json({ success: true, data: uses });
} catch (error) {
next(error);
}
}
// ========== PAYMENT METHODS ==========
async getPaymentMethods(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
requiresBankInfo: req.query.requires_bank_info === 'true' ? true : undefined,
active: req.query.active === 'true' ? true : undefined,
};
const methods = await paymentMethodsService.findAll(filter);
res.json({ success: true, data: methods });
} catch (error) {
next(error);
}
}
async getPaymentMethod(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const method = await paymentMethodsService.findById(req.params.id);
res.json({ success: true, data: method });
} catch (error) {
next(error);
}
}
async getPaymentMethodByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const method = await paymentMethodsService.findByCode(req.params.code);
if (!method) {
res.status(404).json({ success: false, message: 'Forma de pago no encontrada' });
return;
}
res.json({ success: true, data: method });
} catch (error) {
next(error);
}
}
// ========== PAYMENT TYPES ==========
async getPaymentTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
active: req.query.active === 'true' ? true : undefined,
};
const types = await paymentTypesService.findAll(filter);
res.json({ success: true, data: types });
} catch (error) {
next(error);
}
}
async getPaymentType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const type = await paymentTypesService.findById(req.params.id);
res.json({ success: true, data: type });
} catch (error) {
next(error);
}
}
async getPaymentTypeByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const type = await paymentTypesService.findByCode(req.params.code);
if (!type) {
res.status(404).json({ success: false, message: 'Método de pago no encontrado' });
return;
}
res.json({ success: true, data: type });
} catch (error) {
next(error);
}
}
// ========== WITHHOLDING TYPES ==========
async getWithholdingTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
taxCategoryId: req.query.tax_category_id as string | undefined,
active: req.query.active === 'true' ? true : undefined,
};
const types = await withholdingTypesService.findAll(filter);
res.json({ success: true, data: types });
} catch (error) {
next(error);
}
}
async getWithholdingType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const type = await withholdingTypesService.findById(req.params.id);
res.json({ success: true, data: type });
} catch (error) {
next(error);
}
}
async getWithholdingTypeByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const type = await withholdingTypesService.findByCode(req.params.code);
if (!type) {
res.status(404).json({ success: false, message: 'Tipo de retención no encontrado' });
return;
}
res.json({ success: true, data: type });
} catch (error) {
next(error);
}
}
async getWithholdingTypesByCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const types = await withholdingTypesService.findByTaxCategory(req.params.categoryId);
res.json({ success: true, data: types });
} catch (error) {
next(error);
}
}
}
export const fiscalController = new FiscalController();

View File

@ -1,45 +0,0 @@
import { Router } from 'express';
import { fiscalController } from './fiscal.controller.js';
import { authenticate } from '../../shared/middleware/auth.middleware.js';
const router = Router();
// All routes require authentication
router.use(authenticate);
// ========== TAX CATEGORIES ==========
router.get('/tax-categories', (req, res, next) => fiscalController.getTaxCategories(req, res, next));
router.get('/tax-categories/by-code/:code', (req, res, next) => fiscalController.getTaxCategoryByCode(req, res, next));
router.get('/tax-categories/by-sat-code/:satCode', (req, res, next) => fiscalController.getTaxCategoryBySatCode(req, res, next));
router.get('/tax-categories/:id', (req, res, next) => fiscalController.getTaxCategory(req, res, next));
// ========== FISCAL REGIMES ==========
router.get('/fiscal-regimes', (req, res, next) => fiscalController.getFiscalRegimes(req, res, next));
router.get('/fiscal-regimes/by-code/:code', (req, res, next) => fiscalController.getFiscalRegimeByCode(req, res, next));
router.get('/fiscal-regimes/person-type/:personType', (req, res, next) => fiscalController.getFiscalRegimesForPersonType(req, res, next));
router.get('/fiscal-regimes/:id', (req, res, next) => fiscalController.getFiscalRegime(req, res, next));
// ========== CFDI USES ==========
router.get('/cfdi-uses', (req, res, next) => fiscalController.getCfdiUses(req, res, next));
router.get('/cfdi-uses/by-code/:code', (req, res, next) => fiscalController.getCfdiUseByCode(req, res, next));
router.get('/cfdi-uses/person-type/:personType', (req, res, next) => fiscalController.getCfdiUsesForPersonType(req, res, next));
router.get('/cfdi-uses/regime/:regimeCode', (req, res, next) => fiscalController.getCfdiUsesForRegime(req, res, next));
router.get('/cfdi-uses/:id', (req, res, next) => fiscalController.getCfdiUse(req, res, next));
// ========== PAYMENT METHODS (SAT Forms of Payment) ==========
router.get('/payment-methods', (req, res, next) => fiscalController.getPaymentMethods(req, res, next));
router.get('/payment-methods/by-code/:code', (req, res, next) => fiscalController.getPaymentMethodByCode(req, res, next));
router.get('/payment-methods/:id', (req, res, next) => fiscalController.getPaymentMethod(req, res, next));
// ========== PAYMENT TYPES (SAT Payment Methods - PUE/PPD) ==========
router.get('/payment-types', (req, res, next) => fiscalController.getPaymentTypes(req, res, next));
router.get('/payment-types/by-code/:code', (req, res, next) => fiscalController.getPaymentTypeByCode(req, res, next));
router.get('/payment-types/:id', (req, res, next) => fiscalController.getPaymentType(req, res, next));
// ========== WITHHOLDING TYPES ==========
router.get('/withholding-types', (req, res, next) => fiscalController.getWithholdingTypes(req, res, next));
router.get('/withholding-types/by-code/:code', (req, res, next) => fiscalController.getWithholdingTypeByCode(req, res, next));
router.get('/withholding-types/by-category/:categoryId', (req, res, next) => fiscalController.getWithholdingTypesByCategory(req, res, next));
router.get('/withholding-types/:id', (req, res, next) => fiscalController.getWithholdingType(req, res, next));
export default router;

View File

@ -1,4 +0,0 @@
export * from './entities/index.js';
export * from './fiscal-catalogs.service.js';
export { fiscalController } from './fiscal.controller.js';
export { default as fiscalRoutes } from './fiscal.routes.js';

View File

@ -1,333 +0,0 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockEmployee } from '../../../__tests__/helpers.js';
// Mock query functions
const mockQuery = jest.fn();
const mockQueryOne = jest.fn();
jest.mock('../../../config/database.js', () => ({
query: (...args: any[]) => mockQuery(...args),
queryOne: (...args: any[]) => mockQueryOne(...args),
}));
// Import after mocking
import { employeesService } from '../employees.service.js';
import { NotFoundError, ConflictError, ValidationError } from '../../../shared/errors/index.js';
describe('EmployeesService', () => {
const tenantId = 'test-tenant-uuid';
const userId = 'test-user-uuid';
beforeEach(() => {
jest.clearAllMocks();
});
describe('findAll', () => {
it('should return employees with pagination', async () => {
const mockEmployees = [
createMockEmployee({ id: '1', firstName: 'John' }),
createMockEmployee({ id: '2', firstName: 'Jane' }),
];
mockQueryOne.mockResolvedValue({ count: '2' });
mockQuery.mockResolvedValue(mockEmployees);
const result = await employeesService.findAll(tenantId, { page: 1, limit: 20 });
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should filter by company_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await employeesService.findAll(tenantId, { company_id: 'company-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('e.company_id = $'),
expect.arrayContaining([tenantId, 'company-uuid'])
);
});
it('should filter by department_id', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await employeesService.findAll(tenantId, { department_id: 'dept-uuid' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('e.department_id = $'),
expect.arrayContaining([tenantId, 'dept-uuid'])
);
});
it('should filter by status', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await employeesService.findAll(tenantId, { status: 'active' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('e.status = $'),
expect.arrayContaining([tenantId, 'active'])
);
});
it('should filter by search term', async () => {
mockQueryOne.mockResolvedValue({ count: '0' });
mockQuery.mockResolvedValue([]);
await employeesService.findAll(tenantId, { search: 'John' });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('e.first_name ILIKE'),
expect.arrayContaining([tenantId, '%John%'])
);
});
it('should apply pagination correctly', async () => {
mockQueryOne.mockResolvedValue({ count: '50' });
mockQuery.mockResolvedValue([]);
await employeesService.findAll(tenantId, { page: 3, limit: 10 });
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('LIMIT'),
expect.arrayContaining([10, 20]) // limit=10, offset=20 (page 3)
);
});
});
describe('findById', () => {
it('should return employee when found', async () => {
const mockEmployee = createMockEmployee();
mockQueryOne.mockResolvedValue(mockEmployee);
const result = await employeesService.findById('employee-uuid-1', tenantId);
expect(result).toEqual(mockEmployee);
});
it('should throw NotFoundError when employee not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
employeesService.findById('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
const createDto = {
company_id: 'company-uuid',
employee_number: 'EMP-001',
first_name: 'John',
last_name: 'Doe',
hire_date: '2024-01-15',
};
it('should create employee successfully', async () => {
// No existing employee with same number
mockQueryOne.mockResolvedValueOnce(null);
// INSERT returns new employee
mockQueryOne.mockResolvedValueOnce({ id: 'new-uuid', ...createDto });
// findById for return value
mockQueryOne.mockResolvedValueOnce(createMockEmployee({ ...createDto }));
const result = await employeesService.create(createDto, tenantId, userId);
expect(result.first_name).toBe('John');
});
it('should throw ConflictError when employee number already exists', async () => {
mockQueryOne.mockResolvedValue({ id: 'existing-uuid' });
await expect(
employeesService.create(createDto, tenantId, userId)
).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('should update employee successfully', async () => {
const existingEmployee = createMockEmployee();
mockQueryOne.mockResolvedValue(existingEmployee);
mockQuery.mockResolvedValue([]);
const result = await employeesService.update(
'employee-uuid-1',
{ first_name: 'Updated' },
tenantId,
userId
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE hr.employees SET'),
expect.any(Array)
);
});
it('should throw NotFoundError when employee not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
employeesService.update('nonexistent-id', { first_name: 'Test' }, tenantId, userId)
).rejects.toThrow(NotFoundError);
});
it('should return unchanged employee when no fields to update', async () => {
const existingEmployee = createMockEmployee();
mockQueryOne.mockResolvedValue(existingEmployee);
const result = await employeesService.update(
'employee-uuid-1',
{},
tenantId,
userId
);
expect(result).toEqual(existingEmployee);
});
});
describe('terminate', () => {
it('should terminate active employee', async () => {
const activeEmployee = createMockEmployee({ status: 'active' });
mockQueryOne.mockResolvedValue(activeEmployee);
mockQuery.mockResolvedValue([]);
await employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'terminated'"),
expect.any(Array)
);
});
it('should throw ValidationError when employee already terminated', async () => {
const terminatedEmployee = createMockEmployee({ status: 'terminated' });
mockQueryOne.mockResolvedValue(terminatedEmployee);
await expect(
employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId)
).rejects.toThrow(ValidationError);
});
it('should also terminate active contracts', async () => {
const activeEmployee = createMockEmployee({ status: 'active' });
mockQueryOne.mockResolvedValue(activeEmployee);
mockQuery.mockResolvedValue([]);
await employeesService.terminate('employee-uuid-1', '2024-12-31', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE hr.contracts SET'),
expect.any(Array)
);
});
});
describe('reactivate', () => {
it('should reactivate terminated employee', async () => {
const terminatedEmployee = createMockEmployee({ status: 'terminated' });
mockQueryOne.mockResolvedValue(terminatedEmployee);
mockQuery.mockResolvedValue([]);
await employeesService.reactivate('employee-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'active'"),
expect.any(Array)
);
});
it('should reactivate inactive employee', async () => {
const inactiveEmployee = createMockEmployee({ status: 'inactive' });
mockQueryOne.mockResolvedValue(inactiveEmployee);
mockQuery.mockResolvedValue([]);
await employeesService.reactivate('employee-uuid-1', tenantId, userId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status = 'active'"),
expect.any(Array)
);
});
it('should throw ValidationError when employee is already active', async () => {
const activeEmployee = createMockEmployee({ status: 'active' });
mockQueryOne.mockResolvedValue(activeEmployee);
await expect(
employeesService.reactivate('employee-uuid-1', tenantId, userId)
).rejects.toThrow(ValidationError);
});
});
describe('delete', () => {
it('should delete employee without contracts or subordinates', async () => {
const employee = createMockEmployee();
mockQueryOne
.mockResolvedValueOnce(employee) // findById
.mockResolvedValueOnce({ count: '0' }) // hasContracts
.mockResolvedValueOnce({ count: '0' }); // isManager
mockQuery.mockResolvedValue([]);
await employeesService.delete('employee-uuid-1', tenantId);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM hr.employees'),
expect.any(Array)
);
});
it('should throw ConflictError when employee has contracts', async () => {
const employee = createMockEmployee();
mockQueryOne
.mockResolvedValueOnce(employee)
.mockResolvedValueOnce({ count: '5' }); // hasContracts
await expect(
employeesService.delete('employee-uuid-1', tenantId)
).rejects.toThrow(ConflictError);
});
it('should throw ConflictError when employee is a manager', async () => {
const employee = createMockEmployee();
mockQueryOne
.mockResolvedValueOnce(employee)
.mockResolvedValueOnce({ count: '0' }) // hasContracts
.mockResolvedValueOnce({ count: '3' }); // isManager
await expect(
employeesService.delete('employee-uuid-1', tenantId)
).rejects.toThrow(ConflictError);
});
});
describe('getSubordinates', () => {
it('should return subordinates of a manager', async () => {
const manager = createMockEmployee();
const subordinates = [
createMockEmployee({ id: 'sub-1', firstName: 'Sub 1' }),
createMockEmployee({ id: 'sub-2', firstName: 'Sub 2' }),
];
mockQueryOne.mockResolvedValue(manager);
mockQuery.mockResolvedValue(subordinates);
const result = await employeesService.getSubordinates('manager-uuid', tenantId);
expect(result).toHaveLength(2);
});
it('should throw NotFoundError when manager not found', async () => {
mockQueryOne.mockResolvedValue(null);
await expect(
employeesService.getSubordinates('nonexistent-id', tenantId)
).rejects.toThrow(NotFoundError);
});
});
});

View File

@ -1,366 +0,0 @@
/**
* @fileoverview Unit tests for ProductsService
* Tests cover CRUD operations, stock queries, validation, and error handling
*/
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Product, ProductType, TrackingType, ValuationMethod } from '../entities/product.entity';
import { StockQuant } from '../entities/stock-quant.entity';
// Mock the AppDataSource before importing the service
jest.mock('../../../config/typeorm.js', () => ({
AppDataSource: {
getRepository: jest.fn(),
},
}));
// Mock logger
jest.mock('../../../shared/utils/logger.js', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
// Import after mocking
import { AppDataSource } from '../../../config/typeorm.js';
describe('ProductsService', () => {
let mockProductRepository: Partial<Repository<Product>>;
let mockStockQuantRepository: Partial<Repository<StockQuant>>;
let mockQueryBuilder: Partial<SelectQueryBuilder<Product>>;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockProductId = '550e8400-e29b-41d4-a716-446655440010';
const mockUomId = '550e8400-e29b-41d4-a716-446655440020';
const mockCategoryId = '550e8400-e29b-41d4-a716-446655440030';
const mockProduct: Partial<Product> = {
id: mockProductId,
tenantId: mockTenantId,
name: 'Test Product',
code: 'PROD-001',
barcode: '1234567890123',
description: 'A test product description',
productType: ProductType.STORABLE,
tracking: TrackingType.NONE,
categoryId: mockCategoryId,
uomId: mockUomId,
purchaseUomId: mockUomId,
costPrice: 100.00,
listPrice: 150.00,
valuationMethod: ValuationMethod.STANDARD,
weight: 1.5,
volume: 0.5,
canBeSold: true,
canBePurchased: true,
active: true,
imageUrl: null,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
const mockStockQuant: Partial<StockQuant> = {
id: '550e8400-e29b-41d4-a716-446655440040',
tenantId: mockTenantId,
productId: mockProductId,
locationId: '550e8400-e29b-41d4-a716-446655440060',
quantity: 100,
reservedQuantity: 10,
lotId: null,
};
beforeEach(() => {
jest.clearAllMocks();
// Setup mock query builder
mockQueryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[mockProduct], 1]),
getMany: jest.fn().mockResolvedValue([mockProduct]),
};
// Setup mock repositories
mockProductRepository = {
create: jest.fn().mockReturnValue(mockProduct),
save: jest.fn().mockResolvedValue(mockProduct),
findOne: jest.fn().mockResolvedValue(mockProduct),
find: jest.fn().mockResolvedValue([mockProduct]),
update: jest.fn().mockResolvedValue({ affected: 1 }),
softDelete: jest.fn().mockResolvedValue({ affected: 1 }),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};
mockStockQuantRepository = {
find: jest.fn().mockResolvedValue([mockStockQuant]),
findOne: jest.fn().mockResolvedValue(mockStockQuant),
createQueryBuilder: jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([{ total: 100, reserved: 10 }]),
}),
};
// Configure AppDataSource mock
(AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => {
if (entity === Product || entity.name === 'Product') {
return mockProductRepository;
}
if (entity === StockQuant || entity.name === 'StockQuant') {
return mockStockQuantRepository;
}
return {};
});
});
describe('Product CRUD Operations', () => {
it('should find all products with filters', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
page: 1,
limit: 20,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
expect(result.data).toBeDefined();
expect(result.total).toBe(1);
});
it('should filter products by search term', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
search: 'Test',
page: 1,
limit: 20,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should filter products by category', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
categoryId: mockCategoryId,
page: 1,
limit: 20,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should filter products by type', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
productType: ProductType.STORABLE,
page: 1,
limit: 20,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should find product by ID', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findById(
mockProductId,
mockTenantId
);
expect(mockProductRepository.findOne).toHaveBeenCalled();
expect(result).toBeDefined();
expect(result.id).toBe(mockProductId);
});
it('should throw NotFoundError when product not found', async () => {
mockProductRepository.findOne = jest.fn().mockResolvedValue(null);
const { productsService } = await import('../products.service.js');
await expect(
productsService.findById('non-existent-id', mockTenantId)
).rejects.toThrow();
});
it('should create a new product', async () => {
const createDto = {
name: 'New Product',
code: 'PROD-002',
uomId: mockUomId,
productType: ProductType.STORABLE,
costPrice: 50.00,
listPrice: 75.00,
canBeSold: true,
canBePurchased: true,
};
const { productsService } = await import('../products.service.js');
// Service signature: create(dto, tenantId, userId)
const result = await productsService.create(
createDto,
mockTenantId,
'mock-user-id'
);
expect(mockProductRepository.create).toHaveBeenCalled();
expect(mockProductRepository.save).toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should update an existing product', async () => {
const updateDto = {
name: 'Updated Product Name',
listPrice: 175.00,
};
const { productsService } = await import('../products.service.js');
// Service signature: update(id, dto, tenantId, userId)
const result = await productsService.update(
mockProductId,
updateDto,
mockTenantId,
'mock-user-id'
);
expect(mockProductRepository.findOne).toHaveBeenCalled();
expect(mockProductRepository.save).toHaveBeenCalled();
});
it('should soft delete a product', async () => {
const { productsService } = await import('../products.service.js');
// Service signature: delete(id, tenantId, userId)
await productsService.delete(mockProductId, mockTenantId, 'mock-user-id');
// Service uses .update() not .softDelete() directly
expect(mockProductRepository.update).toHaveBeenCalled();
});
});
describe('Stock Operations', () => {
it('should get product stock', async () => {
const { productsService } = await import('../products.service.js');
// Service signature: getStock(productId, tenantId)
const result = await productsService.getStock(
mockProductId,
mockTenantId
);
expect(mockStockQuantRepository.createQueryBuilder).toHaveBeenCalled();
expect(result).toBeDefined();
});
// TODO: Method removed, update test
// it('should get available quantity for product', async () => {
// const { productsService } = await import('../products.service.js');
//
// const result = await productsService.getAvailableQuantity(
// mockTenantId,
// mockCompanyId,
// mockProductId
// );
//
// expect(result).toBeDefined();
// });
});
describe('Validation', () => {
it('should validate unique product code', async () => {
// Simulate existing product with same code
mockProductRepository.findOne = jest.fn()
.mockResolvedValueOnce(mockProduct); // Find duplicate
const createDto = {
name: 'Duplicate Product',
code: 'PROD-001', // Same code as mockProduct
uomId: mockUomId,
};
// Test depends on service implementation
expect(mockProductRepository.findOne).toBeDefined();
});
it('should validate unique barcode', async () => {
mockProductRepository.findOne = jest.fn()
.mockResolvedValueOnce(mockProduct);
const createDto = {
name: 'Another Product',
barcode: '1234567890123', // Same barcode as mockProduct
uomId: mockUomId,
};
expect(mockProductRepository.findOne).toBeDefined();
});
});
describe('Product Types', () => {
it('should filter storable products only', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
productType: ProductType.STORABLE,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should filter consumable products only', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
productType: ProductType.CONSUMABLE,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should filter service products only', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
productType: ProductType.SERVICE,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
});
describe('Sales and Purchase Flags', () => {
it('should filter products that can be sold', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
canBeSold: true,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
it('should filter products that can be purchased', async () => {
const { productsService } = await import('../products.service.js');
const result = await productsService.findAll(mockTenantId, {
canBePurchased: true,
});
expect(mockProductRepository.createQueryBuilder).toHaveBeenCalled();
});
});
});

Some files were not shown because too many files have changed in this diff Show More