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>
203 lines
11 KiB
Python
203 lines
11 KiB
Python
from datetime import date, datetime
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
CheckConstraint,
|
|
Date,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Numeric,
|
|
String,
|
|
Text,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
|
from core.database import Base
|
|
|
|
from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK)
|
|
PaymentForm,
|
|
PaymentMethod,
|
|
ProductService,
|
|
Tax,
|
|
TaxObject,
|
|
UnitOfMeasure,
|
|
VoucherType,
|
|
)
|
|
from ..concepts.models import Concept # noqa: F401
|
|
|
|
|
|
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
|
|
|
__tablename__ = "invoices"
|
|
__table_args__ = {"schema": "fin"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
|
|
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
|
|
shipment_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
|
|
)
|
|
quote_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.quotes.id"), nullable=True
|
|
)
|
|
account_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
|
)
|
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
|
# Tipo de cambio a MXN. Obligatorio para timbrar cuando la moneda no es MXN (lo exige
|
|
# c_Moneda del SAT vía CfdiData.validate); en MXN se queda en NULL y el CFDI no lo lleva.
|
|
exchange_rate: Mapped[float | None] = mapped_column(Numeric(14, 6), nullable=True)
|
|
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
|
|
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# % de IVA POR DEFECTO de las partidas nuevas objeto de impuesto. Con taxes_per_item activo
|
|
# NO determina el total: el impuesto sale de las filas de invoice_item_taxes.
|
|
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0"))
|
|
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# Impuestos retenidos. Restan del total, igual que en el comprobante.
|
|
withheld_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# Versiona el cálculo del impuesto. Las facturas nuevas nacen en true (suma por partida); las
|
|
# que existían antes del cambio quedaron en false y conservan la fórmula con la que se
|
|
# emitieron, para que su total no se mueva sola al registrarles un pago.
|
|
taxes_per_item: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
|
total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
balance: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# Costos reales de la operación traídos de Operaciones al cierre (R-F-02)
|
|
ops_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
|
bank_info: Mapped[str | None] = mapped_column(Text, nullable=True) # datos bancarios
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
# ----- Envío al cliente (R-F-05): PDF almacenado en MinIO -----
|
|
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
# ----- Revisión del cliente (R-F-06) -----
|
|
client_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
client_approved: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# ----- Datos fiscales del CFDI (catálogos SAT) -----
|
|
# Nullables: las facturas emitidas antes de existir los catálogos no los tienen.
|
|
voucher_type_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.voucher_types.id"), nullable=True
|
|
)
|
|
payment_form_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.payment_forms.id"), nullable=True
|
|
)
|
|
payment_method_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
|
|
)
|
|
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
|
# ----- Modo de timbrado (por factura, no por entorno) -----
|
|
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
|
|
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
|
|
# cambiarlo después falsearía el registro de con qué intención se emitió.
|
|
stamping_mode: Mapped[str] = mapped_column(
|
|
String(12), nullable=False, server_default=text("'pruebas'")
|
|
)
|
|
|
|
|
|
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Concepto de una factura (transporte, flete, despacho, gastos en destino, otros)."""
|
|
|
|
__tablename__ = "invoice_items"
|
|
__table_args__ = {"schema": "fin"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
invoice_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
|
)
|
|
# Texto libre histórico: lo consume el PDF actual y se conserva obligatorio.
|
|
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
|
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# ----- Datos fiscales de la partida (catálogos SAT) -----
|
|
concept_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True
|
|
)
|
|
product_service_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.products_services.id"), nullable=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
|
|
)
|
|
|
|
|
|
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Impuesto trasladado o retenido de una partida de la factura.
|
|
|
|
**Es la fuente del impuesto de la factura**, no solo detalle para el CFDI: cuando
|
|
``invoices.taxes_per_item`` está activo, ``tax_amount`` y ``withheld_amount`` son la suma de
|
|
estas filas y el total sale de ahí. Antes el dinero salía de ``invoices.tax_rate`` aplicado
|
|
al subtotal completo, y los dos planos podían divergir.
|
|
|
|
El índice único es por ``(invoice_item_id, tax_id, is_withholding)`` y **no incluye
|
|
``factor``**: un IVA trasladado sigue siendo uno solo por partida, y pasar de Tasa a Exento
|
|
es un UPDATE de esa fila, no una fila nueva.
|
|
"""
|
|
|
|
__tablename__ = "invoice_item_taxes"
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_fin_invoice_item_taxes",
|
|
"invoice_item_id", "tax_id", "is_withholding",
|
|
unique=True,
|
|
postgresql_where=text("deleted_at IS NULL"),
|
|
sqlite_where=text("deleted_at IS NULL"),
|
|
),
|
|
# Declarado también aquí y no solo en la migración: las pruebas construyen el esquema con
|
|
# ``Base.metadata.create_all`` y sin esto validarían una base distinta de la de producción.
|
|
CheckConstraint(
|
|
"factor IN ('Tasa', 'Cuota', 'Exento')", name="ck_fin_invoice_item_taxes_factor"
|
|
),
|
|
{"schema": "fin"},
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
invoice_item_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True
|
|
)
|
|
tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False)
|
|
# false = trasladado (se cobra al cliente); true = retenido
|
|
is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
|
rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000
|
|
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
|
# c_TipoFactor. Un 'Exento' no lleva tasa ni importe en el XML y no suma a los totales; es
|
|
# distinto de una tasa 0%, que sí se declara con TasaOCuota="0.000000".
|
|
factor: Mapped[str] = mapped_column(String(7), nullable=False, server_default=text("'Tasa'"))
|
|
# true = lo capturó una persona por el endpoint de impuestos de la partida. La derivación
|
|
# automática no pisa lo manual, y esto lo registra como hecho en vez de inferirlo de la forma
|
|
# de la fila (que ya no distingue: un IVA al 0% derivado y uno capturado son idénticos).
|
|
is_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
|
|
|
|
|
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Pago (cobranza) aplicado a una factura."""
|
|
|
|
__tablename__ = "payments"
|
|
__table_args__ = {"schema": "fin"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
invoice_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
|
)
|
|
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
|
|
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
# transferencia | efectivo | cheque | tarjeta | otro
|
|
method: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|