383 lines
10 KiB
TypeScript
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();
|