76 KiB
76 KiB
SPEC-IMPUESTOS-AVANZADOS: Sistema de Impuestos Configurables
Metadata
- Código: SPEC-IMPUESTOS-AVANZADOS
- Versión: 1.0.0
- Fecha: 2025-01-15
- Gap Relacionado: GAP-MGN-004-002
- Módulo: MGN-004 (Contabilidad)
- Prioridad: P1
- Story Points: 8
- Odoo Referencia: account (account_tax, account_fiscal_position)
1. Resumen Ejecutivo
1.1 Descripción del Gap
El sistema contable actual tiene impuestos básicos (IVA porcentual) pero carece de funcionalidades avanzadas como: impuestos en grupo, impuestos incluidos en precio, cálculo en cascada, posiciones fiscales automáticas, y soporte completo para localización mexicana (IEPS, retenciones ISR/IVA, base de caja).
1.2 Impacto en el Negocio
| Aspecto | Sistema Actual | Con Impuestos Avanzados |
|---|---|---|
| Tipos de impuesto | Solo porcentual | Porcentual, fijo, división, grupo |
| Precio con IVA | Manual | Automático (incluido/excluido) |
| Retenciones | No soportado | ISR, IVA configurable |
| IEPS | No soportado | Tasas por producto |
| Posiciones fiscales | Manual | Automático por ubicación/cliente |
| Base de caja | No | Impuesto exigible al pago |
| Reportes DIOT | No | Tags automáticos |
1.3 Objetivos de la Especificación
- Implementar motor de cálculo de impuestos multi-tipo
- Soportar impuestos incluidos y excluidos del precio
- Configurar posiciones fiscales con mapeo automático
- Implementar retenciones (ISR, IVA) con factores negativos
- Soportar IEPS con múltiples tasas
- Integrar con CFDI 4.0 (TipoFactor, ObjectoImp)
- Manejar impuestos en base de caja vs acumulado
2. Arquitectura de Datos
2.1 Diagrama del Sistema
┌─────────────────────────────────────────────────────────────────────────────┐
│ SISTEMA DE IMPUESTOS AVANZADOS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ GRUPOS DE IMPUESTOS │ │
│ │ │ │
│ │ IVA 16% IEPS Retenciones Exento │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │payable │ │payable │ │payable │ │ N/A │ │ │
│ │ │receivable│ │receivable│ │receivable│ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ IMPUESTOS (account_tax) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ Tipo │ Monto │ Uso │ Incluido │ Cascada │ CABA │ │ │
│ │ ├───────────┼────────┼──────────┼──────────┼─────────┼────────┤ │ │
│ │ │ percent │ 16.0 │ sale │ No │ No │ on_pay │ │ │
│ │ │ percent │ 16.0 │ purchase │ No │ No │ on_pay │ │ │
│ │ │ percent │ -10.67 │ purchase │ No │ No │ on_pay │ │ │
│ │ │ fixed │ 5.00 │ sale │ Si │ No │ on_inv │ │ │
│ │ │ group │ --- │ sale │ --- │ --- │ --- │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ LÍNEAS DE DISTRIBUCIÓN │ │
│ │ │ │
│ │ Invoice Refund │ │
│ │ ┌───────────────────────────┐ ┌───────────────────────────┐│ │
│ │ │ Base: 100% → cuenta_base │ │ Base: 100% → cuenta_base ││ │
│ │ │ Tax: 100% → cuenta_iva │ │ Tax: 100% → cuenta_iva ││ │
│ │ └───────────────────────────┘ └───────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ POSICIONES FISCALES │ │
│ │ │ │
│ │ Cliente Nacional Cliente Extranjero Zona Fronteriza │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
│ │ │ IVA 16% → 16% │ │ IVA 16% → 0% │ │ IVA 16% → 8% │ │ │
│ │ │ IEPS → IEPS │ │ IEPS → Exento │ │ IEPS → IEPS │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.2 Definición de Tablas
-- =============================================================================
-- SCHEMA: accounting
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Tabla: tax_groups
-- Descripción: Agrupación de impuestos por tipo (IVA, ISR, IEPS, etc.)
-- -----------------------------------------------------------------------------
CREATE TABLE tax_groups (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- Datos principales
name VARCHAR(100) NOT NULL,
sequence INTEGER DEFAULT 10,
-- País de aplicación
country_id UUID REFERENCES countries(id),
-- Cuentas asociadas
tax_payable_account_id UUID REFERENCES accounts(id), -- IVA por pagar
tax_receivable_account_id UUID REFERENCES accounts(id), -- IVA a favor
advance_tax_payment_account_id UUID REFERENCES accounts(id), -- Anticipos
-- Configuración de presentación
preceding_subtotal VARCHAR(100), -- Etiqueta subtotal anterior
-- Auditoría
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
-- Constraints
CONSTRAINT uq_tax_groups_name UNIQUE(tenant_id, name, country_id)
);
-- Índices
CREATE INDEX idx_tax_groups_tenant ON tax_groups(tenant_id);
CREATE INDEX idx_tax_groups_country ON tax_groups(country_id);
-- RLS
ALTER TABLE tax_groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY tax_groups_tenant_isolation ON tax_groups
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- -----------------------------------------------------------------------------
-- Tabla: taxes
-- Descripción: Definición de impuestos individuales
-- -----------------------------------------------------------------------------
CREATE TABLE taxes (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- Datos principales
name VARCHAR(100) NOT NULL,
description TEXT,
-- Tipo y cálculo
amount_type tax_amount_type NOT NULL DEFAULT 'percent',
amount DECIMAL(16,4) NOT NULL DEFAULT 0,
-- Uso
type_tax_use tax_use_type NOT NULL DEFAULT 'sale',
tax_scope tax_scope_type DEFAULT 'consu', -- consu, service
-- Grupo
tax_group_id UUID NOT NULL REFERENCES tax_groups(id),
-- Configuración de precio
price_include_override tax_price_include_type, -- tax_included, tax_excluded, NULL
-- Cascada
include_base_amount BOOLEAN DEFAULT FALSE, -- Afecta base de impuestos siguientes
is_base_affected BOOLEAN DEFAULT TRUE, -- Afectado por impuestos anteriores
-- Exigibilidad (Cash Basis)
tax_exigibility tax_exigibility_type DEFAULT 'on_invoice',
cash_basis_transition_account_id UUID REFERENCES accounts(id),
-- Impuestos hijo (para grupos)
-- Se maneja en tabla separada children_taxes
-- País
country_id UUID NOT NULL REFERENCES countries(id),
-- México CFDI
l10n_mx_factor_type mx_factor_type DEFAULT 'Tasa', -- Tasa, Cuota, Exento
l10n_mx_tax_type mx_tax_type, -- iva, isr, ieps, local
-- Estado
active BOOLEAN DEFAULT TRUE,
sequence INTEGER DEFAULT 1,
-- Auditoría
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
-- Constraints
CONSTRAINT uq_taxes_name UNIQUE(tenant_id, name, type_tax_use, country_id)
);
-- Tipos ENUM
CREATE TYPE tax_amount_type AS ENUM ('percent', 'fixed', 'division', 'group');
CREATE TYPE tax_use_type AS ENUM ('sale', 'purchase', 'none');
CREATE TYPE tax_scope_type AS ENUM ('consu', 'service');
CREATE TYPE tax_price_include_type AS ENUM ('tax_included', 'tax_excluded');
CREATE TYPE tax_exigibility_type AS ENUM ('on_invoice', 'on_payment');
CREATE TYPE mx_factor_type AS ENUM ('Tasa', 'Cuota', 'Exento');
CREATE TYPE mx_tax_type AS ENUM ('iva', 'isr', 'ieps', 'local');
-- Índices
CREATE INDEX idx_taxes_tenant ON taxes(tenant_id);
CREATE INDEX idx_taxes_group ON taxes(tax_group_id);
CREATE INDEX idx_taxes_type_use ON taxes(type_tax_use);
CREATE INDEX idx_taxes_country ON taxes(country_id);
CREATE INDEX idx_taxes_active ON taxes(tenant_id, active) WHERE active = TRUE;
-- RLS
ALTER TABLE taxes ENABLE ROW LEVEL SECURITY;
CREATE POLICY taxes_tenant_isolation ON taxes
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- -----------------------------------------------------------------------------
-- Tabla: tax_children
-- Descripción: Relación de impuestos grupo con sus hijos
-- -----------------------------------------------------------------------------
CREATE TABLE tax_children (
parent_tax_id UUID NOT NULL REFERENCES taxes(id) ON DELETE CASCADE,
child_tax_id UUID NOT NULL REFERENCES taxes(id) ON DELETE CASCADE,
sequence INTEGER DEFAULT 1,
PRIMARY KEY (parent_tax_id, child_tax_id),
CONSTRAINT chk_no_self_reference CHECK (parent_tax_id != child_tax_id)
);
CREATE INDEX idx_tax_children_parent ON tax_children(parent_tax_id);
CREATE INDEX idx_tax_children_child ON tax_children(child_tax_id);
-- -----------------------------------------------------------------------------
-- Tabla: tax_repartition_lines
-- Descripción: Distribución contable del impuesto
-- -----------------------------------------------------------------------------
CREATE TABLE tax_repartition_lines (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tax_id UUID NOT NULL REFERENCES taxes(id) ON DELETE CASCADE,
-- Tipo de documento y distribución
document_type repartition_doc_type NOT NULL, -- invoice, refund
repartition_type repartition_type NOT NULL, -- base, tax
-- Factor de distribución
factor_percent DECIMAL(16,12) NOT NULL DEFAULT 100.0,
-- Cuenta destino (NULL para base sin cuenta)
account_id UUID REFERENCES accounts(id),
-- Orden
sequence INTEGER DEFAULT 1,
-- Auditoría
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TYPE repartition_doc_type AS ENUM ('invoice', 'refund');
CREATE TYPE repartition_type AS ENUM ('base', 'tax');
CREATE INDEX idx_tax_repartition_tax ON tax_repartition_lines(tax_id);
CREATE INDEX idx_tax_repartition_type ON tax_repartition_lines(document_type, repartition_type);
-- -----------------------------------------------------------------------------
-- Tabla: tax_repartition_line_tags
-- Descripción: Tags para reportes fiscales (DIOT, etc.)
-- -----------------------------------------------------------------------------
CREATE TABLE tax_repartition_line_tags (
repartition_line_id UUID NOT NULL REFERENCES tax_repartition_lines(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES account_tags(id) ON DELETE CASCADE,
PRIMARY KEY (repartition_line_id, tag_id)
);
-- -----------------------------------------------------------------------------
-- Tabla: fiscal_positions
-- Descripción: Posiciones fiscales para mapeo automático de impuestos
-- -----------------------------------------------------------------------------
CREATE TABLE fiscal_positions (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- Datos principales
name VARCHAR(100) NOT NULL,
sequence INTEGER DEFAULT 1,
-- Criterios de aplicación automática
auto_apply BOOLEAN DEFAULT FALSE,
country_id UUID REFERENCES countries(id),
country_group_id UUID REFERENCES country_groups(id),
state_ids UUID[], -- Array de estados/provincias
zip_from VARCHAR(20),
zip_to VARCHAR(20),
-- Requisitos
vat_required BOOLEAN DEFAULT FALSE,
foreign_vat VARCHAR(50), -- RFC extranjero si aplica
-- Estado
active BOOLEAN DEFAULT TRUE,
-- Auditoría
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
-- Constraints
CONSTRAINT uq_fiscal_positions_name UNIQUE(tenant_id, name)
);
-- Índices
CREATE INDEX idx_fiscal_positions_tenant ON fiscal_positions(tenant_id);
CREATE INDEX idx_fiscal_positions_auto ON fiscal_positions(auto_apply) WHERE auto_apply = TRUE;
CREATE INDEX idx_fiscal_positions_country ON fiscal_positions(country_id);
-- RLS
ALTER TABLE fiscal_positions ENABLE ROW LEVEL SECURITY;
CREATE POLICY fiscal_positions_tenant_isolation ON fiscal_positions
USING (tenant_id = current_setting('app.current_tenant')::UUID);
-- -----------------------------------------------------------------------------
-- Tabla: fiscal_position_tax_mappings
-- Descripción: Mapeo de impuestos origen → destino
-- -----------------------------------------------------------------------------
CREATE TABLE fiscal_position_tax_mappings (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
-- Mapeo
tax_src_id UUID NOT NULL REFERENCES taxes(id), -- Impuesto original
tax_dest_id UUID REFERENCES taxes(id), -- Impuesto destino (NULL = quitar)
-- Constraints
CONSTRAINT uq_fp_tax_mapping UNIQUE(fiscal_position_id, tax_src_id, tax_dest_id)
);
CREATE INDEX idx_fp_tax_mapping_position ON fiscal_position_tax_mappings(fiscal_position_id);
CREATE INDEX idx_fp_tax_mapping_src ON fiscal_position_tax_mappings(tax_src_id);
-- -----------------------------------------------------------------------------
-- Tabla: fiscal_position_account_mappings
-- Descripción: Mapeo de cuentas origen → destino
-- -----------------------------------------------------------------------------
CREATE TABLE fiscal_position_account_mappings (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fiscal_position_id UUID NOT NULL REFERENCES fiscal_positions(id) ON DELETE CASCADE,
-- Mapeo
account_src_id UUID NOT NULL REFERENCES accounts(id),
account_dest_id UUID NOT NULL REFERENCES accounts(id),
-- Constraints
CONSTRAINT uq_fp_account_mapping UNIQUE(fiscal_position_id, account_src_id)
);
CREATE INDEX idx_fp_account_mapping_position ON fiscal_position_account_mappings(fiscal_position_id);
3. Algoritmos de Cálculo
3.1 Motor de Cálculo de Impuestos
# services/tax_calculator.py
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Dict, Optional
from dataclasses import dataclass
from enum import Enum
class TaxAmountType(Enum):
PERCENT = 'percent'
FIXED = 'fixed'
DIVISION = 'division'
GROUP = 'group'
class RoundingMethod(Enum):
ROUND_PER_LINE = 'round_per_line'
ROUND_GLOBALLY = 'round_globally'
@dataclass
class TaxResult:
tax_id: str
name: str
amount: Decimal
base: Decimal
account_id: Optional[str]
tax_group_id: str
price_include: bool
tax_exigibility: str
repartition_line_id: str
tag_ids: List[str]
@dataclass
class ComputeAllResult:
total_excluded: Decimal
total_included: Decimal
total_void: Decimal
base_tags: List[str]
taxes: List[TaxResult]
class TaxCalculator:
"""Motor de cálculo de impuestos compatible con Odoo."""
def __init__(
self,
precision_rounding: Decimal = Decimal('0.01'),
rounding_method: RoundingMethod = RoundingMethod.ROUND_PER_LINE
):
self.precision = precision_rounding
self.rounding_method = rounding_method
def compute_all(
self,
taxes: List[Dict],
price_unit: Decimal,
quantity: Decimal = Decimal('1'),
currency: Optional[Dict] = None,
product: Optional[Dict] = None,
partner: Optional[Dict] = None,
is_refund: bool = False,
handle_price_include: bool = True
) -> ComputeAllResult:
"""
Calcula todos los impuestos para un precio unitario.
Args:
taxes: Lista de impuestos a aplicar
price_unit: Precio unitario
quantity: Cantidad
currency: Moneda para redondeo
product: Producto (para tags)
partner: Cliente/Proveedor
is_refund: Si es devolución
handle_price_include: Si manejar precio incluido
Returns:
ComputeAllResult con totales y desglose de impuestos
"""
if not taxes:
return ComputeAllResult(
total_excluded=price_unit * quantity,
total_included=price_unit * quantity,
total_void=Decimal('0'),
base_tags=[],
taxes=[]
)
# Fase 1: Aplanar y ordenar impuestos
sorted_taxes, group_per_tax = self._flatten_and_sort(taxes)
# Fase 2: Crear batches de cálculo
batches = self._create_batches(sorted_taxes, handle_price_include)
# Fase 3: Calcular cada impuesto
tax_details = self._compute_tax_details(
sorted_taxes=sorted_taxes,
batches=batches,
group_per_tax=group_per_tax,
price_unit=price_unit,
quantity=quantity,
handle_price_include=handle_price_include
)
# Fase 4: Aplicar distribución (repartition lines)
results = self._apply_repartition(
tax_details=tax_details,
is_refund=is_refund
)
# Fase 5: Calcular totales
total_excluded = price_unit * quantity
total_included = total_excluded
total_void = Decimal('0')
for detail in tax_details.values():
if detail['price_include']:
total_excluded -= detail['tax_amount']
else:
total_included += detail['tax_amount']
return ComputeAllResult(
total_excluded=self._round(total_excluded),
total_included=self._round(total_included),
total_void=total_void,
base_tags=[],
taxes=results
)
def _flatten_and_sort(
self,
taxes: List[Dict]
) -> tuple[List[Dict], Dict[str, Dict]]:
"""
Aplana impuestos grupo y ordena por secuencia.
Ejemplo:
Input: [G, B([A, D, F]), E, C]
Output: [A, D, F, C, E, G]
"""
flat_taxes = []
group_per_tax = {}
for tax in sorted(taxes, key=lambda t: t['sequence']):
if tax['amount_type'] == TaxAmountType.GROUP.value:
children = tax.get('children_taxes', [])
for child in sorted(children, key=lambda t: t['sequence']):
flat_taxes.append(child)
group_per_tax[child['id']] = tax
else:
flat_taxes.append(tax)
return flat_taxes, group_per_tax
def _create_batches(
self,
sorted_taxes: List[Dict],
handle_price_include: bool
) -> Dict[str, List[Dict]]:
"""
Agrupa impuestos en batches para cálculo conjunto.
Criterios de mismo batch:
- Mismo amount_type
- Mismo price_include (si handle_price_include)
- Mismo include_base_amount
"""
batches = {}
current_batch = []
for tax in reversed(sorted_taxes):
if not current_batch:
current_batch = [tax]
else:
first = current_batch[0]
same_batch = (
tax['amount_type'] == first['amount_type']
and (not handle_price_include or
tax.get('price_include') == first.get('price_include'))
and tax.get('include_base_amount') == first.get('include_base_amount')
)
if same_batch:
current_batch.insert(0, tax)
else:
for t in current_batch:
batches[t['id']] = current_batch.copy()
current_batch = [tax]
for t in current_batch:
batches[t['id']] = current_batch.copy()
return batches
def _compute_tax_details(
self,
sorted_taxes: List[Dict],
batches: Dict[str, List[Dict]],
group_per_tax: Dict[str, Dict],
price_unit: Decimal,
quantity: Decimal,
handle_price_include: bool
) -> Dict[str, Dict]:
"""Calcula el detalle de cada impuesto."""
raw_base = price_unit * quantity
tax_details = {}
# Inicializar estructura
for tax in sorted_taxes:
tax_details[tax['id']] = {
'tax': tax,
'batch': batches.get(tax['id'], [tax]),
'group': group_per_tax.get(tax['id']),
'raw_base': raw_base,
'tax_amount': Decimal('0'),
'base': Decimal('0'),
'price_include': tax.get('price_include', False),
'extra_base_for_tax': Decimal('0'),
'extra_base_for_base': Decimal('0'),
}
# Fase 1: Impuestos fijos (orden inverso)
for tax in reversed(sorted_taxes):
if tax['amount_type'] == TaxAmountType.FIXED.value:
detail = tax_details[tax['id']]
sign = Decimal('-1') if price_unit < 0 else Decimal('1')
detail['tax_amount'] = sign * quantity * Decimal(str(tax['amount']))
if self.rounding_method == RoundingMethod.ROUND_PER_LINE:
detail['tax_amount'] = self._round(detail['tax_amount'])
# Fase 2: Impuestos incluidos en precio (orden inverso)
for tax in reversed(sorted_taxes):
if tax.get('price_include') and tax['amount_type'] != TaxAmountType.FIXED.value:
detail = tax_details[tax['id']]
batch = detail['batch']
if tax['amount_type'] == TaxAmountType.PERCENT.value:
total_pct = sum(
Decimal(str(t['amount']))
for t in batch
) / Decimal('100')
factor = Decimal('1') / (Decimal('1') + total_pct)
detail['tax_amount'] = (
detail['raw_base'] * factor *
Decimal(str(tax['amount'])) / Decimal('100')
)
elif tax['amount_type'] == TaxAmountType.DIVISION.value:
detail['tax_amount'] = (
detail['raw_base'] *
Decimal(str(tax['amount'])) / Decimal('100')
)
if self.rounding_method == RoundingMethod.ROUND_PER_LINE:
detail['tax_amount'] = self._round(detail['tax_amount'])
# Fase 3: Impuestos excluidos del precio (orden normal)
for tax in sorted_taxes:
if not tax.get('price_include') and tax['amount_type'] != TaxAmountType.FIXED.value:
detail = tax_details[tax['id']]
batch = detail['batch']
# Calcular base afectada por impuestos anteriores
affected_base = detail['raw_base'] + detail['extra_base_for_tax']
if tax['amount_type'] == TaxAmountType.PERCENT.value:
detail['tax_amount'] = (
affected_base *
Decimal(str(tax['amount'])) / Decimal('100')
)
elif tax['amount_type'] == TaxAmountType.DIVISION.value:
total_pct = sum(
Decimal(str(t['amount']))
for t in batch
) / Decimal('100')
multiplicator = Decimal('1') - total_pct if total_pct != Decimal('1') else Decimal('1')
detail['tax_amount'] = (
affected_base *
Decimal(str(tax['amount'])) / Decimal('100') /
multiplicator
)
if self.rounding_method == RoundingMethod.ROUND_PER_LINE:
detail['tax_amount'] = self._round(detail['tax_amount'])
# Propagar a impuestos siguientes si include_base_amount
if tax.get('include_base_amount'):
for other_tax in sorted_taxes:
if other_tax['sequence'] > tax['sequence']:
other_detail = tax_details[other_tax['id']]
if other_tax.get('is_base_affected', True):
other_detail['extra_base_for_tax'] += detail['tax_amount']
# Calcular base para cada impuesto
for tax_id, detail in tax_details.items():
tax = detail['tax']
batch = detail['batch']
total_batch_tax = sum(
tax_details[t['id']]['tax_amount']
for t in batch
)
if detail['price_include']:
detail['base'] = detail['raw_base'] - total_batch_tax
else:
detail['base'] = detail['raw_base']
detail['base'] += detail['extra_base_for_base']
return tax_details
def _apply_repartition(
self,
tax_details: Dict[str, Dict],
is_refund: bool
) -> List[TaxResult]:
"""Aplica líneas de distribución y genera resultados."""
results = []
doc_type = 'refund' if is_refund else 'invoice'
for tax_id, detail in tax_details.items():
tax = detail['tax']
repartition_lines = tax.get('repartition_lines', [])
for line in repartition_lines:
if line['document_type'] != doc_type:
continue
if line['repartition_type'] == 'tax':
factor = Decimal(str(line['factor_percent'])) / Decimal('100')
amount = detail['tax_amount'] * factor
if self.rounding_method == RoundingMethod.ROUND_PER_LINE:
amount = self._round(amount)
results.append(TaxResult(
tax_id=tax_id,
name=tax['name'],
amount=amount,
base=detail['base'],
account_id=line.get('account_id'),
tax_group_id=tax['tax_group_id'],
price_include=detail['price_include'],
tax_exigibility=tax.get('tax_exigibility', 'on_invoice'),
repartition_line_id=line['id'],
tag_ids=line.get('tag_ids', [])
))
return results
def _round(self, value: Decimal) -> Decimal:
"""Redondea al precision configurado."""
return value.quantize(self.precision, rounding=ROUND_HALF_UP)
3.2 Detector de Posición Fiscal
# services/fiscal_position_detector.py
from typing import Optional, List, Dict
from dataclasses import dataclass
@dataclass
class FiscalPositionMatch:
position_id: str
name: str
score: int
reason: str
class FiscalPositionDetector:
"""Detecta la posición fiscal automática para un partner."""
def __init__(self, db_session):
self.db = db_session
async def detect(
self,
partner_id: str,
delivery_address_id: Optional[str] = None,
company_id: str = None
) -> Optional[FiscalPositionMatch]:
"""
Detecta posición fiscal usando ranking de criterios.
Ranking (mayor prioridad primero):
1. Posición manual asignada al partner
2. VAT requerido (score +2)
3. Código postal en rango (score +2)
4. Estado/Provincia coincide (score +2)
5. País coincide (score +2)
6. Grupo de países coincide (score +2)
7. Menor secuencia
"""
# Verificar posición manual
partner = await self.db.get_partner(partner_id)
if partner.get('fiscal_position_id'):
return FiscalPositionMatch(
position_id=partner['fiscal_position_id'],
name=partner['fiscal_position_name'],
score=100,
reason='Asignación manual'
)
# Obtener dirección relevante
address = await self._get_relevant_address(
partner_id,
delivery_address_id
)
# Obtener posiciones fiscales activas con auto_apply
positions = await self.db.get_fiscal_positions(
company_id=company_id,
auto_apply=True
)
# Calcular score para cada posición
scored_positions = []
for pos in positions:
score = 0
reasons = []
# VAT requerido
if pos['vat_required']:
if partner.get('vat'):
score += 2
reasons.append('VAT presente')
else:
continue # Descalificar si requiere VAT y no tiene
# Código postal
if pos['zip_from'] and pos['zip_to']:
if address.get('zip'):
if pos['zip_from'] <= address['zip'] <= pos['zip_to']:
score += 2
reasons.append(f"CP {address['zip']} en rango")
else:
continue # Descalificar si CP fuera de rango
# Estado/Provincia
if pos['state_ids']:
if address.get('state_id') in pos['state_ids']:
score += 2
reasons.append('Estado coincide')
else:
continue # Descalificar si estado no coincide
# País
if pos['country_id']:
if address.get('country_id') == pos['country_id']:
score += 2
reasons.append('País coincide')
else:
continue # Descalificar si país no coincide
# Grupo de países
if pos['country_group_id']:
country_in_group = await self.db.is_country_in_group(
address.get('country_id'),
pos['country_group_id']
)
if country_in_group:
score += 2
reasons.append('Grupo de países coincide')
else:
continue
# Si llegó aquí, es candidato
scored_positions.append(FiscalPositionMatch(
position_id=pos['id'],
name=pos['name'],
score=score,
reason=', '.join(reasons) if reasons else 'Match genérico'
))
if not scored_positions:
return None
# Ordenar por score (desc) y luego por sequence (asc)
scored_positions.sort(
key=lambda x: (-x.score, positions[x.position_id]['sequence'])
)
return scored_positions[0]
async def _get_relevant_address(
self,
partner_id: str,
delivery_address_id: Optional[str]
) -> Dict:
"""Obtiene la dirección relevante para determinar posición fiscal."""
if delivery_address_id:
return await self.db.get_address(delivery_address_id)
partner = await self.db.get_partner(partner_id)
# Preferir dirección de facturación
if partner.get('invoice_address_id'):
return await self.db.get_address(partner['invoice_address_id'])
# Usar dirección del partner
return {
'country_id': partner.get('country_id'),
'state_id': partner.get('state_id'),
'zip': partner.get('zip'),
}
3.3 Mapeador de Impuestos
# services/tax_mapper.py
from typing import List, Dict, Optional
class TaxMapper:
"""Mapea impuestos usando posición fiscal."""
def __init__(self, db_session):
self.db = db_session
async def map_taxes(
self,
tax_ids: List[str],
fiscal_position_id: Optional[str]
) -> List[str]:
"""
Mapea impuestos según posición fiscal.
Args:
tax_ids: Lista de IDs de impuestos originales
fiscal_position_id: ID de posición fiscal (opcional)
Returns:
Lista de IDs de impuestos mapeados
"""
if not fiscal_position_id or not tax_ids:
return tax_ids
# Obtener mapeos de la posición fiscal
mappings = await self.db.get_tax_mappings(fiscal_position_id)
# Crear diccionario de mapeo
tax_map = {}
for mapping in mappings:
src_id = mapping['tax_src_id']
dest_id = mapping['tax_dest_id'] # Puede ser None (quitar impuesto)
if src_id not in tax_map:
tax_map[src_id] = []
if dest_id:
tax_map[src_id].append(dest_id)
# Aplicar mapeo
result = []
for tax_id in tax_ids:
if tax_id in tax_map:
# Reemplazar con impuestos mapeados
result.extend(tax_map[tax_id])
else:
# Mantener original si no hay mapeo
result.append(tax_id)
# Eliminar duplicados preservando orden
seen = set()
unique_result = []
for tax_id in result:
if tax_id not in seen:
seen.add(tax_id)
unique_result.append(tax_id)
return unique_result
async def map_account(
self,
account_id: str,
fiscal_position_id: Optional[str]
) -> str:
"""
Mapea cuenta según posición fiscal.
Args:
account_id: ID de cuenta original
fiscal_position_id: ID de posición fiscal (opcional)
Returns:
ID de cuenta mapeada
"""
if not fiscal_position_id:
return account_id
mapping = await self.db.get_account_mapping(
fiscal_position_id,
account_id
)
return mapping['account_dest_id'] if mapping else account_id
4. API REST
4.1 Endpoints de Impuestos
# Grupos de Impuestos
POST /api/v1/tax-groups:
summary: Crear grupo de impuestos
body:
name: string (required)
country_id: uuid
tax_payable_account_id: uuid
tax_receivable_account_id: uuid
sequence: integer
response: TaxGroup
GET /api/v1/tax-groups:
summary: Listar grupos de impuestos
params:
country_id: uuid
active: boolean
response: TaxGroup[]
# Impuestos
POST /api/v1/taxes:
summary: Crear impuesto
body:
name: string (required)
amount_type: enum[percent, fixed, division, group]
amount: decimal
type_tax_use: enum[sale, purchase, none]
tax_group_id: uuid (required)
country_id: uuid (required)
price_include_override: enum[tax_included, tax_excluded]
include_base_amount: boolean
tax_exigibility: enum[on_invoice, on_payment]
cash_basis_transition_account_id: uuid
l10n_mx_factor_type: enum[Tasa, Cuota, Exento]
l10n_mx_tax_type: enum[iva, isr, ieps, local]
repartition_lines: RepartitionLine[]
children_tax_ids: uuid[] # Solo para tipo 'group'
response: Tax
GET /api/v1/taxes:
summary: Listar impuestos
params:
type_tax_use: enum
country_id: uuid
active: boolean
tax_group_id: uuid
response: Tax[]
GET /api/v1/taxes/{id}:
summary: Obtener impuesto
response: Tax
PUT /api/v1/taxes/{id}:
summary: Actualizar impuesto
body: Partial<Tax>
response: Tax
DELETE /api/v1/taxes/{id}:
summary: Desactivar impuesto
response: { success: boolean }
# Cálculo de Impuestos
POST /api/v1/taxes/compute:
summary: Calcular impuestos para una línea
body:
tax_ids: uuid[]
price_unit: decimal
quantity: decimal
currency_id: uuid
product_id: uuid
partner_id: uuid
is_refund: boolean
response:
total_excluded: decimal
total_included: decimal
taxes: TaxResult[]
# Posiciones Fiscales
POST /api/v1/fiscal-positions:
summary: Crear posición fiscal
body:
name: string (required)
auto_apply: boolean
country_id: uuid
country_group_id: uuid
state_ids: uuid[]
zip_from: string
zip_to: string
vat_required: boolean
tax_mappings: TaxMapping[]
account_mappings: AccountMapping[]
response: FiscalPosition
GET /api/v1/fiscal-positions:
summary: Listar posiciones fiscales
params:
active: boolean
auto_apply: boolean
response: FiscalPosition[]
POST /api/v1/fiscal-positions/detect:
summary: Detectar posición fiscal para partner
body:
partner_id: uuid (required)
delivery_address_id: uuid
response:
fiscal_position_id: uuid
name: string
reason: string
POST /api/v1/fiscal-positions/{id}/map-taxes:
summary: Mapear impuestos con posición fiscal
body:
tax_ids: uuid[]
response:
mapped_tax_ids: uuid[]
4.2 Schemas de Respuesta
// types/tax.ts
interface TaxGroup {
id: string;
name: string;
sequence: number;
country_id: string | null;
tax_payable_account_id: string | null;
tax_receivable_account_id: string | null;
advance_tax_payment_account_id: string | null;
preceding_subtotal: string | null;
created_at: string;
updated_at: string;
}
interface Tax {
id: string;
name: string;
description: string | null;
amount_type: 'percent' | 'fixed' | 'division' | 'group';
amount: number;
type_tax_use: 'sale' | 'purchase' | 'none';
tax_scope: 'consu' | 'service' | null;
tax_group_id: string;
tax_group: TaxGroup;
price_include: boolean; // Computado
price_include_override: 'tax_included' | 'tax_excluded' | null;
include_base_amount: boolean;
is_base_affected: boolean;
tax_exigibility: 'on_invoice' | 'on_payment';
cash_basis_transition_account_id: string | null;
country_id: string;
l10n_mx_factor_type: 'Tasa' | 'Cuota' | 'Exento';
l10n_mx_tax_type: 'iva' | 'isr' | 'ieps' | 'local' | null;
active: boolean;
sequence: number;
repartition_lines: TaxRepartitionLine[];
children_taxes: Tax[]; // Solo para tipo 'group'
created_at: string;
updated_at: string;
}
interface TaxRepartitionLine {
id: string;
document_type: 'invoice' | 'refund';
repartition_type: 'base' | 'tax';
factor_percent: number;
account_id: string | null;
account: Account | null;
sequence: number;
tag_ids: string[];
}
interface FiscalPosition {
id: string;
name: string;
sequence: number;
auto_apply: boolean;
country_id: string | null;
country_group_id: string | null;
state_ids: string[];
zip_from: string | null;
zip_to: string | null;
vat_required: boolean;
foreign_vat: string | null;
active: boolean;
tax_mappings: FiscalPositionTaxMapping[];
account_mappings: FiscalPositionAccountMapping[];
created_at: string;
updated_at: string;
}
interface FiscalPositionTaxMapping {
id: string;
tax_src_id: string;
tax_src: Tax;
tax_dest_id: string | null;
tax_dest: Tax | null;
}
interface FiscalPositionAccountMapping {
id: string;
account_src_id: string;
account_src: Account;
account_dest_id: string;
account_dest: Account;
}
interface TaxComputeResult {
total_excluded: number;
total_included: number;
total_void: number;
base_tags: string[];
taxes: TaxResultLine[];
}
interface TaxResultLine {
tax_id: string;
name: string;
amount: number;
base: number;
account_id: string | null;
tax_group_id: string;
price_include: boolean;
tax_exigibility: 'on_invoice' | 'on_payment';
repartition_line_id: string;
tag_ids: string[];
}
5. Interfaz de Usuario
5.1 Configuración de Impuestos
┌─────────────────────────────────────────────────────────────────────────────┐
│ Contabilidad > Configuración > Impuestos │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ [+ Nuevo Impuesto] [Filtros ▼] [Agrupar ▼] 🔍 Buscar... │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Grupo: IVA │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ ☑ │ Nombre │ Tipo │ Monto │ Uso │ Incluido │ Act │ │
│ │───│─────────────────│──────────│─────────│─────────│──────────│─────│ │
│ │ □ │ IVA 16% │ Porcent. │ 16.00% │ Ventas │ No │ ✓ │ │
│ │ □ │ IVA 16% │ Porcent. │ 16.00% │ Compras │ No │ ✓ │ │
│ │ □ │ IVA 8% │ Porcent. │ 8.00% │ Ventas │ No │ ✓ │ │
│ │ □ │ IVA 0% │ Porcent. │ 0.00% │ Ventas │ No │ ✓ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Grupo: Retenciones │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ □ │ Ret. IVA 10.67% │ Porcent. │ -10.67% │ Compras │ No │ ✓ │ │
│ │ □ │ Ret. ISR 10% │ Porcent. │ -10.00% │ Compras │ No │ ✓ │ │
│ │ □ │ Ret. ISR 1.25% │ Porcent. │ -1.25% │ Compras │ No │ ✓ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Grupo: IEPS │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ □ │ IEPS 8% │ Porcent. │ 8.00% │ Ventas │ No │ ✓ │ │
│ │ □ │ IEPS 25% │ Porcent. │ 25.00% │ Ventas │ No │ ✓ │ │
│ │ □ │ IEPS 53% │ Porcent. │ 53.00% │ Ventas │ No │ ✓ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.2 Formulario de Impuesto
┌─────────────────────────────────────────────────────────────────────────────┐
│ Impuesto: IVA 16% Ventas [Guardar] [✕] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────┬──────────────────────────────────┐ │
│ │ Información General │ Configuración México │ │
│ ├──────────────────────────────────┼──────────────────────────────────┤ │
│ │ │ │ │
│ │ Nombre: [IVA 16% ] │ Tipo Factor: (●) Tasa │ │
│ │ │ ( ) Cuota │ │
│ │ Tipo Cálculo: [Porcentaje ▼] │ ( ) Exento │ │
│ │ │ │ │
│ │ Monto: [16.00 ] % │ Tipo Impuesto: [IVA ▼] │ │
│ │ │ │ │
│ │ Ámbito: [Ventas ▼] │ │ │
│ │ │ │ │
│ │ Grupo: [IVA 16% ▼] │ │ │
│ │ │ │ │
│ └──────────────────────────────────┴──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Opciones Avanzadas │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Precio: [ ] Impuesto incluido en precio │ │
│ │ │ │
│ │ Cascada: [ ] Afectar base de impuestos siguientes │ │
│ │ [✓] Afectado por impuestos anteriores │ │
│ │ │ │
│ │ Exigibilidad: (●) Al facturar │ │
│ │ ( ) Al cobrar (Base de caja) │ │
│ │ │ │
│ │ Cuenta transición: [118.01 - IVA pagado pendiente ▼] │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Distribución Contable [+ Línea] │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Facturas: │ │
│ │ ┌────────────┬────────────┬──────────────────────────────┬────────┐ │ │
│ │ │ Tipo │ % │ Cuenta │ Tags │ │ │
│ │ ├────────────┼────────────┼──────────────────────────────┼────────┤ │ │
│ │ │ Base │ 100% │ │ +DIOT │ │ │
│ │ │ Impuesto │ 100% │ 208.01 - IVA trasladado │ +DIOT │ │ │
│ │ └────────────┴────────────┴──────────────────────────────┴────────┘ │ │
│ │ │ │
│ │ Notas de Crédito: │ │
│ │ ┌────────────┬────────────┬──────────────────────────────┬────────┐ │ │
│ │ │ Tipo │ % │ Cuenta │ Tags │ │ │
│ │ ├────────────┼────────────┼──────────────────────────────┼────────┤ │ │
│ │ │ Base │ 100% │ │ +DIOT │ │ │
│ │ │ Impuesto │ 100% │ 208.01 - IVA trasladado │ +DIOT │ │ │
│ │ └────────────┴────────────┴──────────────────────────────┴────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.3 Posiciones Fiscales
┌─────────────────────────────────────────────────────────────────────────────┐
│ Posición Fiscal: Cliente Extranjero [Guardar] [✕] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Información General │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ Nombre: [Cliente Extranjero ] │ │
│ │ │ │
│ │ [✓] Detectar automáticamente │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Criterios de Detección │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ País: [≠ México ▼] │ │
│ │ │ │
│ │ Grupo de países: [ ▼] │ │
│ │ │ │
│ │ Estados: [ ▼] │ │
│ │ │ │
│ │ Código Postal: Desde [ ] Hasta [ ] │ │
│ │ │ │
│ │ [ ] Requiere RFC/VAT │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Mapeo de Impuestos [+ Mapeo] │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌──────────────────────────┬──────────────────────────┬───────────┐ │ │
│ │ │ Impuesto Origen │ Impuesto Destino │ Acciones │ │ │
│ │ ├──────────────────────────┼──────────────────────────┼───────────┤ │ │
│ │ │ IVA 16% Ventas │ IVA 0% Exportación │ [🗑] │ │ │
│ │ │ IVA 8% Ventas │ IVA 0% Exportación │ [🗑] │ │ │
│ │ │ IEPS Ventas │ (Sin impuesto) │ [🗑] │ │ │
│ │ └──────────────────────────┴──────────────────────────┴───────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Mapeo de Cuentas [+ Mapeo] │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌──────────────────────────┬──────────────────────────┬───────────┐ │ │
│ │ │ Cuenta Origen │ Cuenta Destino │ Acciones │ │ │
│ │ ├──────────────────────────┼──────────────────────────┼───────────┤ │ │
│ │ │ 401.01 - Ventas Nac. │ 401.02 - Ventas Exp. │ [🗑] │ │ │
│ │ └──────────────────────────┴──────────────────────────┴───────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6. Datos de Referencia - México
6.1 Impuestos Predefinidos
-- Insertar grupos de impuestos México
INSERT INTO tax_groups (tenant_id, name, sequence, country_id) VALUES
('{tenant}', 'IVA 0%', 1, '{mx}'),
('{tenant}', 'IVA 8%', 2, '{mx}'),
('{tenant}', 'IVA 16%', 3, '{mx}'),
('{tenant}', 'Exento', 4, '{mx}'),
('{tenant}', 'Retención IVA', 10, '{mx}'),
('{tenant}', 'Retención ISR', 11, '{mx}'),
('{tenant}', 'IEPS 8%', 20, '{mx}'),
('{tenant}', 'IEPS 25%', 21, '{mx}'),
('{tenant}', 'IEPS 26.5%', 22, '{mx}'),
('{tenant}', 'IEPS 30%', 23, '{mx}'),
('{tenant}', 'IEPS 53%', 24, '{mx}');
-- IVA Ventas
INSERT INTO taxes (tenant_id, name, amount_type, amount, type_tax_use,
tax_group_id, country_id, tax_exigibility,
l10n_mx_factor_type, l10n_mx_tax_type) VALUES
('{tenant}', 'IVA 16%', 'percent', 16.00, 'sale',
'{iva_16_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'IVA 8%', 'percent', 8.00, 'sale',
'{iva_8_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'IVA 0%', 'percent', 0.00, 'sale',
'{iva_0_group}', '{mx}', 'on_invoice', 'Tasa', 'iva'),
('{tenant}', 'Exento', 'percent', 0.00, 'sale',
'{exento_group}', '{mx}', 'on_invoice', 'Exento', 'iva');
-- IVA Compras
INSERT INTO taxes (tenant_id, name, amount_type, amount, type_tax_use,
tax_group_id, country_id, tax_exigibility,
l10n_mx_factor_type, l10n_mx_tax_type) VALUES
('{tenant}', 'IVA 16%', 'percent', 16.00, 'purchase',
'{iva_16_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'IVA 8%', 'percent', 8.00, 'purchase',
'{iva_8_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'IVA 0%', 'percent', 0.00, 'purchase',
'{iva_0_group}', '{mx}', 'on_invoice', 'Tasa', 'iva');
-- Retenciones IVA (Monto negativo = retención)
INSERT INTO taxes (tenant_id, name, amount_type, amount, type_tax_use,
tax_group_id, country_id, tax_exigibility,
l10n_mx_factor_type, l10n_mx_tax_type) VALUES
('{tenant}', 'Ret. IVA 10.67%', 'percent', -10.67, 'purchase',
'{ret_iva_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'Ret. IVA 10%', 'percent', -10.00, 'purchase',
'{ret_iva_group}', '{mx}', 'on_payment', 'Tasa', 'iva'),
('{tenant}', 'Ret. IVA 4%', 'percent', -4.00, 'purchase',
'{ret_iva_group}', '{mx}', 'on_payment', 'Tasa', 'iva');
-- Retenciones ISR
INSERT INTO taxes (tenant_id, name, amount_type, amount, type_tax_use,
tax_group_id, country_id, tax_exigibility,
l10n_mx_factor_type, l10n_mx_tax_type) VALUES
('{tenant}', 'Ret. ISR 10%', 'percent', -10.00, 'purchase',
'{ret_isr_group}', '{mx}', 'on_invoice', 'Tasa', 'isr'),
('{tenant}', 'Ret. ISR 1.25% RESICO', 'percent', -1.25, 'purchase',
'{ret_isr_group}', '{mx}', 'on_invoice', 'Tasa', 'isr');
-- IEPS
INSERT INTO taxes (tenant_id, name, amount_type, amount, type_tax_use,
tax_group_id, country_id, tax_exigibility,
l10n_mx_factor_type, l10n_mx_tax_type) VALUES
('{tenant}', 'IEPS 8%', 'percent', 8.00, 'sale',
'{ieps_8_group}', '{mx}', 'on_payment', 'Tasa', 'ieps'),
('{tenant}', 'IEPS 25%', 'percent', 25.00, 'sale',
'{ieps_25_group}', '{mx}', 'on_payment', 'Tasa', 'ieps'),
('{tenant}', 'IEPS 26.5%', 'percent', 26.50, 'sale',
'{ieps_265_group}', '{mx}', 'on_payment', 'Tasa', 'ieps'),
('{tenant}', 'IEPS 30%', 'percent', 30.00, 'sale',
'{ieps_30_group}', '{mx}', 'on_payment', 'Tasa', 'ieps'),
('{tenant}', 'IEPS 53%', 'percent', 53.00, 'sale',
'{ieps_53_group}', '{mx}', 'on_payment', 'Tasa', 'ieps');
6.2 Posiciones Fiscales México
-- Posiciones fiscales predefinidas
INSERT INTO fiscal_positions (tenant_id, name, auto_apply, country_id, vat_required) VALUES
('{tenant}', 'Cliente Nacional', TRUE, '{mx}', FALSE),
('{tenant}', 'Cliente Extranjero', TRUE, NULL, FALSE), -- NULL = cualquier país excepto México
('{tenant}', 'Zona Fronteriza Norte', TRUE, '{mx}', FALSE);
-- Mapeos para Cliente Extranjero
INSERT INTO fiscal_position_tax_mappings (fiscal_position_id, tax_src_id, tax_dest_id) VALUES
('{fp_extranjero}', '{iva_16_venta}', '{iva_0_venta}'),
('{fp_extranjero}', '{iva_8_venta}', '{iva_0_venta}'),
('{fp_extranjero}', '{ieps_venta}', NULL); -- NULL = quitar impuesto
-- Mapeos para Zona Fronteriza (estados específicos)
UPDATE fiscal_positions
SET state_ids = ARRAY['{baja_california}', '{sonora}', '{chihuahua}', '{coahuila}', '{tamaulipas}']
WHERE name = 'Zona Fronteriza Norte';
INSERT INTO fiscal_position_tax_mappings (fiscal_position_id, tax_src_id, tax_dest_id) VALUES
('{fp_frontera}', '{iva_16_venta}', '{iva_8_venta}');
7. Integración CFDI 4.0
7.1 Mapeo de Campos
| Campo ERP | Campo CFDI | Descripción |
|---|---|---|
l10n_mx_factor_type |
TipoFactor |
Tasa, Cuota, Exento |
l10n_mx_tax_type |
Impuesto |
001=ISR, 002=IVA, 003=IEPS |
amount |
TasaOCuota |
Tasa decimal (0.160000) |
| Calculado | Base |
Base gravable |
| Calculado | Importe |
Monto del impuesto |
7.2 Generación de Nodo Impuestos
# services/cfdi_tax_generator.py
def generate_cfdi_taxes(invoice_line_taxes: List[Dict]) -> Dict:
"""
Genera estructura de impuestos para CFDI 4.0.
Returns:
{
"Traslados": [...],
"Retenciones": [...],
"TotalImpuestosTrasladados": Decimal,
"TotalImpuestosRetenidos": Decimal
}
"""
traslados = []
retenciones = []
for tax_line in invoice_line_taxes:
tax = tax_line['tax']
# Mapear código de impuesto SAT
impuesto_code = {
'isr': '001',
'iva': '002',
'ieps': '003'
}.get(tax['l10n_mx_tax_type'], '002')
node = {
'Base': str(tax_line['base']),
'Impuesto': impuesto_code,
'TipoFactor': tax['l10n_mx_factor_type'],
}
if tax['l10n_mx_factor_type'] != 'Exento':
# Convertir porcentaje a decimal (16% -> 0.160000)
tasa = abs(tax['amount']) / 100
node['TasaOCuota'] = f"{tasa:.6f}"
node['Importe'] = str(abs(tax_line['amount']))
# Separar traslados de retenciones
if tax['amount'] < 0:
retenciones.append(node)
else:
traslados.append(node)
return {
'Traslados': traslados if traslados else None,
'Retenciones': retenciones if retenciones else None,
'TotalImpuestosTrasladados': sum(
Decimal(t['Importe']) for t in traslados if 'Importe' in t
),
'TotalImpuestosRetenidos': sum(
Decimal(r['Importe']) for r in retenciones if 'Importe' in r
),
}
8. Casos de Prueba
8.1 Cálculo Básico IVA 16%
def test_iva_16_percent():
"""Prueba cálculo básico de IVA 16%."""
calculator = TaxCalculator()
taxes = [{
'id': 'iva-16',
'name': 'IVA 16%',
'amount_type': 'percent',
'amount': 16.0,
'sequence': 1,
'tax_group_id': 'iva-group',
'price_include': False,
'repartition_lines': [
{'id': 'r1', 'document_type': 'invoice', 'repartition_type': 'base', 'factor_percent': 100},
{'id': 'r2', 'document_type': 'invoice', 'repartition_type': 'tax', 'factor_percent': 100, 'account_id': 'iva-account'},
]
}]
result = calculator.compute_all(
taxes=taxes,
price_unit=Decimal('100.00'),
quantity=Decimal('1')
)
assert result.total_excluded == Decimal('100.00')
assert result.total_included == Decimal('116.00')
assert len(result.taxes) == 1
assert result.taxes[0].amount == Decimal('16.00')
assert result.taxes[0].base == Decimal('100.00')
8.2 IVA Incluido en Precio
def test_iva_price_included():
"""Prueba IVA incluido en precio."""
calculator = TaxCalculator()
taxes = [{
'id': 'iva-16-inc',
'name': 'IVA 16% (incluido)',
'amount_type': 'percent',
'amount': 16.0,
'sequence': 1,
'tax_group_id': 'iva-group',
'price_include': True,
'repartition_lines': [...]
}]
result = calculator.compute_all(
taxes=taxes,
price_unit=Decimal('116.00'), # Precio con IVA
quantity=Decimal('1')
)
assert result.total_excluded == Decimal('100.00')
assert result.total_included == Decimal('116.00')
assert result.taxes[0].amount == Decimal('16.00')
assert result.taxes[0].base == Decimal('100.00')
8.3 IVA + Retención
def test_iva_with_retention():
"""Prueba IVA 16% con retención IVA 10.67%."""
calculator = TaxCalculator()
taxes = [
{
'id': 'iva-16',
'name': 'IVA 16%',
'amount_type': 'percent',
'amount': 16.0,
'sequence': 1,
'price_include': False,
'repartition_lines': [...]
},
{
'id': 'ret-iva',
'name': 'Ret. IVA 10.67%',
'amount_type': 'percent',
'amount': -10.67, # Negativo = retención
'sequence': 2,
'price_include': False,
'repartition_lines': [...]
}
]
result = calculator.compute_all(
taxes=taxes,
price_unit=Decimal('100.00'),
quantity=Decimal('1')
)
assert result.total_excluded == Decimal('100.00')
# Total = 100 + 16 - 10.67 = 105.33
assert result.total_included == Decimal('105.33')
assert result.taxes[0].amount == Decimal('16.00') # IVA
assert result.taxes[1].amount == Decimal('-10.67') # Retención
8.4 IEPS + IVA en Cascada
def test_ieps_iva_cascade():
"""Prueba IEPS 53% + IVA 16% en cascada (bebidas alcohólicas)."""
calculator = TaxCalculator()
taxes = [
{
'id': 'ieps-53',
'name': 'IEPS 53%',
'amount_type': 'percent',
'amount': 53.0,
'sequence': 1,
'include_base_amount': True, # Afecta base de IVA
'price_include': False,
'repartition_lines': [...]
},
{
'id': 'iva-16',
'name': 'IVA 16%',
'amount_type': 'percent',
'amount': 16.0,
'sequence': 2,
'is_base_affected': True, # Afectado por IEPS
'price_include': False,
'repartition_lines': [...]
}
]
result = calculator.compute_all(
taxes=taxes,
price_unit=Decimal('100.00'),
quantity=Decimal('1')
)
# IEPS: 100 * 0.53 = 53
# IVA: (100 + 53) * 0.16 = 24.48
# Total: 100 + 53 + 24.48 = 177.48
assert result.total_excluded == Decimal('100.00')
assert result.total_included == Decimal('177.48')
assert result.taxes[0].amount == Decimal('53.00') # IEPS
assert result.taxes[1].amount == Decimal('24.48') # IVA sobre base + IEPS
9. Plan de Implementación
Fase 1: Infraestructura Base (3 SP)
- Crear tablas de impuestos y grupos
- Implementar tipos ENUM
- Configurar RLS
Fase 2: Motor de Cálculo (3 SP)
- Implementar TaxCalculator
- Soportar tipos: percent, fixed, division
- Pruebas unitarias
Fase 3: Posiciones Fiscales (2 SP)
- Implementar detector automático
- Mapeo de impuestos y cuentas
- API REST
10. Referencias
- Odoo 18.0:
account/models/account_tax.py - SAT México: Catálogo c_Impuesto CFDI 4.0
- RFC CFDI: Anexo 20 versión 4.0