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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
@ -2229,6 +2231,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@ -3138,6 +3146,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-transformer": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/class-validator": {
|
||||||
|
"version": "0.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||||
|
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/validator": "^13.15.3",
|
||||||
|
"libphonenumber-js": "^1.11.1",
|
||||||
|
"validator": "^13.15.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@ -5782,6 +5807,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.12.34",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz",
|
||||||
|
"integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
|||||||
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
|
// Initialize Invoices Module
|
||||||
|
// Note: Invoices now uses routes-based approach via invoices.routes.ts in app.ts
|
||||||
if (config.invoices?.enabled) {
|
if (config.invoices?.enabled) {
|
||||||
const invoicesModule = new InvoicesModule({
|
const invoicesModuleInstance = new InvoicesModule();
|
||||||
dataSource,
|
app.use(invoicesModuleInstance.router);
|
||||||
basePath: config.invoices.basePath,
|
|
||||||
});
|
|
||||||
app.use(invoicesModule.router);
|
|
||||||
console.log('✅ Invoices module initialized');
|
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 crmRoutes from './modules/crm/crm.routes.js';
|
||||||
import hrRoutes from './modules/hr/hr.routes.js';
|
import hrRoutes from './modules/hr/hr.routes.js';
|
||||||
import reportsRoutes from './modules/reports/reports.routes.js';
|
import reportsRoutes from './modules/reports/reports.routes.js';
|
||||||
|
import invoicesRoutes from './modules/invoices/invoices.routes.js';
|
||||||
|
import productsRoutes from './modules/products/products.routes.js';
|
||||||
|
import warehousesRoutes from './modules/warehouses/warehouses.routes.js';
|
||||||
|
import fiscalRoutes from './modules/fiscal/fiscal.routes.js';
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
@ -73,6 +77,10 @@ app.use(`${apiPrefix}/system`, systemRoutes);
|
|||||||
app.use(`${apiPrefix}/crm`, crmRoutes);
|
app.use(`${apiPrefix}/crm`, crmRoutes);
|
||||||
app.use(`${apiPrefix}/hr`, hrRoutes);
|
app.use(`${apiPrefix}/hr`, hrRoutes);
|
||||||
app.use(`${apiPrefix}/reports`, reportsRoutes);
|
app.use(`${apiPrefix}/reports`, reportsRoutes);
|
||||||
|
app.use(`${apiPrefix}/invoices`, invoicesRoutes);
|
||||||
|
app.use(`${apiPrefix}/products`, productsRoutes);
|
||||||
|
app.use(`${apiPrefix}/warehouses`, warehousesRoutes);
|
||||||
|
app.use(`${apiPrefix}/fiscal`, fiscalRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
|
|||||||
@ -3,13 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import swaggerJSDoc from 'swagger-jsdoc';
|
import swaggerJSDoc from 'swagger-jsdoc';
|
||||||
import { Express } from 'express';
|
import { Application } from 'express';
|
||||||
import swaggerUi from 'swagger-ui-express';
|
import swaggerUi from 'swagger-ui-express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Swagger definition
|
// Swagger definition
|
||||||
const swaggerDefinition = {
|
const swaggerDefinition = {
|
||||||
@ -153,9 +149,9 @@ const options: swaggerJSDoc.Options = {
|
|||||||
definition: swaggerDefinition,
|
definition: swaggerDefinition,
|
||||||
// Path to the API routes for JSDoc comments
|
// Path to the API routes for JSDoc comments
|
||||||
apis: [
|
apis: [
|
||||||
path.join(__dirname, '../modules/**/*.routes.ts'),
|
path.resolve(process.cwd(), 'src/modules/**/*.routes.ts'),
|
||||||
path.join(__dirname, '../modules/**/*.routes.js'),
|
path.resolve(process.cwd(), 'src/modules/**/*.routes.js'),
|
||||||
path.join(__dirname, '../docs/openapi.yaml'),
|
path.resolve(process.cwd(), 'src/docs/openapi.yaml'),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -165,7 +161,7 @@ const swaggerSpec = swaggerJSDoc(options);
|
|||||||
/**
|
/**
|
||||||
* Setup Swagger documentation for Express app
|
* 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
|
// Swagger UI options
|
||||||
const swaggerUiOptions = {
|
const swaggerUiOptions = {
|
||||||
customCss: `
|
customCss: `
|
||||||
|
|||||||
@ -29,11 +29,16 @@ import {
|
|||||||
import { Partner } from '../modules/partners/entities/index.js';
|
import { Partner } from '../modules/partners/entities/index.js';
|
||||||
import {
|
import {
|
||||||
Currency,
|
Currency,
|
||||||
|
CurrencyRate,
|
||||||
Country,
|
Country,
|
||||||
|
State,
|
||||||
UomCategory,
|
UomCategory,
|
||||||
Uom,
|
Uom,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
PaymentTerm,
|
||||||
|
PaymentTermLine,
|
||||||
|
DiscountRule,
|
||||||
} from '../modules/core/entities/index.js';
|
} from '../modules/core/entities/index.js';
|
||||||
|
|
||||||
// Import Financial Entities
|
// Import Financial Entities
|
||||||
@ -60,11 +65,27 @@ import {
|
|||||||
Lot,
|
Lot,
|
||||||
Picking,
|
Picking,
|
||||||
StockMove,
|
StockMove,
|
||||||
|
StockLevel,
|
||||||
|
StockMovement,
|
||||||
|
InventoryCount,
|
||||||
|
InventoryCountLine,
|
||||||
InventoryAdjustment,
|
InventoryAdjustment,
|
||||||
InventoryAdjustmentLine,
|
InventoryAdjustmentLine,
|
||||||
|
TransferOrder,
|
||||||
|
TransferOrderLine,
|
||||||
StockValuationLayer,
|
StockValuationLayer,
|
||||||
} from '../modules/inventory/entities/index.js';
|
} from '../modules/inventory/entities/index.js';
|
||||||
|
|
||||||
|
// Import Fiscal Entities
|
||||||
|
import {
|
||||||
|
TaxCategory,
|
||||||
|
FiscalRegime,
|
||||||
|
CfdiUse,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentType,
|
||||||
|
WithholdingType,
|
||||||
|
} from '../modules/fiscal/entities/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TypeORM DataSource configuration
|
* TypeORM DataSource configuration
|
||||||
*
|
*
|
||||||
@ -104,11 +125,16 @@ export const AppDataSource = new DataSource({
|
|||||||
// Core Module Entities
|
// Core Module Entities
|
||||||
Partner,
|
Partner,
|
||||||
Currency,
|
Currency,
|
||||||
|
CurrencyRate,
|
||||||
Country,
|
Country,
|
||||||
|
State,
|
||||||
UomCategory,
|
UomCategory,
|
||||||
Uom,
|
Uom,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
PaymentTerm,
|
||||||
|
PaymentTermLine,
|
||||||
|
DiscountRule,
|
||||||
// Financial Entities
|
// Financial Entities
|
||||||
AccountType,
|
AccountType,
|
||||||
Account,
|
Account,
|
||||||
@ -129,9 +155,22 @@ export const AppDataSource = new DataSource({
|
|||||||
Lot,
|
Lot,
|
||||||
Picking,
|
Picking,
|
||||||
StockMove,
|
StockMove,
|
||||||
|
StockLevel,
|
||||||
|
StockMovement,
|
||||||
|
InventoryCount,
|
||||||
|
InventoryCountLine,
|
||||||
InventoryAdjustment,
|
InventoryAdjustment,
|
||||||
InventoryAdjustmentLine,
|
InventoryAdjustmentLine,
|
||||||
|
TransferOrder,
|
||||||
|
TransferOrderLine,
|
||||||
StockValuationLayer,
|
StockValuationLayer,
|
||||||
|
// Fiscal Entities
|
||||||
|
TaxCategory,
|
||||||
|
FiscalRegime,
|
||||||
|
CfdiUse,
|
||||||
|
PaymentMethod,
|
||||||
|
PaymentType,
|
||||||
|
WithholdingType,
|
||||||
],
|
],
|
||||||
|
|
||||||
// Directorios de migraciones (para uso futuro)
|
// Directorios de migraciones (para uso futuro)
|
||||||
|
|||||||
@ -124,7 +124,6 @@ export class AIService {
|
|||||||
.update()
|
.update()
|
||||||
.set({
|
.set({
|
||||||
usageCount: () => 'usage_count + 1',
|
usageCount: () => 'usage_count + 1',
|
||||||
lastUsedAt: new Date(),
|
|
||||||
})
|
})
|
||||||
.where('id = :id', { id })
|
.where('id = :id', { id })
|
||||||
.execute();
|
.execute();
|
||||||
@ -339,9 +338,9 @@ export class AIService {
|
|||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update()
|
.update()
|
||||||
.set({
|
.set({
|
||||||
currentRequestsMonth: () => `current_requests_month + ${requestCount}`,
|
currentRequests: () => `current_requests + ${requestCount}`,
|
||||||
currentTokensMonth: () => `current_tokens_month + ${tokenCount}`,
|
currentTokens: () => `current_tokens + ${tokenCount}`,
|
||||||
currentSpendMonth: () => `current_spend_month + ${costUsd}`,
|
currentCost: () => `current_cost + ${costUsd}`,
|
||||||
})
|
})
|
||||||
.where('tenant_id = :tenantId', { tenantId })
|
.where('tenant_id = :tenantId', { tenantId })
|
||||||
.execute();
|
.execute();
|
||||||
@ -354,15 +353,15 @@ export class AIService {
|
|||||||
const quota = await this.getTenantQuota(tenantId);
|
const quota = await this.getTenantQuota(tenantId);
|
||||||
if (!quota) return { available: true };
|
if (!quota) return { available: true };
|
||||||
|
|
||||||
if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) {
|
if (quota.monthlyRequestLimit && quota.currentRequests >= quota.monthlyRequestLimit) {
|
||||||
return { available: false, reason: 'Monthly request limit reached' };
|
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' };
|
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' };
|
return { available: false, reason: 'Monthly spend limit reached' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,10 +372,9 @@ export class AIService {
|
|||||||
const result = await this.quotaRepository.update(
|
const result = await this.quotaRepository.update(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
currentRequestsMonth: 0,
|
currentRequests: 0,
|
||||||
currentTokensMonth: 0,
|
currentTokens: 0,
|
||||||
currentSpendMonth: 0,
|
currentCost: 0,
|
||||||
lastResetAt: new Date(),
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return result.affected ?? 0;
|
return result.affected ?? 0;
|
||||||
|
|||||||
@ -180,20 +180,13 @@ export class AuditController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
|
private async markSessionLogout(_req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||||
try {
|
// Note: Session logout tracking requires a separate Session entity
|
||||||
const { sessionId } = req.params;
|
// LoginHistory only tracks login attempts, not active sessions
|
||||||
const marked = await this.auditService.markSessionLogout(sessionId);
|
res.status(501).json({
|
||||||
|
error: 'Session logout tracking not implemented',
|
||||||
if (!marked) {
|
message: 'Use the Auth module session endpoints for logout tracking',
|
||||||
res.status(404).json({ error: 'Session not found' });
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ data: { success: true } });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@ -56,9 +56,9 @@ export class AuditService {
|
|||||||
const where: FindOptionsWhere<AuditLog> = { tenantId };
|
const where: FindOptionsWhere<AuditLog> = { tenantId };
|
||||||
|
|
||||||
if (filters.userId) where.userId = filters.userId;
|
if (filters.userId) where.userId = filters.userId;
|
||||||
if (filters.entityType) where.entityType = filters.entityType;
|
if (filters.entityType) where.resourceType = filters.entityType;
|
||||||
if (filters.action) where.action = filters.action as any;
|
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.ipAddress) where.ipAddress = filters.ipAddress;
|
||||||
|
|
||||||
if (filters.startDate && filters.endDate) {
|
if (filters.startDate && filters.endDate) {
|
||||||
@ -85,7 +85,7 @@ export class AuditService {
|
|||||||
entityId: string
|
entityId: string
|
||||||
): Promise<AuditLog[]> {
|
): Promise<AuditLog[]> {
|
||||||
return this.auditLogRepository.find({
|
return this.auditLogRepository.find({
|
||||||
where: { tenantId, entityType, entityId },
|
where: { tenantId, resourceType: entityType, resourceId: entityId },
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -143,24 +143,21 @@ export class AuditService {
|
|||||||
|
|
||||||
return this.loginHistoryRepository.find({
|
return this.loginHistoryRepository.find({
|
||||||
where,
|
where,
|
||||||
order: { loginAt: 'DESC' },
|
order: { attemptedAt: 'DESC' },
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveSessionsCount(userId: string): Promise<number> {
|
async getActiveSessionsCount(userId: string): Promise<number> {
|
||||||
|
// Note: LoginHistory tracks login attempts, not sessions
|
||||||
|
// This counts successful login attempts (not truly active sessions)
|
||||||
return this.loginHistoryRepository.count({
|
return this.loginHistoryRepository.count({
|
||||||
where: { userId, logoutAt: undefined, status: 'success' },
|
where: { userId, status: 'success' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markSessionLogout(sessionId: string): Promise<boolean> {
|
// Note: Session logout tracking requires a separate Session entity
|
||||||
const result = await this.loginHistoryRepository.update(
|
// LoginHistory only tracks login attempts
|
||||||
{ sessionId },
|
|
||||||
{ logoutAt: new Date() }
|
|
||||||
);
|
|
||||||
return (result.affected ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// SENSITIVE DATA ACCESS
|
// SENSITIVE DATA ACCESS
|
||||||
@ -216,7 +213,7 @@ export class AuditService {
|
|||||||
|
|
||||||
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
||||||
return this.dataExportRepository.find({
|
return this.dataExportRepository.find({
|
||||||
where: { tenantId, requestedBy: userId },
|
where: { tenantId, userId },
|
||||||
order: { requestedAt: 'DESC' },
|
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,
|
tenantId: string,
|
||||||
configKey: string,
|
configKey: string,
|
||||||
version: number
|
date: Date
|
||||||
): Promise<ConfigChange | null> {
|
): Promise<ConfigChange | null> {
|
||||||
return this.configChangeRepository.findOne({
|
return this.configChangeRepository.findOne({
|
||||||
where: { tenantId, configKey, version },
|
where: { tenantId, configKey },
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,7 +181,10 @@ class ApiKeysController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dto: UpdateApiKeyDto = {
|
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
|
expiration_date: validation.data.expiration_date
|
||||||
? new Date(validation.data.expiration_date)
|
? new Date(validation.data.expiration_date)
|
||||||
: validation.data.expiration_date === null
|
: validation.data.expiration_date === null
|
||||||
|
|||||||
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 { OAuthProvider } from './oauth-provider.entity.js';
|
||||||
export { OAuthUserLink } from './oauth-user-link.entity.js';
|
export { OAuthUserLink } from './oauth-user-link.entity.js';
|
||||||
export { OAuthState } from './oauth-state.entity.js';
|
export { OAuthState } from './oauth-state.entity.js';
|
||||||
|
export { UserProfile } from './user-profile.entity.js';
|
||||||
|
export { ProfileTool } from './profile-tool.entity.js';
|
||||||
|
export { ProfileModule } from './profile-module.entity.js';
|
||||||
|
export { UserProfileAssignment } from './user-profile-assignment.entity.js';
|
||||||
|
export { Device } from './device.entity.js';
|
||||||
|
|||||||
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' })
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
|
||||||
isSuperuser: boolean;
|
isSuperuser: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_superadmin', default: false })
|
||||||
|
isSuperadmin: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_enabled', default: false })
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_secret_encrypted', type: 'text', nullable: true })
|
||||||
|
mfaSecretEncrypted: string;
|
||||||
|
|
||||||
|
@Column({ name: 'mfa_backup_codes', type: 'text', array: true, nullable: true })
|
||||||
|
mfaBackupCodes: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'oauth_provider', length: 50, nullable: true })
|
||||||
|
oauthProvider: string;
|
||||||
|
|
||||||
|
@Column({ name: 'oauth_provider_id', length: 255, nullable: true })
|
||||||
|
oauthProviderId: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'timestamp',
|
type: 'timestamp',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|||||||
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/usage-tracking.entity').UsageTracking,
|
||||||
require('./entities/invoice.entity').Invoice,
|
require('./entities/invoice.entity').Invoice,
|
||||||
require('./entities/invoice-item.entity').InvoiceItem,
|
require('./entities/invoice-item.entity').InvoiceItem,
|
||||||
|
require('./entities/plan-feature.entity').PlanFeature,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { SubscriptionPlan, PlanType } from './subscription-plan.entity.js';
|
||||||
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
|
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity.js';
|
||||||
export { UsageTracking } from './usage-tracking.entity';
|
export { UsageTracking } from './usage-tracking.entity.js';
|
||||||
export { UsageEvent, EventCategory } from './usage-event.entity';
|
export { UsageEvent, EventCategory } from './usage-event.entity.js';
|
||||||
export { Invoice, InvoiceStatus } from './invoice.entity';
|
export { Invoice, InvoiceStatus, InvoiceContext, InvoiceType, InvoiceItem } from './invoice.entity.js';
|
||||||
export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
|
export { InvoiceItemType } from './invoice-item.entity.js';
|
||||||
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
|
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity.js';
|
||||||
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';
|
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,
|
* @deprecated Use Invoice from 'modules/invoices/entities' instead.
|
||||||
PrimaryGeneratedColumn,
|
*
|
||||||
Column,
|
* This entity has been unified with the commercial Invoice entity.
|
||||||
CreateDateColumn,
|
* Both SaaS billing and commercial invoices now use the same table.
|
||||||
UpdateDateColumn,
|
*
|
||||||
Index,
|
* Migration guide:
|
||||||
OneToMany,
|
* - Import from: import { Invoice, InvoiceStatus, InvoiceContext } from '../../invoices/entities/invoice.entity';
|
||||||
} from 'typeorm';
|
* - Set invoiceContext: 'saas' for SaaS billing invoices
|
||||||
import { InvoiceItem } from './invoice-item.entity';
|
* - 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' })
|
// Re-export InvoiceItem as well since it's used together
|
||||||
export class Invoice {
|
export { InvoiceItem } from './invoice-item.entity';
|
||||||
@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[];
|
|
||||||
}
|
|
||||||
|
|||||||
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 })
|
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
// Stripe integration
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_subscription_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
lastPaymentAmount?: number;
|
||||||
|
|
||||||
// Precios actuales
|
// Precios actuales
|
||||||
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
currentPrice: number;
|
currentPrice: number;
|
||||||
|
|||||||
@ -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
|
* Billing Usage Services Index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { SubscriptionPlansService } from './subscription-plans.service';
|
export { SubscriptionPlansService } from './subscription-plans.service.js';
|
||||||
export { SubscriptionsService } from './subscriptions.service';
|
export { SubscriptionsService } from './subscriptions.service.js';
|
||||||
export { UsageTrackingService } from './usage-tracking.service';
|
export { UsageTrackingService } from './usage-tracking.service.js';
|
||||||
export { InvoicesService } from './invoices.service';
|
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 discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
|
||||||
|
|
||||||
const item = this.itemRepository.create({
|
const item = this.itemRepository.create({
|
||||||
invoiceId: savedInvoice.id,
|
|
||||||
itemType: itemDto.itemType,
|
itemType: itemDto.itemType,
|
||||||
description: itemDto.description,
|
description: itemDto.description,
|
||||||
quantity: itemDto.quantity,
|
quantity: itemDto.quantity,
|
||||||
unitPrice: itemDto.unitPrice,
|
unitPrice: itemDto.unitPrice,
|
||||||
discountPercent: itemDto.discountPercent || 0,
|
|
||||||
subtotal: itemTotal - discount,
|
subtotal: itemTotal - discount,
|
||||||
metadata: itemDto.metadata || {},
|
metadata: itemDto.metadata || {},
|
||||||
});
|
});
|
||||||
|
item.invoiceId = savedInvoice.id;
|
||||||
|
|
||||||
await this.itemRepository.save(item);
|
await this.itemRepository.save(item);
|
||||||
}
|
}
|
||||||
@ -310,7 +309,7 @@ export class InvoicesService {
|
|||||||
|
|
||||||
invoice.paidAmount = newPaidAmount;
|
invoice.paidAmount = newPaidAmount;
|
||||||
invoice.paymentMethod = dto.paymentMethod;
|
invoice.paymentMethod = dto.paymentMethod;
|
||||||
invoice.paymentReference = dto.paymentReference;
|
invoice.paymentReference = dto.paymentReference || '';
|
||||||
|
|
||||||
if (newPaidAmount >= total) {
|
if (newPaidAmount >= total) {
|
||||||
invoice.status = 'paid';
|
invoice.status = 'paid';
|
||||||
@ -406,12 +405,15 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const byStatus: Record<InvoiceStatus, number> = {
|
const byStatus: Record<InvoiceStatus, number> = {
|
||||||
draft: 0,
|
draft: 0,
|
||||||
|
validated: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
paid: 0,
|
paid: 0,
|
||||||
partial: 0,
|
partial: 0,
|
||||||
overdue: 0,
|
overdue: 0,
|
||||||
void: 0,
|
void: 0,
|
||||||
refunded: 0,
|
refunded: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
voided: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
@ -430,7 +432,7 @@ export class InvoicesService {
|
|||||||
const pending = Number(invoice.total) - Number(invoice.paidAmount);
|
const pending = Number(invoice.total) - Number(invoice.paidAmount);
|
||||||
pendingAmount += pending;
|
pendingAmount += pending;
|
||||||
|
|
||||||
if (invoice.dueDate < now) {
|
if (invoice.dueDate && invoice.dueDate < now) {
|
||||||
overdueAmount += pending;
|
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');
|
throw new Error('Subscription is already cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription.cancellationReason = dto.reason;
|
subscription.cancellationReason = dto.reason || '';
|
||||||
subscription.cancelledAt = new Date();
|
subscription.cancelledAt = new Date();
|
||||||
|
|
||||||
if (dto.cancelImmediately) {
|
if (dto.cancelImmediately) {
|
||||||
|
|||||||
@ -258,13 +258,17 @@ export class BranchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assignment = this.assignmentRepository.create({
|
const assignment = this.assignmentRepository.create({
|
||||||
...dto,
|
userId: dto.userId,
|
||||||
|
branchId: dto.branchId,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
assignmentType: (dto.assignmentType || 'primary') as any,
|
||||||
|
branchRole: dto.branchRole as any,
|
||||||
|
permissions: dto.permissions || [],
|
||||||
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
|
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
|
||||||
createdBy: assignedBy,
|
createdBy: assignedBy,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return this.assignmentRepository.save(assignment);
|
return this.assignmentRepository.save(assignment) as Promise<UserBranchAssignment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async unassignUser(userId: string, branchId: string): Promise<boolean> {
|
async unassignUser(userId: string, branchId: string): Promise<boolean> {
|
||||||
|
|||||||
@ -2,8 +2,14 @@ import { Response, NextFunction } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
|
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
|
||||||
import { countriesService } from './countries.service.js';
|
import { countriesService } from './countries.service.js';
|
||||||
|
import { statesService, CreateStateDto, UpdateStateDto } from './states.service.js';
|
||||||
|
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './currency-rates.service.js';
|
||||||
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
|
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
|
||||||
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
|
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
|
||||||
|
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
|
||||||
|
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
|
||||||
|
import { PaymentTermLineType } from './entities/payment-term.entity.js';
|
||||||
|
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
|
||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
@ -58,6 +64,185 @@ const updateCategorySchema = z.object({
|
|||||||
active: z.boolean().optional(),
|
active: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Payment Terms Schemas
|
||||||
|
const paymentTermLineSchema = z.object({
|
||||||
|
sequence: z.number().int().min(1).optional(),
|
||||||
|
line_type: z.enum(['balance', 'percent', 'fixed']).optional(),
|
||||||
|
lineType: z.enum(['balance', 'percent', 'fixed']).optional(),
|
||||||
|
value_percent: z.number().min(0).max(100).optional(),
|
||||||
|
valuePercent: z.number().min(0).max(100).optional(),
|
||||||
|
value_amount: z.number().min(0).optional(),
|
||||||
|
valueAmount: z.number().min(0).optional(),
|
||||||
|
days: z.number().int().min(0).optional(),
|
||||||
|
day_of_month: z.number().int().min(1).max(31).optional(),
|
||||||
|
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
||||||
|
end_of_month: z.boolean().optional(),
|
||||||
|
endOfMonth: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPaymentTermSchema = z.object({
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
due_days: z.number().int().min(0).optional(),
|
||||||
|
dueDays: z.number().int().min(0).optional(),
|
||||||
|
discount_percent: z.number().min(0).max(100).optional(),
|
||||||
|
discountPercent: z.number().min(0).max(100).optional(),
|
||||||
|
discount_days: z.number().int().min(0).optional(),
|
||||||
|
discountDays: z.number().int().min(0).optional(),
|
||||||
|
is_immediate: z.boolean().optional(),
|
||||||
|
isImmediate: z.boolean().optional(),
|
||||||
|
lines: z.array(paymentTermLineSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePaymentTermSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
due_days: z.number().int().min(0).optional(),
|
||||||
|
dueDays: z.number().int().min(0).optional(),
|
||||||
|
discount_percent: z.number().min(0).max(100).optional().nullable(),
|
||||||
|
discountPercent: z.number().min(0).max(100).optional().nullable(),
|
||||||
|
discount_days: z.number().int().min(0).optional().nullable(),
|
||||||
|
discountDays: z.number().int().min(0).optional().nullable(),
|
||||||
|
is_immediate: z.boolean().optional(),
|
||||||
|
isImmediate: z.boolean().optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
lines: z.array(paymentTermLineSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculateDueDateSchema = z.object({
|
||||||
|
invoice_date: z.string().datetime().optional(),
|
||||||
|
invoiceDate: z.string().datetime().optional(),
|
||||||
|
total_amount: z.number().min(0),
|
||||||
|
totalAmount: z.number().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discount Rules Schemas
|
||||||
|
const createDiscountRuleSchema = z.object({
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
description: z.string().optional(),
|
||||||
|
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||||
|
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||||
|
discount_value: z.number().min(0),
|
||||||
|
discountValue: z.number().min(0).optional(),
|
||||||
|
max_discount_amount: z.number().min(0).optional().nullable(),
|
||||||
|
maxDiscountAmount: z.number().min(0).optional().nullable(),
|
||||||
|
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||||
|
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||||
|
applies_to_id: z.string().uuid().optional().nullable(),
|
||||||
|
appliesToId: z.string().uuid().optional().nullable(),
|
||||||
|
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||||
|
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||||
|
condition_value: z.number().optional().nullable(),
|
||||||
|
conditionValue: z.number().optional().nullable(),
|
||||||
|
start_date: z.string().datetime().optional().nullable(),
|
||||||
|
startDate: z.string().datetime().optional().nullable(),
|
||||||
|
end_date: z.string().datetime().optional().nullable(),
|
||||||
|
endDate: z.string().datetime().optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).optional(),
|
||||||
|
combinable: z.boolean().optional(),
|
||||||
|
usage_limit: z.number().int().min(0).optional().nullable(),
|
||||||
|
usageLimit: z.number().int().min(0).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDiscountRuleSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||||
|
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||||
|
discount_value: z.number().min(0).optional(),
|
||||||
|
discountValue: z.number().min(0).optional(),
|
||||||
|
max_discount_amount: z.number().min(0).optional().nullable(),
|
||||||
|
maxDiscountAmount: z.number().min(0).optional().nullable(),
|
||||||
|
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||||
|
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||||
|
applies_to_id: z.string().uuid().optional().nullable(),
|
||||||
|
appliesToId: z.string().uuid().optional().nullable(),
|
||||||
|
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||||
|
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||||
|
condition_value: z.number().optional().nullable(),
|
||||||
|
conditionValue: z.number().optional().nullable(),
|
||||||
|
start_date: z.string().datetime().optional().nullable(),
|
||||||
|
startDate: z.string().datetime().optional().nullable(),
|
||||||
|
end_date: z.string().datetime().optional().nullable(),
|
||||||
|
endDate: z.string().datetime().optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).optional(),
|
||||||
|
combinable: z.boolean().optional(),
|
||||||
|
usage_limit: z.number().int().min(0).optional().nullable(),
|
||||||
|
usageLimit: z.number().int().min(0).optional().nullable(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyDiscountsSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
productId: z.string().uuid().optional(),
|
||||||
|
category_id: z.string().uuid().optional(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
customer_id: z.string().uuid().optional(),
|
||||||
|
customerId: z.string().uuid().optional(),
|
||||||
|
customer_group_id: z.string().uuid().optional(),
|
||||||
|
customerGroupId: z.string().uuid().optional(),
|
||||||
|
quantity: z.number().min(0),
|
||||||
|
unit_price: z.number().min(0),
|
||||||
|
unitPrice: z.number().min(0).optional(),
|
||||||
|
total_amount: z.number().min(0),
|
||||||
|
totalAmount: z.number().min(0).optional(),
|
||||||
|
is_first_purchase: z.boolean().optional(),
|
||||||
|
isFirstPurchase: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// States Schemas
|
||||||
|
const createStateSchema = z.object({
|
||||||
|
country_id: z.string().uuid().optional(),
|
||||||
|
countryId: z.string().uuid().optional(),
|
||||||
|
code: z.string().min(1).max(10).toUpperCase(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
timezone: z.string().max(50).optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
}).refine((data) => data.country_id !== undefined || data.countryId !== undefined, {
|
||||||
|
message: 'country_id or countryId is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
timezone: z.string().max(50).optional().nullable(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Currency Rates Schemas
|
||||||
|
const createCurrencyRateSchema = z.object({
|
||||||
|
from_currency_code: z.string().length(3).toUpperCase().optional(),
|
||||||
|
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
|
||||||
|
to_currency_code: z.string().length(3).toUpperCase().optional(),
|
||||||
|
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
|
||||||
|
rate: z.number().positive(),
|
||||||
|
rate_date: z.string().optional(),
|
||||||
|
rateDate: z.string().optional(),
|
||||||
|
source: z.enum(['manual', 'banxico', 'xe', 'openexchange']).optional(),
|
||||||
|
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
|
||||||
|
message: 'from_currency_code or fromCurrencyCode is required',
|
||||||
|
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
|
||||||
|
message: 'to_currency_code or toCurrencyCode is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertCurrencySchema = z.object({
|
||||||
|
amount: z.number().min(0),
|
||||||
|
from_currency_code: z.string().length(3).toUpperCase().optional(),
|
||||||
|
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
|
||||||
|
to_currency_code: z.string().length(3).toUpperCase().optional(),
|
||||||
|
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
|
||||||
|
date: z.string().optional(),
|
||||||
|
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
|
||||||
|
message: 'from_currency_code or fromCurrencyCode is required',
|
||||||
|
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
|
||||||
|
message: 'to_currency_code or toCurrencyCode is required',
|
||||||
|
});
|
||||||
|
|
||||||
class CoreController {
|
class CoreController {
|
||||||
// ========== CURRENCIES ==========
|
// ========== CURRENCIES ==========
|
||||||
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
@ -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 ==========
|
// ========== UOM CATEGORIES ==========
|
||||||
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
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 ==========
|
// ========== PRODUCT CATEGORIES ==========
|
||||||
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -252,6 +742,205 @@ class CoreController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== PAYMENT TERMS ==========
|
||||||
|
async getPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const paymentTerms = await paymentTermsService.findAll(req.tenantId!, activeOnly);
|
||||||
|
res.json({ success: true, data: paymentTerms });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: paymentTerm });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPaymentTermSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreatePaymentTermDto = parseResult.data;
|
||||||
|
const paymentTerm = await paymentTermsService.create(dto, req.tenantId!, req.user?.userId);
|
||||||
|
res.status(201).json({ success: true, data: paymentTerm, message: 'Término de pago creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updatePaymentTermSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdatePaymentTermDto = parseResult.data;
|
||||||
|
const paymentTerm = await paymentTermsService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
|
||||||
|
res.json({ success: true, data: paymentTerm, message: 'Término de pago actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await paymentTermsService.delete(req.params.id, req.tenantId!, req.user?.userId);
|
||||||
|
res.json({ success: true, message: 'Término de pago eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateDueDate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = calculateDueDateSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos para cálculo', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
|
||||||
|
const invoiceDate = parseResult.data.invoice_date ?? parseResult.data.invoiceDate ?? new Date().toISOString();
|
||||||
|
const totalAmount = parseResult.data.total_amount ?? parseResult.data.totalAmount ?? 0;
|
||||||
|
const result = paymentTermsService.calculateDueDate(paymentTerm, new Date(invoiceDate), totalAmount);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStandardPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const standardTerms = paymentTermsService.getStandardTerms();
|
||||||
|
res.json({ success: true, data: standardTerms });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializePaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await paymentTermsService.initializeForTenant(req.tenantId!, req.user?.userId);
|
||||||
|
res.json({ success: true, message: 'Términos de pago inicializados exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DISCOUNT RULES ==========
|
||||||
|
async getDiscountRules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const discountRules = await discountRulesService.findAll(req.tenantId!, activeOnly);
|
||||||
|
res.json({ success: true, data: discountRules });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const discountRule = await discountRulesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: discountRule });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createDiscountRuleSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateDiscountRuleDto = parseResult.data;
|
||||||
|
const discountRule = await discountRulesService.create(dto, req.tenantId!, req.user?.userId);
|
||||||
|
res.status(201).json({ success: true, data: discountRule, message: 'Regla de descuento creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateDiscountRuleSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateDiscountRuleDto = parseResult.data;
|
||||||
|
const discountRule = await discountRulesService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
|
||||||
|
res.json({ success: true, data: discountRule, message: 'Regla de descuento actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await discountRulesService.delete(req.params.id, req.tenantId!, req.user?.userId);
|
||||||
|
res.json({ success: true, message: 'Regla de descuento eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyDiscounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = applyDiscountsSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos inválidos para aplicar descuentos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const data = parseResult.data;
|
||||||
|
const context: ApplyDiscountContext = {
|
||||||
|
productId: data.product_id ?? data.productId,
|
||||||
|
categoryId: data.category_id ?? data.categoryId,
|
||||||
|
customerId: data.customer_id ?? data.customerId,
|
||||||
|
customerGroupId: data.customer_group_id ?? data.customerGroupId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
unitPrice: data.unit_price ?? data.unitPrice ?? 0,
|
||||||
|
totalAmount: data.total_amount ?? data.totalAmount ?? 0,
|
||||||
|
isFirstPurchase: data.is_first_purchase ?? data.isFirstPurchase,
|
||||||
|
};
|
||||||
|
const result = await discountRulesService.applyDiscounts(req.tenantId!, context);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetDiscountRuleUsage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const discountRule = await discountRulesService.resetUsageCount(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: discountRule, message: 'Contador de uso reiniciado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscountRulesByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const discountRules = await discountRulesService.findByProduct(req.params.productId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: discountRules });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscountRulesByCustomer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const discountRules = await discountRulesService.findByCustomer(req.params.customerId, req.tenantId!);
|
||||||
|
res.json({ success: true, data: discountRules });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const coreController = new CoreController();
|
export const coreController = new CoreController();
|
||||||
|
|||||||
@ -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', (req, res, next) => coreController.getCountries(req, res, next));
|
||||||
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
|
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
|
||||||
|
|
||||||
|
// ========== STATES ==========
|
||||||
|
router.get('/states', (req, res, next) => coreController.getStates(req, res, next));
|
||||||
|
router.get('/states/:id', (req, res, next) => coreController.getState(req, res, next));
|
||||||
|
router.get('/countries/:countryId/states', (req, res, next) => coreController.getStatesByCountry(req, res, next));
|
||||||
|
router.get('/countries/code/:countryCode/states', (req, res, next) => coreController.getStatesByCountryCode(req, res, next));
|
||||||
|
router.post('/states', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createState(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateState(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deleteState(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== CURRENCY RATES ==========
|
||||||
|
router.get('/currency-rates', (req, res, next) => coreController.getCurrencyRates(req, res, next));
|
||||||
|
router.get('/currency-rates/latest', (req, res, next) => coreController.getLatestRates(req, res, next));
|
||||||
|
router.get('/currency-rates/rate/:from/:to', (req, res, next) => coreController.getLatestRate(req, res, next));
|
||||||
|
router.get('/currency-rates/history/:from/:to', (req, res, next) => coreController.getCurrencyRateHistory(req, res, next));
|
||||||
|
router.get('/currency-rates/:id', (req, res, next) => coreController.getCurrencyRate(req, res, next));
|
||||||
|
router.post('/currency-rates', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createCurrencyRate(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/currency-rates/convert', (req, res, next) => coreController.convertCurrency(req, res, next));
|
||||||
|
router.delete('/currency-rates/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deleteCurrencyRate(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
// ========== UOM CATEGORIES ==========
|
// ========== UOM CATEGORIES ==========
|
||||||
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
|
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
|
||||||
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
|
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
|
||||||
|
|
||||||
// ========== UOM ==========
|
// ========== UOM ==========
|
||||||
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
|
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
|
||||||
|
router.get('/uom/by-code/:code', (req, res, next) => coreController.getUomByCode(req, res, next));
|
||||||
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
|
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
|
||||||
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
coreController.createUom(req, res, next)
|
coreController.createUom(req, res, next)
|
||||||
);
|
);
|
||||||
|
router.post('/uom/convert', (req, res, next) => coreController.convertUom(req, res, next));
|
||||||
|
router.get('/uom-categories/:categoryId/conversions', (req, res, next) =>
|
||||||
|
coreController.getUomConversions(req, res, next)
|
||||||
|
);
|
||||||
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
coreController.updateUom(req, res, next)
|
coreController.updateUom(req, res, next)
|
||||||
);
|
);
|
||||||
@ -48,4 +82,47 @@ router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (
|
|||||||
coreController.deleteProductCategory(req, res, next)
|
coreController.deleteProductCategory(req, res, next)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ========== PAYMENT TERMS ==========
|
||||||
|
router.get('/payment-terms', (req, res, next) => coreController.getPaymentTerms(req, res, next));
|
||||||
|
router.get('/payment-terms/standard', (req, res, next) => coreController.getStandardPaymentTerms(req, res, next));
|
||||||
|
router.get('/payment-terms/:id', (req, res, next) => coreController.getPaymentTerm(req, res, next));
|
||||||
|
router.post('/payment-terms', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createPaymentTerm(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payment-terms/initialize', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.initializePaymentTerms(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payment-terms/:id/calculate-due-date', (req, res, next) =>
|
||||||
|
coreController.calculateDueDate(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/payment-terms/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updatePaymentTerm(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/payment-terms/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deletePaymentTerm(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== DISCOUNT RULES ==========
|
||||||
|
router.get('/discount-rules', (req, res, next) => coreController.getDiscountRules(req, res, next));
|
||||||
|
router.get('/discount-rules/by-product/:productId', (req, res, next) =>
|
||||||
|
coreController.getDiscountRulesByProduct(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/discount-rules/by-customer/:customerId', (req, res, next) =>
|
||||||
|
coreController.getDiscountRulesByCustomer(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/discount-rules/:id', (req, res, next) => coreController.getDiscountRule(req, res, next));
|
||||||
|
router.post('/discount-rules', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createDiscountRule(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/discount-rules/apply', (req, res, next) => coreController.applyDiscounts(req, res, next));
|
||||||
|
router.put('/discount-rules/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateDiscountRule(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/discount-rules/:id/reset-usage', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.resetDiscountRuleUsage(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/discount-rules/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deleteDiscountRule(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
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 { Currency } from './currency.entity.js';
|
||||||
export { Country } from './country.entity.js';
|
export { Country } from './country.entity.js';
|
||||||
|
export { State } from './state.entity.js';
|
||||||
|
export { CurrencyRate, RateSource } from './currency-rate.entity.js';
|
||||||
export { UomCategory } from './uom-category.entity.js';
|
export { UomCategory } from './uom-category.entity.js';
|
||||||
export { Uom, UomType } from './uom.entity.js';
|
export { Uom, UomType } from './uom.entity.js';
|
||||||
export { ProductCategory } from './product-category.entity.js';
|
export { ProductCategory } from './product-category.entity.js';
|
||||||
export { Sequence, ResetPeriod } from './sequence.entity.js';
|
export { Sequence, ResetPeriod } from './sequence.entity.js';
|
||||||
|
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity.js';
|
||||||
|
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity.js';
|
||||||
|
|||||||
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,
|
PrimaryGeneratedColumn,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Uom } from './uom.entity.js';
|
import { Uom } from './uom.entity.js';
|
||||||
|
import { Tenant } from '../../auth/entities/tenant.entity.js';
|
||||||
|
|
||||||
@Entity({ schema: 'core', name: 'uom_categories' })
|
@Entity({ schema: 'core', name: 'uom_categories' })
|
||||||
@Index('idx_uom_categories_name', ['name'], { unique: true })
|
@Index('idx_uom_categories_tenant', ['tenantId'])
|
||||||
|
@Index('idx_uom_categories_tenant_name', ['tenantId', 'name'], { unique: true })
|
||||||
export class UomCategory {
|
export class UomCategory {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column({ type: '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;
|
name: string;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
@OneToMany(() => Uom, (uom) => uom.category)
|
@OneToMany(() => Uom, (uom) => uom.category)
|
||||||
uoms: Uom[];
|
uoms: Uom[];
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import {
|
|||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
Index,
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { UomCategory } from './uom-category.entity.js';
|
import { UomCategory } from './uom-category.entity.js';
|
||||||
|
import { Tenant } from '../../auth/entities/tenant.entity.js';
|
||||||
|
|
||||||
export enum UomType {
|
export enum UomType {
|
||||||
REFERENCE = 'reference',
|
REFERENCE = 'reference',
|
||||||
@ -16,14 +18,18 @@ export enum UomType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: 'core', name: 'uom' })
|
@Entity({ schema: 'core', name: 'uom' })
|
||||||
|
@Index('idx_uom_tenant', ['tenantId'])
|
||||||
@Index('idx_uom_category_id', ['categoryId'])
|
@Index('idx_uom_category_id', ['categoryId'])
|
||||||
@Index('idx_uom_code', ['code'])
|
@Index('idx_uom_code', ['code'])
|
||||||
@Index('idx_uom_active', ['active'])
|
@Index('idx_uom_active', ['active'])
|
||||||
@Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true })
|
@Index('idx_uom_tenant_category_name', ['tenantId', 'categoryId', 'name'], { unique: true })
|
||||||
export class Uom {
|
export class Uom {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
|
||||||
@ -64,6 +70,10 @@ export class Uom {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
@ManyToOne(() => Tenant)
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
@ -71,6 +81,9 @@ export class Uom {
|
|||||||
category: UomCategory;
|
category: UomCategory;
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
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 './uom.service.js';
|
||||||
export * from './product-categories.service.js';
|
export * from './product-categories.service.js';
|
||||||
export * from './sequences.service.js';
|
export * from './sequences.service.js';
|
||||||
|
export * from './payment-terms.service.js';
|
||||||
|
export * from './discount-rules.service.js';
|
||||||
export * from './entities/index.js';
|
export * from './entities/index.js';
|
||||||
export * from './core.controller.js';
|
export * from './core.controller.js';
|
||||||
export { default as coreRoutes } from './core.routes.js';
|
export { default as coreRoutes } from './core.routes.js';
|
||||||
|
|||||||
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert quantity from one UoM to another
|
||||||
|
* Both UoMs must be in the same category
|
||||||
|
*/
|
||||||
|
async convertQuantity(quantity: number, fromUomId: string, toUomId: string): Promise<number> {
|
||||||
|
logger.debug('Converting quantity', { quantity, fromUomId, toUomId });
|
||||||
|
|
||||||
|
// Same UoM = no conversion needed
|
||||||
|
if (fromUomId === toUomId) {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromUom = await this.findById(fromUomId);
|
||||||
|
const toUom = await this.findById(toUomId);
|
||||||
|
|
||||||
|
// Validate same category
|
||||||
|
if (fromUom.categoryId !== toUom.categoryId) {
|
||||||
|
throw new Error('No se pueden convertir unidades de diferentes categorías');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert: first to reference unit, then to target unit
|
||||||
|
// quantity * fromFactor = reference quantity
|
||||||
|
// reference quantity / toFactor = target quantity
|
||||||
|
const result = (quantity * fromUom.factor) / toUom.factor;
|
||||||
|
|
||||||
|
logger.debug('Conversion result', {
|
||||||
|
quantity,
|
||||||
|
fromUom: fromUom.name,
|
||||||
|
toUom: toUom.name,
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the reference UoM for a category
|
||||||
|
*/
|
||||||
|
async getReferenceUom(categoryId: string): Promise<Uom | null> {
|
||||||
|
logger.debug('Getting reference UoM', { categoryId });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
categoryId,
|
||||||
|
uomType: 'reference' as UomType,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find UoM by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string): Promise<Uom | null> {
|
||||||
|
logger.debug('Finding UoM by code', { code });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all UoMs in a category with their conversion factors
|
||||||
|
*/
|
||||||
|
async getConversionTable(categoryId: string): Promise<{
|
||||||
|
referenceUom: Uom;
|
||||||
|
conversions: Array<{ uom: Uom; toReference: number; fromReference: number }>;
|
||||||
|
}> {
|
||||||
|
logger.debug('Getting conversion table', { categoryId });
|
||||||
|
|
||||||
|
const referenceUom = await this.getReferenceUom(categoryId);
|
||||||
|
if (!referenceUom) {
|
||||||
|
throw new NotFoundError('No se encontró unidad de referencia para esta categoría');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uoms = await this.findAll(categoryId, true);
|
||||||
|
const conversions = uoms.map(uom => ({
|
||||||
|
uom,
|
||||||
|
toReference: uom.factor, // Multiply by this to get reference unit
|
||||||
|
fromReference: 1 / uom.factor, // Multiply by this to get this unit from reference
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { referenceUom, conversions };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uomService = new UomService();
|
export const uomService = new UomService();
|
||||||
|
|||||||
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 './leads.service.js';
|
||||||
export * from './opportunities.service.js';
|
export * from './opportunities.service.js';
|
||||||
export * from './stages.service.js';
|
export * from './stages.service.js';
|
||||||
|
export * from './activities.service.js';
|
||||||
|
export * from './forecasting.service.js';
|
||||||
export * from './crm.controller.js';
|
export * from './crm.controller.js';
|
||||||
export { default as crmRoutes } from './crm.routes.js';
|
export { default as crmRoutes } from './crm.routes.js';
|
||||||
|
|||||||
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 { Flag } from './flag.entity';
|
||||||
export { TenantOverride } from './tenant-override.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 { DataSource } from 'typeorm';
|
||||||
import { FeatureFlagsService } from './services';
|
import { FeatureFlagsService } from './services';
|
||||||
import { FeatureFlagsController } from './controllers';
|
import { FeatureFlagsController } from './controllers';
|
||||||
import { Flag, TenantOverride } from './entities';
|
import { Flag, TenantOverride, FlagEvaluation } from './entities';
|
||||||
|
|
||||||
export interface FeatureFlagsModuleOptions {
|
export interface FeatureFlagsModuleOptions {
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
@ -39,6 +39,6 @@ export class FeatureFlagsModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getEntities(): Function[] {
|
static getEntities(): Function[] {
|
||||||
return [Flag, TenantOverride];
|
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
|
// Account entities
|
||||||
export { AccountType, AccountTypeEnum } from './account-type.entity.js';
|
export { AccountType, AccountTypeEnum } from './account-type.entity.js';
|
||||||
export { Account } from './account.entity.js';
|
export { Account } from './account.entity.js';
|
||||||
|
export { AccountMapping, AccountMappingType } from './account-mapping.entity.js';
|
||||||
|
|
||||||
// Journal entities
|
// Journal entities
|
||||||
export { Journal, JournalType } from './journal.entity.js';
|
export { Journal, JournalType } from './journal.entity.js';
|
||||||
|
|||||||
@ -9,37 +9,39 @@ import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.se
|
|||||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
import { ValidationError } from '../../shared/errors/index.js';
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
// Schemas
|
// Schemas - Accounts use camelCase DTOs
|
||||||
const createAccountSchema = z.object({
|
const createAccountSchema = z.object({
|
||||||
company_id: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
code: z.string().min(1).max(50),
|
code: z.string().min(1).max(50),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
account_type_id: z.string().uuid(),
|
accountTypeId: z.string().uuid(),
|
||||||
parent_id: z.string().uuid().optional(),
|
parentId: z.string().uuid().optional(),
|
||||||
currency_id: z.string().uuid().optional(),
|
currencyId: z.string().uuid().optional(),
|
||||||
is_reconcilable: z.boolean().default(false),
|
isReconcilable: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateAccountSchema = z.object({
|
const updateAccountSchema = z.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
parent_id: z.string().uuid().optional().nullable(),
|
parentId: z.string().uuid().optional().nullable(),
|
||||||
currency_id: z.string().uuid().optional().nullable(),
|
currencyId: z.string().uuid().optional().nullable(),
|
||||||
is_reconcilable: z.boolean().optional(),
|
isReconcilable: z.boolean().optional(),
|
||||||
is_deprecated: z.boolean().optional(),
|
isDeprecated: z.boolean().optional(),
|
||||||
notes: z.string().optional().nullable(),
|
notes: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountQuerySchema = z.object({
|
const accountQuerySchema = z.object({
|
||||||
company_id: z.string().uuid().optional(),
|
companyId: z.string().uuid().optional(),
|
||||||
account_type_id: z.string().uuid().optional(),
|
accountTypeId: z.string().uuid().optional(),
|
||||||
parent_id: z.string().optional(),
|
parentId: z.string().optional(),
|
||||||
is_deprecated: z.coerce.boolean().optional(),
|
isDeprecated: z.coerce.boolean().optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
page: z.coerce.number().int().positive().default(1),
|
||||||
limit: z.coerce.number().int().positive().max(100).default(50),
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Journals and Journal Entries use snake_case DTOs
|
||||||
|
|
||||||
const createJournalSchema = z.object({
|
const createJournalSchema = z.object({
|
||||||
company_id: z.string().uuid(),
|
company_id: z.string().uuid(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
|
|||||||
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 './invoices.service.js';
|
||||||
export * from './payments.service.js';
|
export * from './payments.service.js';
|
||||||
export * from './taxes.service.js';
|
export * from './taxes.service.js';
|
||||||
|
export * from './gl-posting.service.js';
|
||||||
export * from './financial.controller.js';
|
export * from './financial.controller.js';
|
||||||
export { default as financialRoutes } from './financial.routes.js';
|
export { default as financialRoutes } from './financial.routes.js';
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { query, queryOne, getClient } from '../../config/database.js';
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
import { taxesService } from './taxes.service.js';
|
import { taxesService } from './taxes.service.js';
|
||||||
|
import { glPostingService, InvoiceForPosting } from './gl-posting.service.js';
|
||||||
|
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
export interface InvoiceLine {
|
export interface InvoiceLine {
|
||||||
id: string;
|
id: string;
|
||||||
@ -409,10 +412,24 @@ class InvoicesService {
|
|||||||
values.push(dto.account_id);
|
values.push(dto.account_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate amounts
|
// Recalculate amounts using taxesService
|
||||||
const amountUntaxed = quantity * priceUnit;
|
const taxIds = dto.tax_ids ?? existingLine.tax_ids ?? [];
|
||||||
const amountTax = 0; // TODO: Calculate taxes
|
const transactionType = invoice.invoice_type === 'customer' ? 'sales' : 'purchase';
|
||||||
const amountTotal = amountUntaxed + amountTax;
|
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity,
|
||||||
|
priceUnit,
|
||||||
|
discount: 0,
|
||||||
|
taxIds,
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
transactionType
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
values.push(amountUntaxed);
|
values.push(amountUntaxed);
|
||||||
@ -468,16 +485,20 @@ class InvoicesService {
|
|||||||
throw new ValidationError('La factura debe tener al menos una línea');
|
throw new ValidationError('La factura debe tener al menos una línea');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate invoice number
|
logger.info('Validating invoice', { invoiceId: id, invoiceType: invoice.invoice_type });
|
||||||
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')}`;
|
|
||||||
|
|
||||||
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
|
`UPDATE financial.invoices SET
|
||||||
number = $1,
|
number = $1,
|
||||||
status = 'open',
|
status = 'open',
|
||||||
@ -490,7 +511,70 @@ class InvoicesService {
|
|||||||
[invoiceNumber, userId, id, tenantId]
|
[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);
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
@ -508,6 +592,31 @@ class InvoicesService {
|
|||||||
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
|
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Cancelling invoice', { invoiceId: id, invoiceNumber: invoice.number });
|
||||||
|
|
||||||
|
// Reverse journal entry if exists
|
||||||
|
if (invoice.journal_entry_id) {
|
||||||
|
try {
|
||||||
|
await glPostingService.reversePosting(
|
||||||
|
invoice.journal_entry_id,
|
||||||
|
tenantId,
|
||||||
|
`Cancelación de factura ${invoice.number}`,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
logger.info('Journal entry reversed for cancelled invoice', {
|
||||||
|
invoiceId: id,
|
||||||
|
journalEntryId: invoice.journal_entry_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to reverse journal entry', {
|
||||||
|
invoiceId: id,
|
||||||
|
journalEntryId: invoice.journal_entry_id,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
// Continue with cancellation even if reversal fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE financial.invoices SET
|
`UPDATE financial.invoices SET
|
||||||
status = 'cancelled',
|
status = 'cancelled',
|
||||||
|
|||||||
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