erp-core/backend/src/modules/financial/taxes.service.ts

383 lines
10 KiB
TypeScript

import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
export interface Tax {
id: string;
tenant_id: string;
company_id: string;
company_name?: string;
name: string;
code: string;
tax_type: 'sales' | 'purchase' | 'all';
amount: number;
included_in_price: boolean;
active: boolean;
created_at: Date;
}
export interface CreateTaxDto {
company_id: string;
name: string;
code: string;
tax_type: 'sales' | 'purchase' | 'all';
amount: number;
included_in_price?: boolean;
}
export interface UpdateTaxDto {
name?: string;
code?: string;
tax_type?: 'sales' | 'purchase' | 'all';
amount?: number;
included_in_price?: boolean;
active?: boolean;
}
export interface TaxFilters {
company_id?: string;
tax_type?: string;
active?: boolean;
search?: string;
page?: number;
limit?: number;
}
class TaxesService {
async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> {
const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE t.tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND t.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (tax_type) {
whereClause += ` AND t.tax_type = $${paramIndex++}`;
params.push(tax_type);
}
if (active !== undefined) {
whereClause += ` AND t.active = $${paramIndex++}`;
params.push(active);
}
if (search) {
whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<Tax>(
`SELECT t.*,
c.name as company_name
FROM financial.taxes t
LEFT JOIN auth.companies c ON t.company_id = c.id
${whereClause}
ORDER BY t.name
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findById(id: string, tenantId: string): Promise<Tax> {
const tax = await queryOne<Tax>(
`SELECT t.*,
c.name as company_name
FROM financial.taxes t
LEFT JOIN auth.companies c ON t.company_id = c.id
WHERE t.id = $1 AND t.tenant_id = $2`,
[id, tenantId]
);
if (!tax) {
throw new NotFoundError('Impuesto no encontrado');
}
return tax;
}
async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise<Tax> {
// Check unique code
const existing = await queryOne(
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`,
[tenantId, dto.code]
);
if (existing) {
throw new ConflictError('Ya existe un impuesto con ese código');
}
const tax = await queryOne<Tax>(
`INSERT INTO financial.taxes (
tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
tenantId, dto.company_id, dto.name, dto.code, dto.tax_type,
dto.amount, dto.included_in_price ?? false, userId
]
);
return tax!;
}
async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise<Tax> {
const existing = await this.findById(id, tenantId);
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.code !== undefined) {
// Check unique code
const existingCode = await queryOne(
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`,
[tenantId, dto.code, id]
);
if (existingCode) {
throw new ConflictError('Ya existe un impuesto con ese código');
}
updateFields.push(`code = $${paramIndex++}`);
values.push(dto.code);
}
if (dto.tax_type !== undefined) {
updateFields.push(`tax_type = $${paramIndex++}`);
values.push(dto.tax_type);
}
if (dto.amount !== undefined) {
updateFields.push(`amount = $${paramIndex++}`);
values.push(dto.amount);
}
if (dto.included_in_price !== undefined) {
updateFields.push(`included_in_price = $${paramIndex++}`);
values.push(dto.included_in_price);
}
if (dto.active !== undefined) {
updateFields.push(`active = $${paramIndex++}`);
values.push(dto.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.taxes SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findById(id, tenantId);
}
async delete(id: string, tenantId: string): Promise<void> {
await this.findById(id, tenantId);
// Check if tax is used in any invoice lines
const usageCheck = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM financial.invoice_lines
WHERE $1 = ANY(tax_ids)`,
[id]
);
if (parseInt(usageCheck?.count || '0') > 0) {
throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas');
}
await query(
`DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`,
[id, tenantId]
);
}
/**
* Calcula impuestos para una linea de documento
* Sigue la logica de Odoo para calculos de IVA
*/
async calculateTaxes(
lineData: TaxCalculationInput,
tenantId: string,
transactionType: 'sales' | 'purchase' = 'sales'
): Promise<TaxCalculationResult> {
// Validar inputs
if (lineData.quantity <= 0 || lineData.priceUnit < 0) {
return {
amountUntaxed: 0,
amountTax: 0,
amountTotal: 0,
taxBreakdown: [],
};
}
// Calcular subtotal antes de impuestos
const subtotal = lineData.quantity * lineData.priceUnit;
const discountAmount = subtotal * (lineData.discount || 0) / 100;
const amountUntaxed = subtotal - discountAmount;
// Si no hay impuestos, retornar solo el monto sin impuestos
if (!lineData.taxIds || lineData.taxIds.length === 0) {
return {
amountUntaxed,
amountTax: 0,
amountTotal: amountUntaxed,
taxBreakdown: [],
};
}
// Obtener impuestos de la BD
const taxResults = await query<Tax>(
`SELECT * FROM financial.taxes
WHERE id = ANY($1) AND tenant_id = $2 AND active = true
AND (tax_type = $3 OR tax_type = 'all')`,
[lineData.taxIds, tenantId, transactionType]
);
if (taxResults.length === 0) {
return {
amountUntaxed,
amountTax: 0,
amountTotal: amountUntaxed,
taxBreakdown: [],
};
}
// Calcular impuestos
const taxBreakdown: TaxBreakdownItem[] = [];
let totalTax = 0;
for (const tax of taxResults) {
let taxBase = amountUntaxed;
let taxAmount: number;
if (tax.included_in_price) {
// Precio incluye impuesto (IVA incluido)
// Base = Precio / (1 + tasa)
// Impuesto = Precio - Base
taxBase = amountUntaxed / (1 + tax.amount / 100);
taxAmount = amountUntaxed - taxBase;
} else {
// Precio sin impuesto (IVA añadido)
// Impuesto = Base * tasa
taxAmount = amountUntaxed * tax.amount / 100;
}
taxBreakdown.push({
taxId: tax.id,
taxName: tax.name,
taxCode: tax.code,
taxRate: tax.amount,
includedInPrice: tax.included_in_price,
base: Math.round(taxBase * 100) / 100,
taxAmount: Math.round(taxAmount * 100) / 100,
});
totalTax += taxAmount;
}
// Redondear a 2 decimales
const finalAmountTax = Math.round(totalTax * 100) / 100;
const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100;
const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100;
return {
amountUntaxed: finalAmountUntaxed,
amountTax: finalAmountTax,
amountTotal: finalAmountTotal,
taxBreakdown,
};
}
/**
* Calcula impuestos para multiples lineas (ej: para totales de documento)
*/
async calculateDocumentTaxes(
lines: TaxCalculationInput[],
tenantId: string,
transactionType: 'sales' | 'purchase' = 'sales'
): Promise<TaxCalculationResult> {
let totalUntaxed = 0;
let totalTax = 0;
const allBreakdown: TaxBreakdownItem[] = [];
for (const line of lines) {
const result = await this.calculateTaxes(line, tenantId, transactionType);
totalUntaxed += result.amountUntaxed;
totalTax += result.amountTax;
allBreakdown.push(...result.taxBreakdown);
}
// Consolidar breakdown por impuesto
const consolidatedBreakdown = new Map<string, TaxBreakdownItem>();
for (const item of allBreakdown) {
const existing = consolidatedBreakdown.get(item.taxId);
if (existing) {
existing.base += item.base;
existing.taxAmount += item.taxAmount;
} else {
consolidatedBreakdown.set(item.taxId, { ...item });
}
}
return {
amountUntaxed: Math.round(totalUntaxed * 100) / 100,
amountTax: Math.round(totalTax * 100) / 100,
amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100,
taxBreakdown: Array.from(consolidatedBreakdown.values()),
};
}
}
// Interfaces para calculo de impuestos
export interface TaxCalculationInput {
quantity: number;
priceUnit: number;
discount: number;
taxIds: string[];
}
export interface TaxBreakdownItem {
taxId: string;
taxName: string;
taxCode: string;
taxRate: number;
includedInPrice: boolean;
base: number;
taxAmount: number;
}
export interface TaxCalculationResult {
amountUntaxed: number;
amountTax: number;
amountTotal: number;
taxBreakdown: TaxBreakdownItem[];
}
export const taxesService = new TaxesService();