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>
111 lines
4.4 KiB
Python
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
|