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>
104 lines
5.2 KiB
Python
104 lines
5.2 KiB
Python
"""Catálogo de conceptos de facturación — ``fin.concepts``.
|
|
|
|
A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada
|
|
concepto que la empresa factura (flete internacional, despacho, almacenaje…) se
|
|
registra una vez y queda amarrado a la clave de producto/servicio del SAT que le
|
|
corresponde.
|
|
|
|
La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos
|
|
compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál
|
|
descripción corresponde a la clave, así que la unicidad se garantiza por índice y se
|
|
valida además en el service para devolver un 409 con mensaje entendible.
|
|
"""
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
CheckConstraint,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
|
from core.database import Base
|
|
|
|
from ..catalogs.models import Tax, ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
|
|
|
|
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
|
|
# lógica libera su clave y su código para uno nuevo.
|
|
_ALIVE = text("deleted_at IS NULL")
|
|
|
|
|
|
class Concept(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Concepto facturable de una empresa, ligado a una clave ProdServ del SAT."""
|
|
|
|
__tablename__ = "concepts"
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_fin_concepts_code",
|
|
"tenant_id", "company_id", "code",
|
|
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
|
),
|
|
Index(
|
|
"uq_fin_concepts_product_service",
|
|
"tenant_id", "company_id", "product_service_id",
|
|
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
|
),
|
|
# Duplicados de la migración a propósito: las pruebas construyen el esquema con
|
|
# ``create_all``, así que sin esto validarían una base distinta de la de producción.
|
|
CheckConstraint(
|
|
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
|
|
name="ck_fin_concepts_default_tax_factor",
|
|
),
|
|
CheckConstraint(
|
|
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
|
|
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
|
|
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
|
|
name="ck_fin_concepts_default_tax_coherente",
|
|
),
|
|
{"schema": "fin"},
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto
|
|
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
product_service_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True
|
|
)
|
|
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
|
)
|
|
tax_object_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
|
)
|
|
# ── Configuración fiscal por defecto ────────────────────────────────────────────────────
|
|
# La partida hereda de aquí su impuesto cuando el concepto lo define, y entonces el % global
|
|
# de la factura deja de aplicarle. Es lo que permite tener conceptos que no causan IVA:
|
|
# exentos (factor 'Exento') o a tasa 0% (factor 'Tasa' con tasa 0), que fiscalmente NO son lo
|
|
# mismo ni entre sí ni que un ObjetoImp 01 «no objeto de impuesto».
|
|
default_tax_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.taxes.id"), nullable=True
|
|
)
|
|
# FRACCIÓN con 6 decimales (0.160000), igual que ``invoice_item_taxes.rate`` y que el
|
|
# TasaOCuota del XML. NO es el porcentaje de ``invoices.tax_rate`` (16.00): el tipo coincide
|
|
# con el destino justo para que la copia sea trivial y no haya un factor 100 en el camino.
|
|
default_tax_rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True)
|
|
default_tax_factor: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
|
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# Cargadas con selectinload para que el listado no dispare N+1 consultas.
|
|
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
|
|
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
|
|
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
|
|
default_tax: Mapped["Tax | None"] = relationship("Tax", lazy="selectin")
|