59 KiB
59 KiB
SPEC-GASTOS-EMPLEADOS
Metadatos
| Campo | Valor |
|---|---|
| Código | SPEC-TRANS-014 |
| Versión | 1.0.0 |
| Fecha | 2025-01-15 |
| Autor | Requirements-Analyst Agent |
| Estado | DRAFT |
| Prioridad | P0 |
| Módulos Afectados | MGN-010 (RRHH Básico) |
| Gaps Cubiertos | GAP-MGN-010-002 |
Resumen Ejecutivo
Esta especificación define el sistema de gestión de gastos de empleados para ERP Core:
- Gastos Individuales: Registro de gastos con categorías, montos y comprobantes
- Reportes de Gastos: Agrupación de gastos para aprobación
- Flujo de Aprobación: Workflow multinivel (manager → contabilidad)
- Reembolso: Integración con pagos y nómina
- Políticas: Límites y validaciones por categoría
- Refacturación: Gastos refacturables a clientes/proyectos
Referencia Odoo 18
Basado en análisis del módulo hr_expense de Odoo 18:
- hr.expense: Línea de gasto individual
- hr.expense.sheet: Reporte/hoja de gastos
- product.template (con
hr_expense_ok): Categorías de gasto - Integración con contabilidad vía
account.move
Parte 1: Arquitectura del Sistema
1.1 Visión General
┌─────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE GASTOS DE EMPLEADOS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Empleado │───▶│ Gasto │◀───│ Categoría │ │
│ │ │ │ (expense) │ │ (product) │ │
│ └──────────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Expense Sheet │ │
│ │ (Reporte de Gastos) │ │
│ └──────────────┬───────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Manager │ │ Contabilidad│ │ Tesorería │ │
│ │ Approval │───▶│ Validation │───▶│ Payment │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Journal Entry │ │
│ │ + Reembolso │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
1.2 Flujo de Gastos
1. REGISTRAR GASTO
├─ Empleado crea gasto (categoría, monto, fecha)
├─ Adjunta comprobante (recibo/factura)
└─ Indica si pagó empleado o empresa
│
▼
2. CREAR REPORTE
├─ Agrupar gastos en expense_sheet
├─ Agregar descripción/justificación
└─ Enviar a aprobación
│
▼
3. APROBACIÓN MANAGER
├─ Manager revisa gastos
├─ Aprueba o rechaza
└─ Si rechaza → vuelve a draft
│
▼
4. VALIDACIÓN CONTABLE
├─ Contabilidad verifica documentación
├─ Genera asiento contable (en borrador)
└─ Publica asiento
│
▼
5. REEMBOLSO
├─ Si pagó empleado → crear pago
├─ Opciones: transferencia, efectivo, nómina
└─ Marcar como pagado
Parte 2: Modelo de Datos
2.1 Diagrama Entidad-Relación
┌─────────────────────┐ ┌─────────────────────┐
│ expense_sheets │ │ expense_categories│
│─────────────────────│ │─────────────────────│
│ id (PK) │ │ id (PK) │
│ employee_id (FK) │ │ code │
│ name │ │ name │
│ state │ │ expense_account_id │
│ submission_date │ │ cost_type │
│ approval_date │◀──┐ │ default_unit_price │
│ accounting_date │ │ │ uom_id │
│ total_amount │ │ │ reinvoiceable │
│ journal_entry_id │ │ └─────────────────────┘
└─────────┬───────────┘ │ │
│ │ │
│ 1:N │ │
▼ │ ▼
┌─────────────────────┐ │ ┌─────────────────────┐
│ expenses │ │ │ expense_policies │
│─────────────────────│ │ │─────────────────────│
│ id (PK) │ │ │ id (PK) │
│ sheet_id (FK) │───┘ │ category_id (FK) │
│ employee_id (FK) │ │ company_id (FK) │
│ category_id (FK) │───────│ max_amount │
│ name │ │ requires_receipt │
│ expense_date │ │ approval_limit │
│ unit_amount │ │ auto_approve_below │
│ quantity │ └─────────────────────┘
│ total_amount │
│ currency_id (FK) │
│ paid_by │
│ analytic_account_id │
│ state │
└─────────────────────┘
2.2 Definición de Tablas
expense_categories (Categorías de Gasto)
-- Categorías de gastos (basado en product con hr_expense_ok)
CREATE TABLE expense_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Configuración contable
expense_account_id UUID NOT NULL REFERENCES accounts(id),
-- Tipo de costo
cost_type expense_cost_type NOT NULL DEFAULT 'actual',
-- actual: empleado ingresa monto real
-- fixed: monto fijo configurado
-- mileage: cálculo por distancia
default_unit_price DECIMAL(15,4) DEFAULT 0,
uom_id UUID REFERENCES units_of_measure(id), -- km, unidad, día
-- Refacturación
reinvoiceable BOOLEAN NOT NULL DEFAULT false,
sale_account_id UUID REFERENCES accounts(id),
-- Estado
active BOOLEAN NOT NULL DEFAULT true,
company_id UUID NOT NULL REFERENCES companies(id),
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID NOT NULL REFERENCES users(id)
);
CREATE TYPE expense_cost_type AS ENUM ('actual', 'fixed', 'mileage');
-- Categorías predeterminadas
COMMENT ON TABLE expense_categories IS 'Categorías de gastos: viajes, alimentación, hospedaje, etc.';
expenses (Gastos Individuales)
-- Líneas de gasto individual
CREATE TABLE expenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
sheet_id UUID REFERENCES expense_sheets(id) ON DELETE SET NULL,
employee_id UUID NOT NULL REFERENCES employees(id),
category_id UUID NOT NULL REFERENCES expense_categories(id),
-- Descripción
name VARCHAR(255) NOT NULL,
description TEXT,
reference VARCHAR(100), -- Número de factura/recibo
-- Fecha y montos
expense_date DATE NOT NULL,
unit_amount DECIMAL(15,2) NOT NULL,
quantity DECIMAL(10,4) NOT NULL DEFAULT 1,
total_amount DECIMAL(15,2) NOT NULL GENERATED ALWAYS AS (unit_amount * quantity) STORED,
-- Moneda
currency_id UUID NOT NULL REFERENCES currencies(id),
exchange_rate DECIMAL(12,6) DEFAULT 1,
amount_company_currency DECIMAL(15,2),
-- Quién pagó
paid_by expense_paid_by NOT NULL DEFAULT 'employee',
-- Analítica (centro de costo / proyecto)
analytic_account_id UUID REFERENCES analytic_accounts(id),
project_id UUID REFERENCES projects(id),
-- Refacturación a cliente
sale_order_id UUID REFERENCES sale_orders(id),
reinvoice_status reinvoice_status DEFAULT 'no',
-- Estado del gasto individual
state expense_state NOT NULL DEFAULT 'draft',
-- Auditoría
company_id UUID NOT NULL REFERENCES companies(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID NOT NULL REFERENCES users(id),
-- Constraints
CONSTRAINT positive_amount CHECK (unit_amount >= 0),
CONSTRAINT positive_quantity CHECK (quantity > 0)
);
CREATE TYPE expense_paid_by AS ENUM ('employee', 'company');
CREATE TYPE expense_state AS ENUM ('draft', 'reported', 'submitted', 'approved', 'posted', 'done', 'refused');
CREATE TYPE reinvoice_status AS ENUM ('no', 'pending', 'invoiced');
CREATE INDEX idx_expenses_employee ON expenses(employee_id);
CREATE INDEX idx_expenses_sheet ON expenses(sheet_id);
CREATE INDEX idx_expenses_date ON expenses(expense_date);
CREATE INDEX idx_expenses_state ON expenses(state);
expense_sheets (Reportes de Gastos)
-- Hojas/reportes de gastos para aprobación
CREATE TABLE expense_sheets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
name VARCHAR(255) NOT NULL,
reference VARCHAR(50) UNIQUE, -- EXP-2025-0001
-- Empleado
employee_id UUID NOT NULL REFERENCES employees(id),
department_id UUID REFERENCES departments(id),
manager_id UUID REFERENCES employees(id),
-- Fechas
submission_date DATE,
approval_date DATE,
accounting_date DATE,
-- Totales calculados
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
currency_id UUID NOT NULL REFERENCES currencies(id),
-- Estado del workflow
state expense_sheet_state NOT NULL DEFAULT 'draft',
-- Aprobaciones
approved_by UUID REFERENCES users(id),
validated_by UUID REFERENCES users(id),
refused_by UUID REFERENCES users(id),
refuse_reason TEXT,
-- Contabilidad
journal_id UUID REFERENCES journals(id),
journal_entry_id UUID REFERENCES journal_entries(id),
-- Pago/Reembolso
payment_state payment_state DEFAULT 'not_paid',
payment_id UUID REFERENCES payments(id),
payment_mode expense_payment_mode,
-- Auditoría
company_id UUID NOT NULL REFERENCES companies(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL REFERENCES users(id),
updated_by UUID NOT NULL REFERENCES users(id)
);
CREATE TYPE expense_sheet_state AS ENUM (
'draft', -- Borrador
'submitted', -- Enviado a aprobación
'approved', -- Aprobado por manager
'posted', -- Contabilizado
'done', -- Pagado/Completado
'refused' -- Rechazado
);
CREATE TYPE payment_state AS ENUM ('not_paid', 'partial', 'paid');
CREATE TYPE expense_payment_mode AS ENUM ('bank_transfer', 'cash', 'payslip');
CREATE INDEX idx_expense_sheets_employee ON expense_sheets(employee_id);
CREATE INDEX idx_expense_sheets_state ON expense_sheets(state);
CREATE INDEX idx_expense_sheets_date ON expense_sheets(submission_date);
-- Secuencia para referencia
CREATE SEQUENCE expense_sheet_seq;
expense_attachments (Comprobantes)
-- Archivos adjuntos (recibos, facturas)
CREATE TABLE expense_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
expense_id UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE,
-- Archivo
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INTEGER,
mime_type VARCHAR(100),
-- OCR (si aplica)
ocr_processed BOOLEAN DEFAULT false,
ocr_data JSONB,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL REFERENCES users(id)
);
CREATE INDEX idx_expense_attachments_expense ON expense_attachments(expense_id);
expense_policies (Políticas de Gasto)
-- Políticas y límites por categoría
CREATE TABLE expense_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Alcance
company_id UUID NOT NULL REFERENCES companies(id),
category_id UUID REFERENCES expense_categories(id), -- NULL = todas
department_id UUID REFERENCES departments(id), -- NULL = todos
-- Límites
max_amount_per_expense DECIMAL(15,2),
max_amount_per_day DECIMAL(15,2),
max_amount_per_month DECIMAL(15,2),
-- Reglas
requires_receipt BOOLEAN NOT NULL DEFAULT true,
receipt_required_above DECIMAL(15,2), -- Requerido solo arriba de X
requires_description BOOLEAN NOT NULL DEFAULT false,
-- Auto-aprobación
auto_approve_below DECIMAL(15,2), -- Auto-aprobar montos menores
-- Aprobación multinivel
approval_limit_manager DECIMAL(15,2), -- Hasta X, solo manager
approval_limit_director DECIMAL(15,2), -- Hasta X, director
-- Arriba de director → CFO/Finance
-- Vigencia
valid_from DATE NOT NULL DEFAULT CURRENT_DATE,
valid_to DATE,
active BOOLEAN NOT NULL DEFAULT true,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_expense_policies_company ON expense_policies(company_id);
CREATE INDEX idx_expense_policies_category ON expense_policies(category_id);
Parte 3: Servicios de Aplicación
3.1 ExpenseService
// src/modules/hr-expense/services/expense.service.ts
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Expense, ExpenseState, PaidBy } from '../entities/expense.entity';
import { ExpenseSheet, ExpenseSheetState } from '../entities/expense-sheet.entity';
import { ExpenseCategory } from '../entities/expense-category.entity';
import { ExpensePolicy } from '../entities/expense-policy.entity';
@Injectable()
export class ExpenseService {
constructor(
@InjectRepository(Expense)
private readonly expenseRepo: Repository<Expense>,
@InjectRepository(ExpenseSheet)
private readonly sheetRepo: Repository<ExpenseSheet>,
@InjectRepository(ExpenseCategory)
private readonly categoryRepo: Repository<ExpenseCategory>,
@InjectRepository(ExpensePolicy)
private readonly policyRepo: Repository<ExpensePolicy>,
private readonly dataSource: DataSource,
) {}
/**
* Crear nuevo gasto
*/
async createExpense(dto: CreateExpenseDto, userId: string): Promise<Expense> {
// Obtener empleado del usuario
const employee = await this.getEmployeeByUser(userId);
// Obtener categoría y validar
const category = await this.categoryRepo.findOneOrFail({
where: { id: dto.categoryId, active: true }
});
// Calcular monto según tipo de categoría
let unitAmount = dto.unitAmount;
if (category.costType === 'fixed') {
unitAmount = category.defaultUnitPrice;
} else if (category.costType === 'mileage') {
unitAmount = category.defaultUnitPrice; // precio por km
}
// Validar políticas
await this.validateExpensePolicy({
categoryId: category.id,
amount: unitAmount * dto.quantity,
employeeId: employee.id,
companyId: employee.companyId,
});
// Crear gasto
const expense = this.expenseRepo.create({
employeeId: employee.id,
categoryId: category.id,
name: dto.name || category.name,
description: dto.description,
reference: dto.reference,
expenseDate: dto.expenseDate,
unitAmount,
quantity: dto.quantity || 1,
currencyId: dto.currencyId || employee.companyCurrencyId,
paidBy: dto.paidBy || PaidBy.EMPLOYEE,
analyticAccountId: dto.analyticAccountId,
projectId: dto.projectId,
companyId: employee.companyId,
state: ExpenseState.DRAFT,
createdBy: userId,
updatedBy: userId,
});
return this.expenseRepo.save(expense);
}
/**
* Validar gasto contra políticas
*/
private async validateExpensePolicy(params: {
categoryId: string;
amount: number;
employeeId: string;
companyId: string;
}): Promise<void> {
const policies = await this.policyRepo.find({
where: [
{ companyId: params.companyId, categoryId: params.categoryId, active: true },
{ companyId: params.companyId, categoryId: null, active: true }, // Global
],
order: { categoryId: 'DESC' } // Específica primero
});
if (policies.length === 0) return; // Sin políticas = permitido
const policy = policies[0];
// Validar monto máximo por gasto
if (policy.maxAmountPerExpense && params.amount > policy.maxAmountPerExpense) {
throw new BadRequestException(
`El monto ${params.amount} excede el máximo permitido de ${policy.maxAmountPerExpense}`
);
}
// Validar monto diario
if (policy.maxAmountPerDay) {
const todayTotal = await this.getTodayTotal(params.employeeId, params.categoryId);
if (todayTotal + params.amount > policy.maxAmountPerDay) {
throw new BadRequestException(
`El gasto excedería el límite diario de ${policy.maxAmountPerDay}`
);
}
}
// Validar monto mensual
if (policy.maxAmountPerMonth) {
const monthTotal = await this.getMonthTotal(params.employeeId, params.categoryId);
if (monthTotal + params.amount > policy.maxAmountPerMonth) {
throw new BadRequestException(
`El gasto excedería el límite mensual de ${policy.maxAmountPerMonth}`
);
}
}
}
/**
* Crear reporte de gastos (expense sheet)
*/
async createExpenseSheet(
dto: CreateExpenseSheetDto,
userId: string
): Promise<ExpenseSheet> {
const employee = await this.getEmployeeByUser(userId);
// Generar referencia
const reference = await this.generateSheetReference(employee.companyId);
// Crear hoja
const sheet = this.sheetRepo.create({
name: dto.name || `Gastos ${new Date().toLocaleDateString()}`,
reference,
employeeId: employee.id,
departmentId: employee.departmentId,
managerId: employee.managerId,
currencyId: employee.companyCurrencyId,
state: ExpenseSheetState.DRAFT,
companyId: employee.companyId,
createdBy: userId,
updatedBy: userId,
});
const savedSheet = await this.sheetRepo.save(sheet);
// Agregar gastos si se proporcionaron
if (dto.expenseIds?.length > 0) {
await this.addExpensesToSheet(savedSheet.id, dto.expenseIds, userId);
}
return this.getSheetWithTotals(savedSheet.id);
}
/**
* Agregar gastos a un reporte
*/
async addExpensesToSheet(
sheetId: string,
expenseIds: string[],
userId: string
): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId }
});
if (sheet.state !== ExpenseSheetState.DRAFT) {
throw new BadRequestException('Solo se pueden agregar gastos a reportes en borrador');
}
// Actualizar gastos
await this.expenseRepo.update(
{ id: In(expenseIds), state: ExpenseState.DRAFT },
{ sheetId, state: ExpenseState.REPORTED, updatedBy: userId }
);
// Recalcular total
return this.recalculateSheetTotal(sheetId);
}
/**
* Enviar reporte a aprobación
*/
async submitSheet(sheetId: string, userId: string): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['expenses', 'expenses.attachments']
});
// Validaciones
if (sheet.state !== ExpenseSheetState.DRAFT) {
throw new BadRequestException('El reporte ya fue enviado');
}
if (!sheet.expenses || sheet.expenses.length === 0) {
throw new BadRequestException('El reporte debe tener al menos un gasto');
}
// Validar comprobantes requeridos
await this.validateReceiptsRequired(sheet);
// Verificar auto-aprobación
const canAutoApprove = await this.checkAutoApproval(sheet);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Actualizar estado del reporte
sheet.state = canAutoApprove
? ExpenseSheetState.APPROVED
: ExpenseSheetState.SUBMITTED;
sheet.submissionDate = new Date();
sheet.updatedBy = userId;
if (canAutoApprove) {
sheet.approvalDate = new Date();
sheet.approvedBy = userId; // Sistema
}
await queryRunner.manager.save(sheet);
// Actualizar estado de gastos
await queryRunner.manager.update(Expense,
{ sheetId: sheet.id },
{ state: ExpenseState.SUBMITTED, updatedBy: userId }
);
await queryRunner.commitTransaction();
// Notificar al manager si no fue auto-aprobado
if (!canAutoApprove && sheet.managerId) {
await this.notifyManager(sheet);
}
return sheet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Verificar si aplica auto-aprobación
*/
private async checkAutoApproval(sheet: ExpenseSheet): Promise<boolean> {
const policy = await this.policyRepo.findOne({
where: { companyId: sheet.companyId, active: true },
order: { categoryId: 'DESC' }
});
if (!policy?.autoApproveBelow) return false;
return sheet.totalAmount <= policy.autoApproveBelow;
}
/**
* Validar comprobantes requeridos
*/
private async validateReceiptsRequired(sheet: ExpenseSheet): Promise<void> {
for (const expense of sheet.expenses) {
const policy = await this.policyRepo.findOne({
where: [
{ companyId: sheet.companyId, categoryId: expense.categoryId, active: true },
{ companyId: sheet.companyId, categoryId: null, active: true }
],
order: { categoryId: 'DESC' }
});
if (!policy) continue;
const needsReceipt = policy.requiresReceipt ||
(policy.receiptRequiredAbove && expense.totalAmount > policy.receiptRequiredAbove);
if (needsReceipt && (!expense.attachments || expense.attachments.length === 0)) {
throw new BadRequestException(
`El gasto "${expense.name}" requiere comprobante adjunto`
);
}
}
}
}
3.2 ExpenseApprovalService
// src/modules/hr-expense/services/expense-approval.service.ts
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { ExpenseSheet, ExpenseSheetState } from '../entities/expense-sheet.entity';
import { Expense, ExpenseState } from '../entities/expense.entity';
import { ExpensePolicy } from '../entities/expense-policy.entity';
@Injectable()
export class ExpenseApprovalService {
constructor(
@InjectRepository(ExpenseSheet)
private readonly sheetRepo: Repository<ExpenseSheet>,
@InjectRepository(Expense)
private readonly expenseRepo: Repository<Expense>,
@InjectRepository(ExpensePolicy)
private readonly policyRepo: Repository<ExpensePolicy>,
private readonly dataSource: DataSource,
private readonly accountingService: AccountingService,
) {}
/**
* Aprobar reporte de gastos (Manager)
*/
async approveSheet(
sheetId: string,
userId: string,
comment?: string
): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['employee', 'expenses']
});
// Validar estado
if (sheet.state !== ExpenseSheetState.SUBMITTED) {
throw new BadRequestException('El reporte no está pendiente de aprobación');
}
// Validar que el usuario puede aprobar
await this.validateApprovalAuthority(sheet, userId);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Actualizar reporte
sheet.state = ExpenseSheetState.APPROVED;
sheet.approvalDate = new Date();
sheet.approvedBy = userId;
sheet.updatedBy = userId;
await queryRunner.manager.save(sheet);
// Actualizar gastos
await queryRunner.manager.update(Expense,
{ sheetId: sheet.id },
{ state: ExpenseState.APPROVED, updatedBy: userId }
);
// Registrar en historial
await this.logApprovalAction(queryRunner, sheet.id, 'approved', userId, comment);
await queryRunner.commitTransaction();
// Notificar al empleado
await this.notifyEmployee(sheet, 'approved');
return sheet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Rechazar reporte de gastos
*/
async refuseSheet(
sheetId: string,
userId: string,
reason: string
): Promise<ExpenseSheet> {
if (!reason?.trim()) {
throw new BadRequestException('Debe proporcionar un motivo de rechazo');
}
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['employee', 'expenses']
});
// Validar estado (puede rechazar desde submitted, approved o posted)
if (![ExpenseSheetState.SUBMITTED, ExpenseSheetState.APPROVED, ExpenseSheetState.POSTED]
.includes(sheet.state)) {
throw new BadRequestException('El reporte no puede ser rechazado en este estado');
}
// Si está posted, reversar asiento contable
if (sheet.state === ExpenseSheetState.POSTED && sheet.journalEntryId) {
await this.accountingService.reverseJournalEntry(sheet.journalEntryId, userId);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Actualizar reporte
sheet.state = ExpenseSheetState.REFUSED;
sheet.refusedBy = userId;
sheet.refuseReason = reason;
sheet.updatedBy = userId;
await queryRunner.manager.save(sheet);
// Actualizar gastos
await queryRunner.manager.update(Expense,
{ sheetId: sheet.id },
{ state: ExpenseState.REFUSED, updatedBy: userId }
);
await this.logApprovalAction(queryRunner, sheet.id, 'refused', userId, reason);
await queryRunner.commitTransaction();
// Notificar al empleado
await this.notifyEmployee(sheet, 'refused', reason);
return sheet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Validar autoridad de aprobación
*/
private async validateApprovalAuthority(
sheet: ExpenseSheet,
userId: string
): Promise<void> {
const user = await this.getUserWithRoles(userId);
// Verificar si es manager del empleado
const isManager = sheet.managerId === user.employeeId;
// Verificar si tiene rol de aprobador de gastos
const isExpenseApprover = user.roles.some(r =>
r.permissions.includes('expense.approve')
);
if (!isManager && !isExpenseApprover) {
throw new ForbiddenException('No tiene autoridad para aprobar este reporte');
}
// Verificar límites de aprobación
const policy = await this.policyRepo.findOne({
where: { companyId: sheet.companyId, active: true }
});
if (policy) {
// Manager tiene límite
if (isManager && !isExpenseApprover) {
if (policy.approvalLimitManager && sheet.totalAmount > policy.approvalLimitManager) {
throw new ForbiddenException(
`El monto ${sheet.totalAmount} excede su límite de aprobación (${policy.approvalLimitManager}). ` +
'Requiere aprobación de nivel superior.'
);
}
}
}
}
/**
* Contabilizar reporte aprobado
*/
async postSheet(sheetId: string, userId: string): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['expenses', 'expenses.category', 'employee']
});
if (sheet.state !== ExpenseSheetState.APPROVED) {
throw new BadRequestException('El reporte debe estar aprobado para contabilizar');
}
if (!sheet.journalId) {
throw new BadRequestException('Debe especificar un diario contable');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Crear asiento contable
const journalEntry = await this.createJournalEntry(sheet, userId);
// Actualizar reporte
sheet.state = ExpenseSheetState.POSTED;
sheet.accountingDate = new Date();
sheet.validatedBy = userId;
sheet.journalEntryId = journalEntry.id;
sheet.updatedBy = userId;
await queryRunner.manager.save(sheet);
// Actualizar gastos
await queryRunner.manager.update(Expense,
{ sheetId: sheet.id },
{ state: ExpenseState.POSTED, updatedBy: userId }
);
await queryRunner.commitTransaction();
return sheet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Crear asiento contable para gastos
*/
private async createJournalEntry(
sheet: ExpenseSheet,
userId: string
): Promise<JournalEntry> {
const lines: JournalEntryLine[] = [];
// Agrupar gastos por cuenta
const expensesByAccount = new Map<string, number>();
for (const expense of sheet.expenses) {
const accountId = expense.category.expenseAccountId;
const current = expensesByAccount.get(accountId) || 0;
expensesByAccount.set(accountId, current + expense.totalAmount);
}
// Líneas de débito (gastos)
for (const [accountId, amount] of expensesByAccount) {
lines.push({
accountId,
debit: amount,
credit: 0,
name: `Gastos ${sheet.reference}`,
analyticAccountId: sheet.expenses[0]?.analyticAccountId,
});
}
// Línea de crédito
// Si pagó empleado → cuenta por pagar al empleado
// Si pagó empresa → cuenta de banco/tarjeta corporativa
const employeePaidAmount = sheet.expenses
.filter(e => e.paidBy === PaidBy.EMPLOYEE)
.reduce((sum, e) => sum + e.totalAmount, 0);
const companyPaidAmount = sheet.expenses
.filter(e => e.paidBy === PaidBy.COMPANY)
.reduce((sum, e) => sum + e.totalAmount, 0);
if (employeePaidAmount > 0) {
// Cuenta por pagar al empleado (como proveedor)
const employeePayableAccount = await this.getEmployeePayableAccount(sheet.employee);
lines.push({
accountId: employeePayableAccount.id,
partnerId: sheet.employee.partnerId,
debit: 0,
credit: employeePaidAmount,
name: `Reembolso a ${sheet.employee.name}`,
});
}
if (companyPaidAmount > 0) {
// Cuenta de tarjeta corporativa o banco
const companyExpenseAccount = await this.getCompanyExpenseAccount(sheet.companyId);
lines.push({
accountId: companyExpenseAccount.id,
debit: 0,
credit: companyPaidAmount,
name: `Gastos empresa ${sheet.reference}`,
});
}
return this.accountingService.createJournalEntry({
journalId: sheet.journalId,
date: sheet.accountingDate || new Date(),
reference: sheet.reference,
lines,
state: 'draft', // En Odoo 18, se crea en borrador
createdBy: userId,
});
}
}
3.3 ExpenseReimbursementService
// src/modules/hr-expense/services/expense-reimbursement.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { ExpenseSheet, ExpenseSheetState, PaymentMode } from '../entities/expense-sheet.entity';
@Injectable()
export class ExpenseReimbursementService {
constructor(
@InjectRepository(ExpenseSheet)
private readonly sheetRepo: Repository<ExpenseSheet>,
private readonly dataSource: DataSource,
private readonly paymentService: PaymentService,
private readonly payrollService: PayrollService,
) {}
/**
* Reembolsar gastos vía pago directo
*/
async reimburseViaPayment(
sheetId: string,
dto: ReimbursePaymentDto,
userId: string
): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['employee', 'expenses']
});
if (sheet.state !== ExpenseSheetState.POSTED) {
throw new BadRequestException('El reporte debe estar contabilizado para reembolsar');
}
// Calcular monto a reembolsar (solo lo pagado por empleado)
const reimbursementAmount = sheet.expenses
.filter(e => e.paidBy === 'employee')
.reduce((sum, e) => sum + e.totalAmount, 0);
if (reimbursementAmount <= 0) {
throw new BadRequestException('No hay montos a reembolsar al empleado');
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Crear pago
const payment = await this.paymentService.createPayment({
partnerId: sheet.employee.partnerId,
amount: reimbursementAmount,
currencyId: sheet.currencyId,
journalId: dto.journalId,
paymentMethodId: dto.paymentMethodId,
reference: `Reembolso ${sheet.reference}`,
date: new Date(),
}, queryRunner);
// Conciliar con asiento de gastos
await this.paymentService.reconcileWithEntry(
payment.id,
sheet.journalEntryId,
queryRunner
);
// Actualizar reporte
sheet.paymentState = 'paid';
sheet.paymentId = payment.id;
sheet.paymentMode = dto.journalType === 'bank'
? PaymentMode.BANK_TRANSFER
: PaymentMode.CASH;
sheet.state = ExpenseSheetState.DONE;
sheet.updatedBy = userId;
await queryRunner.manager.save(sheet);
await queryRunner.commitTransaction();
return sheet;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
/**
* Reembolsar gastos vía nómina
*/
async reimburseViaPayslip(
sheetId: string,
userId: string
): Promise<ExpenseSheet> {
const sheet = await this.sheetRepo.findOneOrFail({
where: { id: sheetId },
relations: ['employee', 'expenses']
});
// Para nómina, puede estar en approved (no necesita estar posted)
if (![ExpenseSheetState.APPROVED, ExpenseSheetState.POSTED].includes(sheet.state)) {
throw new BadRequestException(
'El reporte debe estar aprobado o contabilizado para incluir en nómina'
);
}
// Calcular monto
const reimbursementAmount = sheet.expenses
.filter(e => e.paidBy === 'employee')
.reduce((sum, e) => sum + e.totalAmount, 0);
if (reimbursementAmount <= 0) {
throw new BadRequestException('No hay montos a reembolsar');
}
// Registrar input en nómina
await this.payrollService.addPayslipInput({
employeeId: sheet.employeeId,
inputTypeCode: 'EXPENSE_REIMB',
amount: reimbursementAmount,
description: `Reembolso gastos ${sheet.reference}`,
sourceType: 'expense_sheet',
sourceId: sheet.id,
});
// Actualizar estado
sheet.paymentMode = PaymentMode.PAYSLIP;
sheet.paymentState = 'pending'; // Se pagará con la nómina
sheet.updatedBy = userId;
await this.sheetRepo.save(sheet);
return sheet;
}
/**
* Marcar como pagado (cuando se procesa la nómina)
*/
async markAsPaidViaPayslip(
sheetId: string,
payslipId: string,
userId: string
): Promise<void> {
await this.sheetRepo.update(
{ id: sheetId },
{
state: ExpenseSheetState.DONE,
paymentState: 'paid',
updatedBy: userId,
}
);
}
}
Parte 4: API REST
4.1 Endpoints de Gastos
// src/modules/hr-expense/controllers/expense.controller.ts
@Controller('api/v1/expenses')
@UseGuards(JwtAuthGuard)
@ApiTags('Expenses')
export class ExpenseController {
constructor(
private readonly expenseService: ExpenseService,
private readonly approvalService: ExpenseApprovalService,
private readonly reimbursementService: ExpenseReimbursementService,
) {}
// ============ GASTOS INDIVIDUALES ============
@Post()
@ApiOperation({ summary: 'Crear gasto' })
async createExpense(
@Body() dto: CreateExpenseDto,
@CurrentUser() user: User,
): Promise<ExpenseResponseDto> {
const expense = await this.expenseService.createExpense(dto, user.id);
return this.mapToResponse(expense);
}
@Get()
@ApiOperation({ summary: 'Listar mis gastos' })
async listMyExpenses(
@Query() query: ExpenseQueryDto,
@CurrentUser() user: User,
): Promise<PaginatedResponse<ExpenseResponseDto>> {
return this.expenseService.findByEmployee(user.employeeId, query);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener gasto' })
async getExpense(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<ExpenseResponseDto> {
return this.expenseService.findOne(id, user);
}
@Patch(':id')
@ApiOperation({ summary: 'Actualizar gasto' })
async updateExpense(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateExpenseDto,
@CurrentUser() user: User,
): Promise<ExpenseResponseDto> {
return this.expenseService.update(id, dto, user.id);
}
@Delete(':id')
@ApiOperation({ summary: 'Eliminar gasto' })
async deleteExpense(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<void> {
return this.expenseService.delete(id, user.id);
}
@Post(':id/attachments')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Adjuntar comprobante' })
async uploadAttachment(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: User,
): Promise<AttachmentResponseDto> {
return this.expenseService.addAttachment(id, file, user.id);
}
// ============ REPORTES DE GASTOS ============
@Post('sheets')
@ApiOperation({ summary: 'Crear reporte de gastos' })
async createSheet(
@Body() dto: CreateExpenseSheetDto,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
const sheet = await this.expenseService.createExpenseSheet(dto, user.id);
return this.mapSheetToResponse(sheet);
}
@Get('sheets')
@ApiOperation({ summary: 'Listar mis reportes' })
async listMySheets(
@Query() query: SheetQueryDto,
@CurrentUser() user: User,
): Promise<PaginatedResponse<ExpenseSheetResponseDto>> {
return this.expenseService.findSheetsByEmployee(user.employeeId, query);
}
@Get('sheets/to-approve')
@ApiOperation({ summary: 'Reportes pendientes de aprobación' })
@Roles('expense.approve')
async listToApprove(
@Query() query: SheetQueryDto,
@CurrentUser() user: User,
): Promise<PaginatedResponse<ExpenseSheetResponseDto>> {
return this.expenseService.findSheetsToApprove(user, query);
}
@Post('sheets/:id/submit')
@ApiOperation({ summary: 'Enviar reporte a aprobación' })
async submitSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
const sheet = await this.expenseService.submitSheet(id, user.id);
return this.mapSheetToResponse(sheet);
}
@Post('sheets/:id/approve')
@ApiOperation({ summary: 'Aprobar reporte' })
@Roles('expense.approve')
async approveSheet(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveSheetDto,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
const sheet = await this.approvalService.approveSheet(id, user.id, dto.comment);
return this.mapSheetToResponse(sheet);
}
@Post('sheets/:id/refuse')
@ApiOperation({ summary: 'Rechazar reporte' })
@Roles('expense.approve')
async refuseSheet(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RefuseSheetDto,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
const sheet = await this.approvalService.refuseSheet(id, user.id, dto.reason);
return this.mapSheetToResponse(sheet);
}
@Post('sheets/:id/post')
@ApiOperation({ summary: 'Contabilizar reporte' })
@Roles('accounting.expense')
async postSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
const sheet = await this.approvalService.postSheet(id, user.id);
return this.mapSheetToResponse(sheet);
}
@Post('sheets/:id/reimburse')
@ApiOperation({ summary: 'Reembolsar gastos' })
@Roles('accounting.payment')
async reimburseSheet(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReimburseDto,
@CurrentUser() user: User,
): Promise<ExpenseSheetResponseDto> {
let sheet: ExpenseSheet;
if (dto.method === 'payslip') {
sheet = await this.reimbursementService.reimburseViaPayslip(id, user.id);
} else {
sheet = await this.reimbursementService.reimburseViaPayment(id, dto, user.id);
}
return this.mapSheetToResponse(sheet);
}
}
4.2 DTOs
// src/modules/hr-expense/dto/expense.dto.ts
export class CreateExpenseDto {
@IsUUID()
categoryId: string;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
reference?: string;
@IsDateString()
expenseDate: string;
@IsNumber()
@Min(0)
unitAmount: number;
@IsNumber()
@Min(0.0001)
@IsOptional()
quantity?: number = 1;
@IsUUID()
@IsOptional()
currencyId?: string;
@IsEnum(PaidBy)
@IsOptional()
paidBy?: PaidBy = PaidBy.EMPLOYEE;
@IsUUID()
@IsOptional()
analyticAccountId?: string;
@IsUUID()
@IsOptional()
projectId?: string;
}
export class CreateExpenseSheetDto {
@IsString()
@IsOptional()
name?: string;
@IsUUID({ each: true })
@IsOptional()
expenseIds?: string[];
}
export class RefuseSheetDto {
@IsString()
@MinLength(10)
reason: string;
}
export class ReimburseDto {
@IsEnum(['payment', 'payslip'])
method: 'payment' | 'payslip';
@IsUUID()
@ValidateIf(o => o.method === 'payment')
journalId?: string;
@IsUUID()
@ValidateIf(o => o.method === 'payment')
paymentMethodId?: string;
}
Parte 5: Categorías Predeterminadas
5.1 Seed de Categorías
// src/modules/hr-expense/seeds/expense-categories.seed.ts
export const DEFAULT_EXPENSE_CATEGORIES = [
{
code: 'MIL',
name: 'Kilometraje',
description: 'Reembolso por uso de vehículo personal',
costType: 'mileage',
defaultUnitPrice: 0.30, // MXN/km o configurable
uomCode: 'km',
expenseAccountCode: '601.01', // Gastos de viaje
reinvoiceable: true,
},
{
code: 'FOOD',
name: 'Alimentación',
description: 'Comidas y alimentos durante trabajo',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.02', // Gastos de representación
reinvoiceable: true,
},
{
code: 'HOTEL',
name: 'Hospedaje',
description: 'Hoteles y alojamiento',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'night',
expenseAccountCode: '601.01',
reinvoiceable: true,
},
{
code: 'TRANS',
name: 'Transporte',
description: 'Taxis, Uber, transporte público',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.01',
reinvoiceable: true,
},
{
code: 'FLIGHT',
name: 'Vuelos',
description: 'Boletos de avión',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.01',
reinvoiceable: true,
},
{
code: 'COMM',
name: 'Comunicaciones',
description: 'Teléfono, internet, mensajería',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.03',
reinvoiceable: false,
},
{
code: 'OFFICE',
name: 'Suministros de Oficina',
description: 'Papelería y materiales de oficina',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.04',
reinvoiceable: false,
},
{
code: 'GIFT',
name: 'Regalos Corporativos',
description: 'Obsequios a clientes',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.02',
reinvoiceable: false,
},
{
code: 'PARK',
name: 'Estacionamiento',
description: 'Gastos de estacionamiento',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.01',
reinvoiceable: true,
},
{
code: 'OTHER',
name: 'Otros Gastos',
description: 'Gastos varios no categorizados',
costType: 'actual',
defaultUnitPrice: 0,
uomCode: 'unit',
expenseAccountCode: '601.99',
reinvoiceable: false,
},
];
Parte 6: Reportes y Analytics
6.1 Queries de Reportes
-- Vista de gastos por empleado/período
CREATE VIEW v_expense_summary_employee AS
SELECT
e.employee_id,
emp.name as employee_name,
emp.department_id,
d.name as department_name,
DATE_TRUNC('month', e.expense_date) as month,
ec.code as category_code,
ec.name as category_name,
COUNT(*) as expense_count,
SUM(e.total_amount) as total_amount,
e.currency_id
FROM expenses e
JOIN employees emp ON e.employee_id = emp.id
LEFT JOIN departments d ON emp.department_id = d.id
JOIN expense_categories ec ON e.category_id = ec.id
WHERE e.state NOT IN ('draft', 'refused')
GROUP BY
e.employee_id, emp.name, emp.department_id, d.name,
DATE_TRUNC('month', e.expense_date),
ec.code, ec.name, e.currency_id;
-- Vista de gastos por departamento
CREATE VIEW v_expense_summary_department AS
SELECT
d.id as department_id,
d.name as department_name,
DATE_TRUNC('month', e.expense_date) as month,
ec.code as category_code,
COUNT(DISTINCT e.employee_id) as employee_count,
COUNT(*) as expense_count,
SUM(e.total_amount) as total_amount,
AVG(e.total_amount) as avg_expense,
e.currency_id
FROM expenses e
JOIN employees emp ON e.employee_id = emp.id
JOIN departments d ON emp.department_id = d.id
JOIN expense_categories ec ON e.category_id = ec.id
WHERE e.state NOT IN ('draft', 'refused')
GROUP BY d.id, d.name, DATE_TRUNC('month', e.expense_date),
ec.code, e.currency_id;
-- Gastos pendientes de reembolso
CREATE VIEW v_pending_reimbursements AS
SELECT
es.id as sheet_id,
es.reference,
es.employee_id,
emp.name as employee_name,
es.total_amount,
es.currency_id,
es.state,
es.submission_date,
es.approval_date,
CURRENT_DATE - es.approval_date::date as days_pending
FROM expense_sheets es
JOIN employees emp ON es.employee_id = emp.id
WHERE es.state = 'posted'
AND es.payment_state != 'paid'
ORDER BY es.approval_date;
6.2 Dashboard Service
// src/modules/hr-expense/services/expense-analytics.service.ts
@Injectable()
export class ExpenseAnalyticsService {
/**
* Obtener resumen de gastos por período
*/
async getExpenseSummary(
companyId: string,
dateFrom: Date,
dateTo: Date,
groupBy: 'employee' | 'department' | 'category'
): Promise<ExpenseSummaryDto[]> {
const qb = this.expenseRepo
.createQueryBuilder('e')
.select([
groupBy === 'employee' ? 'e.employee_id' : null,
groupBy === 'department' ? 'emp.department_id' : null,
groupBy === 'category' ? 'e.category_id' : null,
'COUNT(*) as count',
'SUM(e.total_amount) as total',
'AVG(e.total_amount) as average',
].filter(Boolean))
.leftJoin('e.employee', 'emp')
.where('e.company_id = :companyId', { companyId })
.andWhere('e.expense_date BETWEEN :dateFrom AND :dateTo', { dateFrom, dateTo })
.andWhere('e.state NOT IN (:...excluded)', { excluded: ['draft', 'refused'] })
.groupBy(groupBy === 'employee' ? 'e.employee_id' :
groupBy === 'department' ? 'emp.department_id' : 'e.category_id');
return qb.getRawMany();
}
/**
* Obtener tendencia mensual
*/
async getMonthlyTrend(
companyId: string,
months: number = 12
): Promise<MonthlyTrendDto[]> {
const dateFrom = new Date();
dateFrom.setMonth(dateFrom.getMonth() - months);
return this.dataSource.query(`
SELECT
DATE_TRUNC('month', expense_date) as month,
COUNT(*) as count,
SUM(total_amount) as total
FROM expenses
WHERE company_id = $1
AND expense_date >= $2
AND state NOT IN ('draft', 'refused')
GROUP BY DATE_TRUNC('month', expense_date)
ORDER BY month
`, [companyId, dateFrom]);
}
/**
* Comparar contra presupuesto
*/
async getBudgetComparison(
companyId: string,
analyticAccountId: string,
year: number
): Promise<BudgetComparisonDto> {
// Obtener presupuesto
const budget = await this.budgetRepo.findOne({
where: {
companyId,
analyticAccountId,
year
}
});
// Obtener gastos reales
const actual = await this.dataSource.query(`
SELECT SUM(total_amount) as total
FROM expenses
WHERE company_id = $1
AND analytic_account_id = $2
AND EXTRACT(YEAR FROM expense_date) = $3
AND state NOT IN ('draft', 'refused')
`, [companyId, analyticAccountId, year]);
return {
budgeted: budget?.amount || 0,
actual: actual[0]?.total || 0,
variance: (budget?.amount || 0) - (actual[0]?.total || 0),
percentUsed: budget?.amount
? ((actual[0]?.total || 0) / budget.amount) * 100
: 0,
};
}
}
Parte 7: Integración con Otros Módulos
7.1 Integración con Proyectos (Refacturación)
// Cuando un gasto es refacturable a cliente
async handleReinvoiceableExpense(expense: Expense): Promise<void> {
if (!expense.category.reinvoiceable || !expense.saleOrderId) {
return;
}
// Crear línea en orden de venta
await this.saleOrderService.addExpenseLine({
orderId: expense.saleOrderId,
productId: expense.category.saleProductId,
name: `${expense.category.name}: ${expense.name}`,
quantity: expense.quantity,
priceUnit: expense.unitAmount,
expenseId: expense.id,
});
// Actualizar estado de refacturación
await this.expenseRepo.update(expense.id, {
reinvoiceStatus: 'pending'
});
}
7.2 Integración con Nómina
// En PayrollService - obtener reembolsos pendientes
async getExpenseReimbursementsForPayslip(
employeeId: string,
periodEnd: Date
): Promise<PayslipInput[]> {
const sheets = await this.expenseSheetRepo.find({
where: {
employeeId,
paymentMode: PaymentMode.PAYSLIP,
paymentState: 'pending',
approvalDate: LessThanOrEqual(periodEnd),
},
relations: ['expenses']
});
return sheets.map(sheet => ({
inputTypeCode: 'EXPENSE_REIMB',
amount: sheet.expenses
.filter(e => e.paidBy === 'employee')
.reduce((sum, e) => sum + e.totalAmount, 0),
description: `Reembolso ${sheet.reference}`,
sourceType: 'expense_sheet',
sourceId: sheet.id,
}));
}
Parte 8: Consideraciones de Seguridad
8.1 Permisos
// Permisos requeridos
export const EXPENSE_PERMISSIONS = {
// Empleado
'expense.own.create': 'Crear gastos propios',
'expense.own.read': 'Ver gastos propios',
'expense.own.update': 'Editar gastos propios (en draft)',
'expense.own.delete': 'Eliminar gastos propios (en draft)',
// Manager
'expense.team.read': 'Ver gastos del equipo',
'expense.approve': 'Aprobar/rechazar reportes',
// Contabilidad
'expense.all.read': 'Ver todos los gastos',
'expense.post': 'Contabilizar reportes',
'expense.reimburse': 'Procesar reembolsos',
// Administrador
'expense.categories.manage': 'Gestionar categorías',
'expense.policies.manage': 'Gestionar políticas',
};
8.2 Validaciones de Fraude
// Detectar patrones sospechosos
async detectSuspiciousPatterns(expense: Expense): Promise<Warning[]> {
const warnings: Warning[] = [];
// Gasto duplicado (mismo monto, fecha, categoría)
const duplicates = await this.expenseRepo.count({
where: {
employeeId: expense.employeeId,
categoryId: expense.categoryId,
expenseDate: expense.expenseDate,
totalAmount: expense.totalAmount,
id: Not(expense.id),
}
});
if (duplicates > 0) {
warnings.push({
type: 'duplicate',
message: 'Posible gasto duplicado detectado',
});
}
// Gasto en fin de semana
const dayOfWeek = new Date(expense.expenseDate).getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) {
warnings.push({
type: 'weekend',
message: 'Gasto registrado en fin de semana',
});
}
// Monto redondo sospechoso (posible estimación)
if (expense.totalAmount % 100 === 0 && expense.totalAmount >= 500) {
warnings.push({
type: 'round_amount',
message: 'Monto redondo - verificar comprobante',
});
}
return warnings;
}
Apéndice A: Diagrama de Estados
┌─────────────────────────────────────────┐
│ FLUJO DE ESTADOS │
└─────────────────────────────────────────┘
GASTO INDIVIDUAL REPORTE DE GASTOS
================ =================
┌─────────┐ ┌─────────┐
│ DRAFT │ │ DRAFT │
└────┬────┘ └────┬────┘
│ agregar a reporte │ submit()
▼ ▼
┌──────────┐ ┌───────────┐
│ REPORTED │ │ SUBMITTED │◀──────┐
└────┬─────┘ └─────┬─────┘ │
│ submit reporte │ │
▼ ┌─────┴─────┐ │
┌───────────┐ │ │ │
│ SUBMITTED │ approve() refuse() │
└─────┬─────┘ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ approve │APPROVED │ │ REFUSED │ │
▼ └────┬────┘ └─────────┘ │
┌──────────┐ │ │
│ APPROVED │ │ post() │
└────┬─────┘ ▼ │
│ ┌─────────┐ │
│ post │ POSTED │──────────────┘
▼ └────┬────┘ refuse()
┌─────────┐ │
│ POSTED │ │ reimburse()
└────┬────┘ ▼
│ ┌─────────┐
│ paid │ DONE │
▼ └─────────┘
┌─────────┐
│ DONE │
└─────────┘
Apéndice B: Checklist de Implementación
- Modelo de datos (migraciones)
- Entidades TypeORM
- ExpenseService (CRUD gastos)
- ExpenseSheetService (reportes)
- ExpenseApprovalService (workflow)
- ExpenseReimbursementService (pagos)
- ExpenseAnalyticsService (reportes)
- Controladores REST
- DTOs y validaciones
- Seed de categorías
- Permisos y roles
- Tests unitarios
- Tests de integración
- Documentación API (Swagger)
Documento generado como parte del análisis de gaps ERP Core vs Odoo 18 Referencia: GAP-MGN-010-002 - Sistema de Gastos de Empleados