Compare commits
No commits in common. "3ce5c6ad1714a3fbd6aacb21fd6d99d214b438b2" and "8d201c5b5846e130c49272334d590c80e42a30be" have entirely different histories.
3ce5c6ad17
...
8d201c5b58
30
jest.config.js
Normal file
30
jest.config.js
Normal file
@ -0,0 +1,30 @@
|
||||
/** @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
31
package-lock.json
generated
@ -9,6 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
@ -2229,6 +2231,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "17.0.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||
@ -3138,6 +3146,23 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@ -5782,6 +5807,12 @@
|
||||
"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": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
||||
@ -14,6 +14,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
|
||||
102
src/__tests__/core/countries.service.test.ts
Normal file
102
src/__tests__/core/countries.service.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
210
src/__tests__/core/currencies.service.test.ts
Normal file
210
src/__tests__/core/currencies.service.test.ts
Normal file
@ -0,0 +1,210 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
src/__tests__/core/states.service.test.ts
Normal file
151
src/__tests__/core/states.service.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/__tests__/core/uom.service.test.ts
Normal file
218
src/__tests__/core/uom.service.test.ts
Normal file
@ -0,0 +1,218 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
741
src/__tests__/helpers.ts
Normal file
741
src/__tests__/helpers.ts
Normal file
@ -0,0 +1,741 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
42
src/__tests__/setup.ts
Normal file
42
src/__tests__/setup.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// 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);
|
||||
});
|
||||
@ -372,12 +372,10 @@ export function initializeModules(
|
||||
}
|
||||
|
||||
// Initialize Invoices Module
|
||||
// Note: Invoices now uses routes-based approach via invoices.routes.ts in app.ts
|
||||
if (config.invoices?.enabled) {
|
||||
const invoicesModule = new InvoicesModule({
|
||||
dataSource,
|
||||
basePath: config.invoices.basePath,
|
||||
});
|
||||
app.use(invoicesModule.router);
|
||||
const invoicesModuleInstance = new InvoicesModule();
|
||||
app.use(invoicesModuleInstance.router);
|
||||
console.log('✅ Invoices module initialized');
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,10 @@ import systemRoutes from './modules/system/system.routes.js';
|
||||
import crmRoutes from './modules/crm/crm.routes.js';
|
||||
import hrRoutes from './modules/hr/hr.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();
|
||||
|
||||
@ -73,6 +77,10 @@ app.use(`${apiPrefix}/system`, systemRoutes);
|
||||
app.use(`${apiPrefix}/crm`, crmRoutes);
|
||||
app.use(`${apiPrefix}/hr`, hrRoutes);
|
||||
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
|
||||
app.use((_req: Request, res: Response) => {
|
||||
|
||||
@ -3,13 +3,9 @@
|
||||
*/
|
||||
|
||||
import swaggerJSDoc from 'swagger-jsdoc';
|
||||
import { Express } from 'express';
|
||||
import { Application } from 'express';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Swagger definition
|
||||
const swaggerDefinition = {
|
||||
@ -153,9 +149,9 @@ const options: swaggerJSDoc.Options = {
|
||||
definition: swaggerDefinition,
|
||||
// Path to the API routes for JSDoc comments
|
||||
apis: [
|
||||
path.join(__dirname, '../modules/**/*.routes.ts'),
|
||||
path.join(__dirname, '../modules/**/*.routes.js'),
|
||||
path.join(__dirname, '../docs/openapi.yaml'),
|
||||
path.resolve(process.cwd(), 'src/modules/**/*.routes.ts'),
|
||||
path.resolve(process.cwd(), 'src/modules/**/*.routes.js'),
|
||||
path.resolve(process.cwd(), 'src/docs/openapi.yaml'),
|
||||
],
|
||||
};
|
||||
|
||||
@ -165,7 +161,7 @@ const swaggerSpec = swaggerJSDoc(options);
|
||||
/**
|
||||
* Setup Swagger documentation for Express app
|
||||
*/
|
||||
export function setupSwagger(app: Express, prefix: string = '/api/v1') {
|
||||
export function setupSwagger(app: Application, prefix: string = '/api/v1') {
|
||||
// Swagger UI options
|
||||
const swaggerUiOptions = {
|
||||
customCss: `
|
||||
|
||||
@ -29,11 +29,16 @@ import {
|
||||
import { Partner } from '../modules/partners/entities/index.js';
|
||||
import {
|
||||
Currency,
|
||||
CurrencyRate,
|
||||
Country,
|
||||
State,
|
||||
UomCategory,
|
||||
Uom,
|
||||
ProductCategory,
|
||||
Sequence,
|
||||
PaymentTerm,
|
||||
PaymentTermLine,
|
||||
DiscountRule,
|
||||
} from '../modules/core/entities/index.js';
|
||||
|
||||
// Import Financial Entities
|
||||
@ -60,11 +65,27 @@ import {
|
||||
Lot,
|
||||
Picking,
|
||||
StockMove,
|
||||
StockLevel,
|
||||
StockMovement,
|
||||
InventoryCount,
|
||||
InventoryCountLine,
|
||||
InventoryAdjustment,
|
||||
InventoryAdjustmentLine,
|
||||
TransferOrder,
|
||||
TransferOrderLine,
|
||||
StockValuationLayer,
|
||||
} 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
|
||||
*
|
||||
@ -104,11 +125,16 @@ export const AppDataSource = new DataSource({
|
||||
// Core Module Entities
|
||||
Partner,
|
||||
Currency,
|
||||
CurrencyRate,
|
||||
Country,
|
||||
State,
|
||||
UomCategory,
|
||||
Uom,
|
||||
ProductCategory,
|
||||
Sequence,
|
||||
PaymentTerm,
|
||||
PaymentTermLine,
|
||||
DiscountRule,
|
||||
// Financial Entities
|
||||
AccountType,
|
||||
Account,
|
||||
@ -129,9 +155,22 @@ export const AppDataSource = new DataSource({
|
||||
Lot,
|
||||
Picking,
|
||||
StockMove,
|
||||
StockLevel,
|
||||
StockMovement,
|
||||
InventoryCount,
|
||||
InventoryCountLine,
|
||||
InventoryAdjustment,
|
||||
InventoryAdjustmentLine,
|
||||
TransferOrder,
|
||||
TransferOrderLine,
|
||||
StockValuationLayer,
|
||||
// Fiscal Entities
|
||||
TaxCategory,
|
||||
FiscalRegime,
|
||||
CfdiUse,
|
||||
PaymentMethod,
|
||||
PaymentType,
|
||||
WithholdingType,
|
||||
],
|
||||
|
||||
// Directorios de migraciones (para uso futuro)
|
||||
|
||||
@ -124,7 +124,6 @@ export class AIService {
|
||||
.update()
|
||||
.set({
|
||||
usageCount: () => 'usage_count + 1',
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where('id = :id', { id })
|
||||
.execute();
|
||||
@ -339,9 +338,9 @@ export class AIService {
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
currentRequestsMonth: () => `current_requests_month + ${requestCount}`,
|
||||
currentTokensMonth: () => `current_tokens_month + ${tokenCount}`,
|
||||
currentSpendMonth: () => `current_spend_month + ${costUsd}`,
|
||||
currentRequests: () => `current_requests + ${requestCount}`,
|
||||
currentTokens: () => `current_tokens + ${tokenCount}`,
|
||||
currentCost: () => `current_cost + ${costUsd}`,
|
||||
})
|
||||
.where('tenant_id = :tenantId', { tenantId })
|
||||
.execute();
|
||||
@ -354,15 +353,15 @@ export class AIService {
|
||||
const quota = await this.getTenantQuota(tenantId);
|
||||
if (!quota) return { available: true };
|
||||
|
||||
if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) {
|
||||
if (quota.monthlyRequestLimit && quota.currentRequests >= quota.monthlyRequestLimit) {
|
||||
return { available: false, reason: 'Monthly request limit reached' };
|
||||
}
|
||||
|
||||
if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) {
|
||||
if (quota.monthlyTokenLimit && quota.currentTokens >= quota.monthlyTokenLimit) {
|
||||
return { available: false, reason: 'Monthly token limit reached' };
|
||||
}
|
||||
|
||||
if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) {
|
||||
if (quota.monthlyCostLimit && quota.currentCost >= quota.monthlyCostLimit) {
|
||||
return { available: false, reason: 'Monthly spend limit reached' };
|
||||
}
|
||||
|
||||
@ -373,10 +372,9 @@ export class AIService {
|
||||
const result = await this.quotaRepository.update(
|
||||
{},
|
||||
{
|
||||
currentRequestsMonth: 0,
|
||||
currentTokensMonth: 0,
|
||||
currentSpendMonth: 0,
|
||||
lastResetAt: new Date(),
|
||||
currentRequests: 0,
|
||||
currentTokens: 0,
|
||||
currentCost: 0,
|
||||
}
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
|
||||
@ -180,20 +180,13 @@ export class AuditController {
|
||||
}
|
||||
}
|
||||
|
||||
private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const marked = await this.auditService.markSessionLogout(sessionId);
|
||||
|
||||
if (!marked) {
|
||||
res.status(404).json({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ data: { success: true } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
private async markSessionLogout(_req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||
// Note: Session logout tracking requires a separate Session entity
|
||||
// LoginHistory only tracks login attempts, not active sessions
|
||||
res.status(501).json({
|
||||
error: 'Session logout tracking not implemented',
|
||||
message: 'Use the Auth module session endpoints for logout tracking',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@ -56,9 +56,9 @@ export class AuditService {
|
||||
const where: FindOptionsWhere<AuditLog> = { tenantId };
|
||||
|
||||
if (filters.userId) where.userId = filters.userId;
|
||||
if (filters.entityType) where.entityType = filters.entityType;
|
||||
if (filters.entityType) where.resourceType = filters.entityType;
|
||||
if (filters.action) where.action = filters.action as any;
|
||||
if (filters.category) where.category = filters.category as any;
|
||||
if (filters.category) where.actionCategory = filters.category as any;
|
||||
if (filters.ipAddress) where.ipAddress = filters.ipAddress;
|
||||
|
||||
if (filters.startDate && filters.endDate) {
|
||||
@ -85,7 +85,7 @@ export class AuditService {
|
||||
entityId: string
|
||||
): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.find({
|
||||
where: { tenantId, entityType, entityId },
|
||||
where: { tenantId, resourceType: entityType, resourceId: entityId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@ -143,24 +143,21 @@ export class AuditService {
|
||||
|
||||
return this.loginHistoryRepository.find({
|
||||
where,
|
||||
order: { loginAt: 'DESC' },
|
||||
order: { attemptedAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
where: { userId, logoutAt: undefined, status: 'success' },
|
||||
where: { userId, status: 'success' },
|
||||
});
|
||||
}
|
||||
|
||||
async markSessionLogout(sessionId: string): Promise<boolean> {
|
||||
const result = await this.loginHistoryRepository.update(
|
||||
{ sessionId },
|
||||
{ logoutAt: new Date() }
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
// Note: Session logout tracking requires a separate Session entity
|
||||
// LoginHistory only tracks login attempts
|
||||
|
||||
// ============================================
|
||||
// SENSITIVE DATA ACCESS
|
||||
@ -216,7 +213,7 @@ export class AuditService {
|
||||
|
||||
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
||||
return this.dataExportRepository.find({
|
||||
where: { tenantId, requestedBy: userId },
|
||||
where: { tenantId, userId },
|
||||
order: { requestedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@ -291,13 +288,16 @@ export class AuditService {
|
||||
});
|
||||
}
|
||||
|
||||
async getConfigVersion(
|
||||
// Note: ConfigChange entity doesn't track versions
|
||||
// Use changedAt timestamp to get specific config snapshots
|
||||
async getConfigChangeByDate(
|
||||
tenantId: string,
|
||||
configKey: string,
|
||||
version: number
|
||||
date: Date
|
||||
): Promise<ConfigChange | null> {
|
||||
return this.configChangeRepository.findOne({
|
||||
where: { tenantId, configKey, version },
|
||||
where: { tenantId, configKey },
|
||||
order: { changedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +181,10 @@ class ApiKeysController {
|
||||
}
|
||||
|
||||
const dto: UpdateApiKeyDto = {
|
||||
...validation.data,
|
||||
name: validation.data.name,
|
||||
scope: validation.data.scope ?? undefined,
|
||||
allowed_ips: validation.data.allowed_ips ?? undefined,
|
||||
is_active: validation.data.is_active,
|
||||
expiration_date: validation.data.expiration_date
|
||||
? new Date(validation.data.expiration_date)
|
||||
: validation.data.expiration_date === null
|
||||
|
||||
64
src/modules/auth/entities/device.entity.ts
Normal file
64
src/modules/auth/entities/device.entity.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
@ -13,3 +13,8 @@ export { MfaAuditLog, MfaEventType } from './mfa-audit-log.entity.js';
|
||||
export { OAuthProvider } from './oauth-provider.entity.js';
|
||||
export { OAuthUserLink } from './oauth-user-link.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';
|
||||
|
||||
27
src/modules/auth/entities/profile-module.entity.ts
Normal file
27
src/modules/auth/entities/profile-module.entity.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
36
src/modules/auth/entities/profile-tool.entity.ts
Normal file
36
src/modules/auth/entities/profile-tool.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
36
src/modules/auth/entities/user-profile-assignment.entity.ts
Normal file
36
src/modules/auth/entities/user-profile-assignment.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
52
src/modules/auth/entities/user-profile.entity.ts
Normal file
52
src/modules/auth/entities/user-profile.entity.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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[];
|
||||
}
|
||||
@ -60,6 +60,24 @@ export class User {
|
||||
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
|
||||
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({
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
|
||||
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal file
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal file
@ -0,0 +1,409 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
786
src/modules/billing-usage/__tests__/invoices.service.test.ts
Normal file
786
src/modules/billing-usage/__tests__/invoices.service.test.ts
Normal file
@ -0,0 +1,786 @@
|
||||
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$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal file
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal file
@ -0,0 +1,466 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,597 @@
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,408 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,502 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,423 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -53,6 +53,7 @@ export class BillingUsageModule {
|
||||
require('./entities/usage-tracking.entity').UsageTracking,
|
||||
require('./entities/invoice.entity').Invoice,
|
||||
require('./entities/invoice-item.entity').InvoiceItem,
|
||||
require('./entities/plan-feature.entity').PlanFeature,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
72
src/modules/billing-usage/entities/coupon.entity.ts
Normal file
@ -0,0 +1,72 @@
|
||||
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[];
|
||||
}
|
||||
@ -1,8 +1,13 @@
|
||||
export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
|
||||
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
|
||||
export { UsageTracking } from './usage-tracking.entity';
|
||||
export { UsageEvent, EventCategory } from './usage-event.entity';
|
||||
export { Invoice, InvoiceStatus } from './invoice.entity';
|
||||
export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
|
||||
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
|
||||
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
|
||||
export { SubscriptionPlan, PlanType } from './subscription-plan.entity.js';
|
||||
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity.js';
|
||||
export { UsageTracking } from './usage-tracking.entity.js';
|
||||
export { UsageEvent, EventCategory } from './usage-event.entity.js';
|
||||
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity.js';
|
||||
export { InvoiceItemType } from './invoice-item.entity.js';
|
||||
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity.js';
|
||||
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity.js';
|
||||
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';
|
||||
|
||||
@ -1,121 +1,17 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { InvoiceItem } from './invoice-item.entity';
|
||||
/**
|
||||
* @deprecated Use Invoice from 'modules/invoices/entities' instead.
|
||||
*
|
||||
* This entity has been unified with the commercial Invoice entity.
|
||||
* Both SaaS billing and commercial invoices now use the same table.
|
||||
*
|
||||
* Migration guide:
|
||||
* - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity';
|
||||
* - Set invoiceContext: 'saas' for SaaS billing invoices
|
||||
* - Use subscriptionId, periodStart, periodEnd for SaaS-specific fields
|
||||
*/
|
||||
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded';
|
||||
// Re-export from unified invoice entity
|
||||
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType } from '../../invoices/entities/invoice.entity';
|
||||
|
||||
@Entity({ name: 'invoices', schema: 'billing' })
|
||||
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[];
|
||||
}
|
||||
// Re-export InvoiceItem as well since it's used together
|
||||
export { InvoiceItem } from './invoice-item.entity';
|
||||
|
||||
61
src/modules/billing-usage/entities/plan-feature.entity.ts
Normal file
61
src/modules/billing-usage/entities/plan-feature.entity.ts
Normal file
@ -0,0 +1,61 @@
|
||||
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;
|
||||
}
|
||||
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
52
src/modules/billing-usage/entities/plan-limit.entity.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
43
src/modules/billing-usage/entities/stripe-event.entity.ts
Normal file
@ -0,0 +1,43 @@
|
||||
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;
|
||||
}
|
||||
@ -70,6 +70,21 @@ export class TenantSubscription {
|
||||
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
||||
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
|
||||
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||
currentPrice: number;
|
||||
|
||||
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
348
src/modules/billing-usage/services/coupons.service.ts
Normal file
348
src/modules/billing-usage/services/coupons.service.ts
Normal file
@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,10 @@
|
||||
* Billing Usage Services Index
|
||||
*/
|
||||
|
||||
export { SubscriptionPlansService } from './subscription-plans.service';
|
||||
export { SubscriptionsService } from './subscriptions.service';
|
||||
export { UsageTrackingService } from './usage-tracking.service';
|
||||
export { InvoicesService } from './invoices.service';
|
||||
export { SubscriptionPlansService } from './subscription-plans.service.js';
|
||||
export { SubscriptionsService } from './subscriptions.service.js';
|
||||
export { UsageTrackingService } from './usage-tracking.service.js';
|
||||
export { InvoicesService } from './invoices.service.js';
|
||||
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';
|
||||
|
||||
@ -76,15 +76,14 @@ export class InvoicesService {
|
||||
const discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
|
||||
|
||||
const item = this.itemRepository.create({
|
||||
invoiceId: savedInvoice.id,
|
||||
itemType: itemDto.itemType,
|
||||
description: itemDto.description,
|
||||
quantity: itemDto.quantity,
|
||||
unitPrice: itemDto.unitPrice,
|
||||
discountPercent: itemDto.discountPercent || 0,
|
||||
subtotal: itemTotal - discount,
|
||||
metadata: itemDto.metadata || {},
|
||||
});
|
||||
item.invoiceId = savedInvoice.id;
|
||||
|
||||
await this.itemRepository.save(item);
|
||||
}
|
||||
@ -310,7 +309,7 @@ export class InvoicesService {
|
||||
|
||||
invoice.paidAmount = newPaidAmount;
|
||||
invoice.paymentMethod = dto.paymentMethod;
|
||||
invoice.paymentReference = dto.paymentReference;
|
||||
invoice.paymentReference = dto.paymentReference || '';
|
||||
|
||||
if (newPaidAmount >= total) {
|
||||
invoice.status = 'paid';
|
||||
@ -406,12 +405,15 @@ export class InvoicesService {
|
||||
|
||||
const byStatus: Record<InvoiceStatus, number> = {
|
||||
draft: 0,
|
||||
validated: 0,
|
||||
sent: 0,
|
||||
paid: 0,
|
||||
partial: 0,
|
||||
overdue: 0,
|
||||
void: 0,
|
||||
refunded: 0,
|
||||
cancelled: 0,
|
||||
voided: 0,
|
||||
};
|
||||
|
||||
let totalRevenue = 0;
|
||||
@ -430,7 +432,7 @@ export class InvoicesService {
|
||||
const pending = Number(invoice.total) - Number(invoice.paidAmount);
|
||||
pendingAmount += pending;
|
||||
|
||||
if (invoice.dueDate < now) {
|
||||
if (invoice.dueDate && invoice.dueDate < now) {
|
||||
overdueAmount += pending;
|
||||
}
|
||||
}
|
||||
|
||||
334
src/modules/billing-usage/services/plan-limits.service.ts
Normal file
334
src/modules/billing-usage/services/plan-limits.service.ts
Normal file
@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
462
src/modules/billing-usage/services/stripe-webhook.service.ts
Normal file
462
src/modules/billing-usage/services/stripe-webhook.service.ts
Normal file
@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@ -135,7 +135,7 @@ export class SubscriptionsService {
|
||||
throw new Error('Subscription is already cancelled');
|
||||
}
|
||||
|
||||
subscription.cancellationReason = dto.reason;
|
||||
subscription.cancellationReason = dto.reason || '';
|
||||
subscription.cancelledAt = new Date();
|
||||
|
||||
if (dto.cancelImmediately) {
|
||||
|
||||
@ -258,13 +258,17 @@ export class BranchesService {
|
||||
}
|
||||
|
||||
const assignment = this.assignmentRepository.create({
|
||||
...dto,
|
||||
userId: dto.userId,
|
||||
branchId: dto.branchId,
|
||||
tenantId,
|
||||
assignmentType: (dto.assignmentType || 'primary') as any,
|
||||
branchRole: dto.branchRole as any,
|
||||
permissions: dto.permissions || [],
|
||||
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
|
||||
createdBy: assignedBy,
|
||||
} as any);
|
||||
});
|
||||
|
||||
return this.assignmentRepository.save(assignment);
|
||||
return this.assignmentRepository.save(assignment) as Promise<UserBranchAssignment>;
|
||||
}
|
||||
|
||||
async unassignUser(userId: string, branchId: string): Promise<boolean> {
|
||||
|
||||
@ -2,8 +2,14 @@ import { Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.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 { 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 { ValidationError } from '../../shared/errors/index.js';
|
||||
|
||||
@ -58,6 +64,185 @@ const updateCategorySchema = z.object({
|
||||
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 {
|
||||
// ========== CURRENCIES ==========
|
||||
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
@ -126,6 +311,261 @@ 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 ==========
|
||||
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
@ -195,6 +635,56 @@ 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 ==========
|
||||
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
@ -252,6 +742,205 @@ class CoreController {
|
||||
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();
|
||||
|
||||
@ -21,16 +21,50 @@ 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/: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 ==========
|
||||
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));
|
||||
|
||||
// ========== UOM ==========
|
||||
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.post('/uom', requireRoles('admin', 'super_admin'), (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) =>
|
||||
coreController.updateUom(req, res, next)
|
||||
);
|
||||
@ -48,4 +82,47 @@ router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (
|
||||
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;
|
||||
|
||||
269
src/modules/core/currency-rates.service.ts
Normal file
269
src/modules/core/currency-rates.service.ts
Normal file
@ -0,0 +1,269 @@
|
||||
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();
|
||||
527
src/modules/core/discount-rules.service.ts
Normal file
527
src/modules/core/discount-rules.service.ts
Normal file
@ -0,0 +1,527 @@
|
||||
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();
|
||||
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
@ -0,0 +1,163 @@
|
||||
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;
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
export { Currency } from './currency.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 { Uom, UomType } from './uom.entity.js';
|
||||
export { ProductCategory } from './product-category.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';
|
||||
|
||||
144
src/modules/core/entities/payment-term.entity.ts
Normal file
144
src/modules/core/entities/payment-term.entity.ts
Normal file
@ -0,0 +1,144 @@
|
||||
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;
|
||||
}
|
||||
45
src/modules/core/entities/state.entity.ts
Normal file
45
src/modules/core/entities/state.entity.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
@ -3,28 +3,43 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Uom } from './uom.entity.js';
|
||||
import { Tenant } from '../../auth/entities/tenant.entity.js';
|
||||
|
||||
@Entity({ schema: 'core', name: 'uom_categories' })
|
||||
@Index('idx_uom_categories_name', ['name'], { unique: true })
|
||||
@Index('idx_uom_categories_tenant', ['tenantId'])
|
||||
@Index('idx_uom_categories_tenant_name', ['tenantId', 'name'], { unique: true })
|
||||
export class UomCategory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: false, unique: true })
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@OneToMany(() => Uom, (uom) => uom.category)
|
||||
uoms: Uom[];
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@ -3,11 +3,13 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { UomCategory } from './uom-category.entity.js';
|
||||
import { Tenant } from '../../auth/entities/tenant.entity.js';
|
||||
|
||||
export enum UomType {
|
||||
REFERENCE = 'reference',
|
||||
@ -16,14 +18,18 @@ export enum UomType {
|
||||
}
|
||||
|
||||
@Entity({ schema: 'core', name: 'uom' })
|
||||
@Index('idx_uom_tenant', ['tenantId'])
|
||||
@Index('idx_uom_category_id', ['categoryId'])
|
||||
@Index('idx_uom_code', ['code'])
|
||||
@Index('idx_uom_active', ['active'])
|
||||
@Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true })
|
||||
@Index('idx_uom_tenant_category_name', ['tenantId', 'categoryId', 'name'], { unique: true })
|
||||
export class Uom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
||||
categoryId: string;
|
||||
|
||||
@ -64,6 +70,10 @@ export class Uom {
|
||||
active: boolean;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
|
||||
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
||||
nullable: false,
|
||||
})
|
||||
@ -71,6 +81,9 @@ export class Uom {
|
||||
category: UomCategory;
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ export * from './countries.service.js';
|
||||
export * from './uom.service.js';
|
||||
export * from './product-categories.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 './core.controller.js';
|
||||
export { default as coreRoutes } from './core.routes.js';
|
||||
|
||||
461
src/modules/core/payment-terms.service.ts
Normal file
461
src/modules/core/payment-terms.service.ts
Normal file
@ -0,0 +1,461 @@
|
||||
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();
|
||||
148
src/modules/core/states.service.ts
Normal file
148
src/modules/core/states.service.ts
Normal file
@ -0,0 +1,148 @@
|
||||
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();
|
||||
@ -157,6 +157,93 @@ class UomService {
|
||||
|
||||
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();
|
||||
|
||||
309
src/modules/crm/__tests__/leads.service.test.ts
Normal file
309
src/modules/crm/__tests__/leads.service.test.ts
Normal file
@ -0,0 +1,309 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
361
src/modules/crm/__tests__/opportunities.service.test.ts
Normal file
361
src/modules/crm/__tests__/opportunities.service.test.ts
Normal file
@ -0,0 +1,361 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
src/modules/crm/__tests__/stages.service.test.ts
Normal file
286
src/modules/crm/__tests__/stages.service.test.ts
Normal file
@ -0,0 +1,286 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
571
src/modules/crm/activities.service.ts
Normal file
571
src/modules/crm/activities.service.ts
Normal file
@ -0,0 +1,571 @@
|
||||
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();
|
||||
452
src/modules/crm/forecasting.service.ts
Normal file
452
src/modules/crm/forecasting.service.ts
Normal file
@ -0,0 +1,452 @@
|
||||
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();
|
||||
@ -1,5 +1,7 @@
|
||||
export * from './leads.service.js';
|
||||
export * from './opportunities.service.js';
|
||||
export * from './stages.service.js';
|
||||
export * from './activities.service.js';
|
||||
export * from './forecasting.service.js';
|
||||
export * from './crm.controller.js';
|
||||
export { default as crmRoutes } from './crm.routes.js';
|
||||
|
||||
53
src/modules/feature-flags/entities/flag-evaluation.entity.ts
Normal file
53
src/modules/feature-flags/entities/flag-evaluation.entity.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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;
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export { Flag } from './flag.entity';
|
||||
export { TenantOverride } from './tenant-override.entity';
|
||||
export { FlagEvaluation } from './flag-evaluation.entity';
|
||||
|
||||
@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { FeatureFlagsService } from './services';
|
||||
import { FeatureFlagsController } from './controllers';
|
||||
import { Flag, TenantOverride } from './entities';
|
||||
import { Flag, TenantOverride, FlagEvaluation } from './entities';
|
||||
|
||||
export interface FeatureFlagsModuleOptions {
|
||||
dataSource: DataSource;
|
||||
@ -39,6 +39,6 @@ export class FeatureFlagsModule {
|
||||
}
|
||||
|
||||
static getEntities(): Function[] {
|
||||
return [Flag, TenantOverride];
|
||||
return [Flag, TenantOverride, FlagEvaluation];
|
||||
}
|
||||
}
|
||||
|
||||
272
src/modules/financial/__tests__/accounts.service.spec.ts
Normal file
272
src/modules/financial/__tests__/accounts.service.spec.ts
Normal file
@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @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();
|
||||
// });
|
||||
// });
|
||||
});
|
||||
145
src/modules/financial/dto/create-bank-statement.dto.ts
Normal file
145
src/modules/financial/dto/create-bank-statement.dto.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
6
src/modules/financial/dto/index.ts
Normal file
6
src/modules/financial/dto/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* DTOs para el modulo de conciliacion bancaria
|
||||
*/
|
||||
|
||||
export * from './create-bank-statement.dto.js';
|
||||
export * from './reconcile-line.dto.js';
|
||||
171
src/modules/financial/dto/reconcile-line.dto.ts
Normal file
171
src/modules/financial/dto/reconcile-line.dto.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
75
src/modules/financial/entities/account-mapping.entity.ts
Normal file
75
src/modules/financial/entities/account-mapping.entity.ts
Normal file
@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
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;
|
||||
}
|
||||
93
src/modules/financial/entities/bank-statement-line.entity.ts
Normal file
93
src/modules/financial/entities/bank-statement-line.entity.ts
Normal file
@ -0,0 +1,93 @@
|
||||
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;
|
||||
}
|
||||
111
src/modules/financial/entities/bank-statement.entity.ts
Normal file
111
src/modules/financial/entities/bank-statement.entity.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
// Account entities
|
||||
export { AccountType, AccountTypeEnum } from './account-type.entity.js';
|
||||
export { Account } from './account.entity.js';
|
||||
export { AccountMapping, AccountMappingType } from './account-mapping.entity.js';
|
||||
|
||||
// Journal entities
|
||||
export { Journal, JournalType } from './journal.entity.js';
|
||||
|
||||
@ -9,37 +9,39 @@ import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.se
|
||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||
import { ValidationError } from '../../shared/errors/index.js';
|
||||
|
||||
// Schemas
|
||||
// Schemas - Accounts use camelCase DTOs
|
||||
const createAccountSchema = z.object({
|
||||
company_id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
code: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(255),
|
||||
account_type_id: z.string().uuid(),
|
||||
parent_id: z.string().uuid().optional(),
|
||||
currency_id: z.string().uuid().optional(),
|
||||
is_reconcilable: z.boolean().default(false),
|
||||
accountTypeId: z.string().uuid(),
|
||||
parentId: z.string().uuid().optional(),
|
||||
currencyId: z.string().uuid().optional(),
|
||||
isReconcilable: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateAccountSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
parent_id: z.string().uuid().optional().nullable(),
|
||||
currency_id: z.string().uuid().optional().nullable(),
|
||||
is_reconcilable: z.boolean().optional(),
|
||||
is_deprecated: z.boolean().optional(),
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
currencyId: z.string().uuid().optional().nullable(),
|
||||
isReconcilable: z.boolean().optional(),
|
||||
isDeprecated: z.boolean().optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const accountQuerySchema = z.object({
|
||||
company_id: z.string().uuid().optional(),
|
||||
account_type_id: z.string().uuid().optional(),
|
||||
parent_id: z.string().optional(),
|
||||
is_deprecated: z.coerce.boolean().optional(),
|
||||
companyId: z.string().uuid().optional(),
|
||||
accountTypeId: z.string().uuid().optional(),
|
||||
parentId: z.string().optional(),
|
||||
isDeprecated: z.coerce.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||
});
|
||||
|
||||
// Journals and Journal Entries use snake_case DTOs
|
||||
|
||||
const createJournalSchema = z.object({
|
||||
company_id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
|
||||
711
src/modules/financial/gl-posting.service.ts
Normal file
711
src/modules/financial/gl-posting.service.ts
Normal file
@ -0,0 +1,711 @@
|
||||
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();
|
||||
@ -4,5 +4,6 @@ export * from './journal-entries.service.js';
|
||||
export * from './invoices.service.js';
|
||||
export * from './payments.service.js';
|
||||
export * from './taxes.service.js';
|
||||
export * from './gl-posting.service.js';
|
||||
export * from './financial.controller.js';
|
||||
export { default as financialRoutes } from './financial.routes.js';
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { query, queryOne, getClient } from '../../config/database.js';
|
||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.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 {
|
||||
id: string;
|
||||
@ -409,10 +412,24 @@ class InvoicesService {
|
||||
values.push(dto.account_id);
|
||||
}
|
||||
|
||||
// Recalculate amounts
|
||||
const amountUntaxed = quantity * priceUnit;
|
||||
const amountTax = 0; // TODO: Calculate taxes
|
||||
const amountTotal = amountUntaxed + amountTax;
|
||||
// Recalculate amounts using taxesService
|
||||
const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? [];
|
||||
const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase';
|
||||
|
||||
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++}`);
|
||||
values.push(amountUntaxed);
|
||||
@ -468,16 +485,20 @@ class InvoicesService {
|
||||
throw new ValidationError('La factura debe tener al menos una línea');
|
||||
}
|
||||
|
||||
// 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')}`;
|
||||
logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type });
|
||||
|
||||
await query(
|
||||
// Generate invoice number using sequences service
|
||||
const sequenceCode = invoice.invoice_type === 'customer'
|
||||
? SEQUENCE_CODES.INVOICE_CUSTOMER
|
||||
: SEQUENCE_CODES.INVOICE_SUPPLIER;
|
||||
const invoiceNumber = await sequencesService.getNextNumber(sequenceCode, tenantId);
|
||||
|
||||
const client = await getClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Update invoice status and number
|
||||
await client.query(
|
||||
`UPDATE financial.invoices SET
|
||||
number = $1,
|
||||
status = 'open',
|
||||
@ -490,7 +511,70 @@ class InvoicesService {
|
||||
[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> {
|
||||
@ -508,6 +592,31 @@ class InvoicesService {
|
||||
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(
|
||||
`UPDATE financial.invoices SET
|
||||
status = 'cancelled',
|
||||
|
||||
810
src/modules/financial/services/bank-reconciliation.service.ts
Normal file
810
src/modules/financial/services/bank-reconciliation.service.ts
Normal file
@ -0,0 +1,810 @@
|
||||
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();
|
||||
47
src/modules/fiscal/entities/cfdi-use.entity.ts
Normal file
47
src/modules/fiscal/entities/cfdi-use.entity.ts
Normal file
@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
49
src/modules/fiscal/entities/fiscal-regime.entity.ts
Normal file
49
src/modules/fiscal/entities/fiscal-regime.entity.ts
Normal file
@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
6
src/modules/fiscal/entities/index.ts
Normal file
6
src/modules/fiscal/entities/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
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';
|
||||
36
src/modules/fiscal/entities/payment-method.entity.ts
Normal file
36
src/modules/fiscal/entities/payment-method.entity.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
33
src/modules/fiscal/entities/payment-type.entity.ts
Normal file
33
src/modules/fiscal/entities/payment-type.entity.ts
Normal file
@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
52
src/modules/fiscal/entities/tax-category.entity.ts
Normal file
52
src/modules/fiscal/entities/tax-category.entity.ts
Normal file
@ -0,0 +1,52 @@
|
||||
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;
|
||||
}
|
||||
54
src/modules/fiscal/entities/withholding-type.entity.ts
Normal file
54
src/modules/fiscal/entities/withholding-type.entity.ts
Normal file
@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
426
src/modules/fiscal/fiscal-catalogs.service.ts
Normal file
426
src/modules/fiscal/fiscal-catalogs.service.ts
Normal file
@ -0,0 +1,426 @@
|
||||
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();
|
||||
281
src/modules/fiscal/fiscal.controller.ts
Normal file
281
src/modules/fiscal/fiscal.controller.ts
Normal file
@ -0,0 +1,281 @@
|
||||
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();
|
||||
45
src/modules/fiscal/fiscal.routes.ts
Normal file
45
src/modules/fiscal/fiscal.routes.ts
Normal file
@ -0,0 +1,45 @@
|
||||
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;
|
||||
4
src/modules/fiscal/index.ts
Normal file
4
src/modules/fiscal/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
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';
|
||||
333
src/modules/hr/__tests__/employees.service.test.ts
Normal file
333
src/modules/hr/__tests__/employees.service.test.ts
Normal file
@ -0,0 +1,333 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
src/modules/inventory/__tests__/products.service.spec.ts
Normal file
366
src/modules/inventory/__tests__/products.service.spec.ts
Normal file
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @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
Loading…
Reference in New Issue
Block a user