Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/concepts/dto.py
Jair Cedillo 1f8fdf2866 feat(fin): el IVA se calcula por partida, no con un % global
El impuesto vivía en dos planos que podían divergir: el dinero salía de invoices.tax_rate
aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada partida. Una partida
que no causa IVA se lo cobraba igual, y con una retención capturada la factura pedía 1160
mientras el comprobante declaraba 1060 — cobranza persiguiendo un adeudo inexistente.

Ahora fin.invoice_item_taxes es la fuente del impuesto y _recompute la lee:
total = subtotal + trasladado - retenido, la misma composición del comprobante. Se agrega
withheld_amount, porque sin guardarlo el total no cuadraba con subtotal + tax_amount y nada
en la fila lo explicaba.

NINGUNA factura existente cambia de total. El cálculo se versiona con taxes_per_item: las
nuevas nacen en true, las 9 que ya existían quedaron en false con la fórmula que las emitió.
Backfillear habría exigido poner tax_object_id='02' en partidas que nadie clasificó —
inventar una afirmación fiscal — y _recompute corre desde create_payment, así que un pago
meses después le habría bajado el total, dejado saldo negativo, marcado 'pagada' y pisado su
paid_at. El rollback es un UPDATE.

Conceptos que no causan IVA: fin.concepts gana impuesto, tasa y tipo de factor por defecto,
que la partida hereda como ya heredaba las claves fiscales. Exento (ObjetoImp 02 +
TipoFactor Exento) y tasa 0% son distintos y ahora los dos son expresables; el 0% era
incapturable, el rate==0 borraba el traslado y el timbrado fallaba pidiendo el desglose.

Redondeo: manda el comprobante. subtotal = Σ round(qty × precio) por renglón, no round(Σ),
y tax_amount es la suma de los importes ya materializados, todo ROUND_HALF_UP con el mismo
`cents` que usa el builder. El PAC valida que SubTotal sea la suma de los Importe.

Trampas que el cambio cerró:
- _build_data construía TaxLine sin factor: un exento se habría timbrado como gravado al 0%,
  un CFDI incorrecto que el PAC acepta.
- CfdiData.transferred no excluía Exento mientras _add_totals sí: una fila exenta con importe
  dejaba el XML inconsistente consigo mismo.
- El guard de captura manual era heurístico (retención o impuesto != IVA), así que un IVA al
  8% capturado volvía al 16% por cambiarle la cantidad a la partida. Ahora is_manual es un
  hecho registrado.
- delete_item dejaba los impuestos vivos: cobro fantasma de una partida que ya no existe.
- set_item_tax y delete_item_tax no recalculaban la factura.
- El PDF imprimía "IVA (16%)" y no mostraba retenciones. Ahora desglosa por
  (impuesto, factor, tasa) con el mismo criterio del comprobante, y los exentos se listan con
  su base y sin importe: es lo que explica por qué el total no es subtotal × 1.16.

stamp_invoice verifica que invoice.total sea el del comprobante antes de sellar, y falla en
vez de corregir: el timbrado es donde el dinero se vuelve irreversible y recalcular ahí
cambiaría montos sin que nadie lo vea.

Cuota queda fuera con 422 explícito: su importe es cuota × cantidad, no base × tasa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:37:26 -05:00

111 lines
4.4 KiB
Python

"""Esquemas del catálogo de conceptos de facturación."""
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from ..catalogs.dto import (
ProductServiceResponse,
TaxObjectResponse,
TaxResponse,
UnitOfMeasureResponse,
)
# Configuración fiscal por defecto del concepto: es lo que permite tener conceptos que no
# causan IVA. La tasa va como FRACCIÓN (0.16), igual que en la partida y en el XML, NO como el
# porcentaje de la factura (16.00).
_FISCAL_DEFAULTS = ("default_tax_id", "default_tax_rate", "default_tax_factor")
class _FiscalDefaultsMixin(BaseModel):
"""Valida que la configuración fiscal esté completa o vacía, nunca a medias.
Espeja el CHECK de la base para que el error salga como un 422 legible en vez de un
IntegrityError, y para que el service no tenga que adivinar un estado medio capturado.
"""
@model_validator(mode="after")
def _valida_defaults_fiscales(self):
puestos = {c for c in _FISCAL_DEFAULTS if getattr(self, c, None) is not None}
if not puestos:
return self
if self.default_tax_id is None or self.default_tax_factor is None:
raise ValueError(
"La configuración fiscal del concepto necesita impuesto y tipo de factor"
)
if self.default_tax_factor == "Exento":
if self.default_tax_rate:
raise ValueError("Un concepto exento no lleva tasa")
elif self.default_tax_rate is None:
raise ValueError("Un concepto con factor Tasa necesita su tasa (0 para el 0%)")
return self
class ConceptBase(_FiscalDefaultsMixin):
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
description: str = Field(..., min_length=1, max_length=500)
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str = Field("MXN", min_length=3, max_length=3)
is_active: bool = True
notes: str | None = None
class ConceptCreate(ConceptBase):
pass
class ConceptUpdate(_FiscalDefaultsMixin):
"""Actualización parcial: solo se tocan los campos enviados."""
code: str | None = Field(None, min_length=1, max_length=40)
description: str | None = Field(None, min_length=1, max_length=500)
product_service_id: int | None = None
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, min_length=3, max_length=3)
is_active: bool | None = None
notes: str | None = None
class ConceptResponse(ConceptBase):
"""Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI."""
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
product_service: ProductServiceResponse | None = None
unit_of_measure: UnitOfMeasureResponse | None = None
tax_object: TaxObjectResponse | None = None
default_tax: TaxResponse | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime