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>
This commit is contained in:
2026-08-11 13:37:26 -05:00
parent dbda0b5755
commit 1f8fdf2866
18 changed files with 1200 additions and 84 deletions

View File

@@ -0,0 +1,163 @@
"""IVA por partida: ``taxes_per_item``, retenciones en el total, ``factor`` y defaults del concepto.
Hasta aquí 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. Con una partida no objeto de impuesto la factura le cobraba IVA igual, y con una
retención capturada el total de la factura y el del comprobante no coincidían.
**No se reescribe ni una fila de las facturas existentes.** El cálculo se versiona con
``taxes_per_item``: las facturas nuevas nacen en ``true`` y usan la suma por partida; todas las
que ya existen quedan en ``false`` y conservan la fórmula con la que se emitieron.
La alternativa —backfillear ``invoice_item_taxes`` desde ``tax_rate``— se descartó por dos
razones. Obligaría a poner ``tax_object_id = '02'`` en partidas que nadie clasificó, que es
inventar una afirmación fiscal. Y ``_recompute`` no corre en la migración sino la próxima vez que
alguien toque la factura: registrar un pago meses después le bajaría el total, dejaría saldo
negativo, la marcaría 'pagada' y pisaría su ``paid_at``, sin que nada explicara por qué. El
rollback aquí es ``UPDATE fin.invoices SET taxes_per_item = false``.
Revision ID: m8n9o0p1q2r3
Revises: l7m8n9o0p1q2
Create Date: 2026-08-11 00:00:00.000000
"""
import logging
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "m8n9o0p1q2r3"
down_revision: Union[str, None] = "l7m8n9o0p1q2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
logger = logging.getLogger("alembic.runtime.migration")
def upgrade() -> None:
# ── fin.invoices ─────────────────────────────────────────────────────────────────────────
# server_default false: TODA factura existente queda con la fórmula vieja. Después se cambia
# el default a true para que las nuevas nazcan con el cálculo por partida.
op.add_column(
"invoices",
sa.Column("taxes_per_item", sa.Boolean(), nullable=False, server_default=sa.text("false")),
schema="fin",
)
op.alter_column("invoices", "taxes_per_item", server_default=sa.text("true"), schema="fin")
# Las retenciones restan del total y hasta ahora no se guardaban en ningún lado: el total no
# cuadraba con subtotal + tax_amount y nada en la fila explicaba el faltante.
op.add_column(
"invoices",
sa.Column("withheld_amount", sa.Numeric(14, 2), nullable=False, server_default=sa.text("0")),
schema="fin",
)
# ── fin.invoice_item_taxes ───────────────────────────────────────────────────────────────
# factor: c_TipoFactor. Sin esta columna un Exento es inexpresable — el builder ya sabe
# omitir TasaOCuota e Importe y excluirlo de los totales, pero nada podía pedírselo.
op.add_column(
"invoice_item_taxes",
sa.Column("factor", sa.String(7), nullable=False, server_default=sa.text("'Tasa'")),
schema="fin",
)
op.create_check_constraint(
"ck_fin_invoice_item_taxes_factor",
"invoice_item_taxes",
"factor IN ('Tasa', 'Cuota', 'Exento')",
schema="fin",
)
# is_manual reemplaza al heurístico que adivinaba la captura manual por la forma de la fila
# (una retención, o un impuesto distinto del IVA). Con IVA al 0% y Exento en el catálogo de
# conceptos ese heurístico deja de discriminar: un traslado de IVA capturado a mano es
# idéntico en forma a uno derivado.
op.add_column(
"invoice_item_taxes",
sa.Column("is_manual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
schema="fin",
)
# ── fin.concepts: configuración fiscal por defecto ───────────────────────────────────────
# La tasa va como FRACCIÓN con 6 decimales (0.160000), igual que invoice_item_taxes.rate y
# que el TasaOCuota del XML — NO como el porcentaje de invoices.tax_rate (16.00). El tipo es
# idéntico al destino a propósito: convertir en el camino es la vía corta a un IVA del 1600%.
op.add_column("concepts", sa.Column("default_tax_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("concepts", sa.Column("default_tax_rate", sa.Numeric(8, 6), nullable=True), schema="fin")
op.add_column("concepts", sa.Column("default_tax_factor", sa.String(7), nullable=True), schema="fin")
op.create_foreign_key(
"fk_fin_concepts_default_tax_id", "concepts", "taxes",
["default_tax_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_check_constraint(
"ck_fin_concepts_default_tax_factor",
"concepts",
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
schema="fin",
)
# Impide el estado medio capturado (impuesto sin factor, tasa sin impuesto) que después
# habría que adivinar en el service. Un Exento no lleva tasa; lo demás sí.
op.create_check_constraint(
"ck_fin_concepts_default_tax_coherente",
"concepts",
"(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))",
schema="fin",
)
_reporta_facturas_afectadas()
def _reporta_facturas_afectadas() -> None:
"""Deja en la bitácora cuántas facturas se quedan con la fórmula vieja y por qué.
Solo lee y cuenta: no cambia nada. Es la constancia de que la migración no movió dinero, y
la lista de trabajo para quien decida pasar borradores al cálculo por partida.
"""
bind = op.get_bind()
if not bind.dialect.has_table(bind, "invoices", schema="fin"):
return
total = bind.execute(
sa.text("SELECT count(*) FROM fin.invoices WHERE deleted_at IS NULL")
).scalar()
# Facturas a las que la fórmula vieja les cobró IVA sobre partidas que no lo causan: es el
# bug que motiva el cambio. Se quedan como están (su total no se toca) y se listan para que
# Cobranza decida qué hacer con las que ya salieron al cliente.
con_iva_indebido = bind.execute(
sa.text(
"""
SELECT count(DISTINCT i.id)
FROM fin.invoices i
JOIN fin.invoice_items ii ON ii.invoice_id = i.id AND ii.deleted_at IS NULL
LEFT JOIN sat.tax_objects tobj ON tobj.id = ii.tax_object_id
WHERE i.deleted_at IS NULL
AND i.tax_rate > 0
AND (tobj.code IS NULL OR tobj.code <> '02')
"""
)
).scalar()
logger.info(
"IVA por partida: %s facturas existentes quedan en taxes_per_item=false y conservan su "
"total. De ellas, %s tienen partidas que no causan IVA y a las que la fórmula anterior "
"se lo cobró; su total NO se modifica.",
total, con_iva_indebido,
)
def downgrade() -> None:
op.drop_constraint("ck_fin_concepts_default_tax_coherente", "concepts", schema="fin", type_="check")
op.drop_constraint("ck_fin_concepts_default_tax_factor", "concepts", schema="fin", type_="check")
op.drop_constraint("fk_fin_concepts_default_tax_id", "concepts", schema="fin", type_="foreignkey")
op.drop_column("concepts", "default_tax_factor", schema="fin")
op.drop_column("concepts", "default_tax_rate", schema="fin")
op.drop_column("concepts", "default_tax_id", schema="fin")
op.drop_column("invoice_item_taxes", "is_manual", schema="fin")
op.drop_constraint("ck_fin_invoice_item_taxes_factor", "invoice_item_taxes", schema="fin", type_="check")
op.drop_column("invoice_item_taxes", "factor", schema="fin")
op.drop_column("invoices", "withheld_amount", schema="fin")
op.drop_column("invoices", "taxes_per_item", schema="fin")

View File

@@ -3,17 +3,62 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from typing import Literal
from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse
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 ConceptBase(BaseModel):
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
@@ -24,7 +69,7 @@ class ConceptCreate(ConceptBase):
pass
class ConceptUpdate(BaseModel):
class ConceptUpdate(_FiscalDefaultsMixin):
"""Actualización parcial: solo se tocan los campos enviados."""
code: str | None = Field(None, min_length=1, max_length=40)
@@ -32,6 +77,15 @@ class ConceptUpdate(BaseModel):
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
@@ -49,6 +103,7 @@ class ConceptResponse(ConceptBase):
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

View File

@@ -11,13 +11,23 @@ descripción corresponde a la clave, así que la unicidad se garantiza por índi
valida además en el service para devolver un 409 con mensaje entendible.
"""
from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text
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 ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
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.
@@ -39,6 +49,18 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
"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"},
)
@@ -54,6 +76,19 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
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"))
@@ -65,3 +100,4 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
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")

View File

@@ -10,7 +10,7 @@ from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure
from ..catalogs.models import ProductService, Tax, TaxObject, UnitOfMeasure
from .dto import ConceptCreate, ConceptUpdate
from .models import Concept
@@ -21,12 +21,25 @@ def _check_sat_refs(db: Session, data: dict) -> None:
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
("default_tax_id", Tax, "El impuesto por defecto no existe en el catálogo del SAT"),
]:
value = data.get(field)
if field in data and value is not None:
if db.query(model.id).filter(model.id == value).first() is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
# El impuesto por defecto del concepto es un TRASLADO —lo que se le cobra al cliente—, así
# que tiene que ser trasladable. Un ISR aquí es un error de captura del catálogo, y atajarlo
# en el concepto evita que se propague a cada partida que lo use.
default_tax_id = data.get("default_tax_id")
if default_tax_id is not None:
tax = db.query(Tax).filter(Tax.id == default_tax_id).first()
if tax is not None and not tax.is_transferred:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
)
def _check_unique(
db: Session,

View File

@@ -2,7 +2,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
from pydantic import BaseModel, ConfigDict, Field, model_validator, computed_field
class InvoiceClientReviewInput(BaseModel):
@@ -140,9 +140,14 @@ class InvoiceResponse(InvoiceBase):
tax_rate: Decimal
subtotal: Decimal
tax_amount: Decimal
# Impuestos retenidos: restan del total, igual que en el comprobante. Sin exponerlos, el
# total no cuadraría con subtotal + tax_amount y nada explicaría la diferencia.
withheld_amount: Decimal
total: Decimal
paid_amount: Decimal
balance: Decimal
# false en las facturas anteriores al cálculo por partida: conservan la fórmula del % global.
taxes_per_item: bool
ops_cost_total: Decimal | None = None
sent_at: datetime | None = None
paid_at: datetime | None = None
@@ -166,8 +171,22 @@ class InvoiceItemTaxInput(BaseModel):
"""
tax_id: int
rate: Decimal = Field(..., ge=0, le=1, max_digits=8, decimal_places=6)
# Nula sólo para un exento, que no lleva TasaOCuota en el comprobante. Una tasa 0 SÍ es un
# valor válido y distinto: se declara con TasaOCuota="0.000000".
rate: Decimal | None = Field(None, ge=0, le=1, max_digits=8, decimal_places=6)
is_withholding: bool = False
# 'Cuota' queda fuera a propósito: su importe es cuota × cantidad, no base × tasa, y
# aceptarla sin esa fórmula daría importes plausibles y equivocados.
factor: Literal["Tasa", "Exento"] = "Tasa"
@model_validator(mode="after")
def _valida_tasa_contra_factor(self):
if self.factor == "Exento":
if self.rate:
raise ValueError("Un impuesto exento no lleva tasa")
elif self.rate is None:
raise ValueError("Un impuesto con factor Tasa requiere la tasa (0 para el 0%)")
return self
class InvoiceItemTaxResponse(BaseModel):
@@ -179,3 +198,5 @@ class InvoiceItemTaxResponse(BaseModel):
is_withholding: bool
rate: Decimal | None = None
amount: Decimal
factor: str
is_manual: bool

View File

@@ -1,6 +1,18 @@
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
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
@@ -45,8 +57,16 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
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"))
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0")) # % IVA
# % 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"))
@@ -119,8 +139,14 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
"""Impuesto trasladado o retenido de una partida de la factura.
Es captura de detalle fiscal para el futuro CFDI: **no** interviene en el cálculo
de subtotal/IVA/total de la factura, que sigue saliendo de ``invoices.tax_rate``.
**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"
@@ -132,6 +158,11 @@ class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
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"},
)
@@ -144,6 +175,13 @@ class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
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):

View File

@@ -63,6 +63,8 @@ def _build_lines(
total,
paid,
balance,
tax_groups: Sequence[dict] | None = None,
withheld=0,
bank_info: str | None,
notes: str | None,
) -> list[tuple[str, int]]:
@@ -91,7 +93,14 @@ def _build_lines(
L.append(("-" * 78, 10))
L.append(("", 11))
L.append((f"Subtotal: {_money(subtotal, currency)}", 11))
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
# Un renglón por grupo (impuesto, factor, tasa), como los agrupa el comprobante. El % de
# la factura dejó de servir aquí: una factura puede mezclar tasas, o traer una partida
# exenta, y entonces no hay un único porcentaje que sea cierto.
for g in tax_groups or []:
L.append((_renglon_impuesto(g, currency), 11))
if not tax_groups and Decimal(str(tax_amount or 0)) != 0:
# Facturas con la fórmula anterior (un % global sobre el subtotal completo).
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
L.append((f"Total: {_money(total, currency)}", 13))
L.append((f"Pagado: {_money(paid, currency)}", 11))
L.append((f"Saldo: {_money(balance, currency)}", 12))
@@ -108,6 +117,24 @@ def _build_lines(
return L
def _renglon_impuesto(grupo: dict, currency: str) -> str:
"""Un renglón del desglose de impuestos.
Los exentos se listan **con su base y sin importe**: es lo único que le explica al cliente por
qué el total no es el subtotal por 1.16, que es justo la pregunta que llega por teléfono. Las
retenciones van con signo negativo, porque restan del total igual que en el comprobante — antes
no aparecían en el PDF y la factura impresa pedía un importe distinto al del CFDI.
"""
nombre = str(grupo.get("nombre") or "Impuesto")
base = _money(grupo.get("base") or 0, currency)
if grupo.get("factor") == "Exento":
return f"{nombre} Exento (sobre {base}): —"
tasa = Decimal(str(grupo.get("rate") or 0)) * 100
importe = Decimal(str(grupo.get("amount") or 0))
etiqueta = f"Ret. {nombre}" if grupo.get("is_withholding") else nombre
signo = "-" if grupo.get("is_withholding") else ""
return f"{etiqueta} {tasa:.2f}% (sobre {base}): {signo}{_money(importe, currency)}"
def build_invoice_pdf(**kwargs) -> bytes:
"""Construye el PDF de la factura y devuelve los bytes."""
lines = _build_lines(**kwargs)

View File

@@ -150,7 +150,7 @@ def set_item_tax(item_id: int, payload: InvoiceItemTaxInput, company_id: int = Q
"""Alta o ajuste. La combinación impuesto + traslado/retención es única por partida."""
return taxes_service.set_item_tax(
db, item_id, payload.tax_id, payload.rate, payload.is_withholding,
current_user["tenant_id"], company_id,
current_user["tenant_id"], company_id, factor=payload.factor,
)

View File

@@ -1,6 +1,6 @@
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from fastapi import HTTPException, status
from sqlalchemy import func
@@ -24,7 +24,7 @@ from .dto import (
PaymentCreate,
)
from . import taxes_service
from .models import Invoice, InvoiceItem, Payment
from .models import Invoice, InvoiceItem, InvoiceItemTax, Payment
from .pdf import build_invoice_pdf
logger = logging.getLogger(__name__)
@@ -63,20 +63,70 @@ def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) ->
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def _totales_por_partida(db: Session, invoice: Invoice) -> tuple[Decimal, Decimal, Decimal]:
"""``(subtotal, trasladado, retenido)`` sumando partida por partida.
Se agrega en Python y no en SQL por dos razones. El redondeo por renglón —que es el que hace
el comprobante— no se expresa igual en Postgres que en SQLite, donde corre la suite; y
``func.sum`` devuelve float bajo SQLite, que es justo lo que no se quiere tocando dinero.
Son unidades de partidas por factura, no miles.
"""
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.all()
)
subtotal = trasladado = retenido = Decimal("0.00")
for item in items:
subtotal += taxes_service.line_base(item)
for t in (
db.query(InvoiceItemTax)
.filter(
InvoiceItemTax.invoice_item_id == item.id,
InvoiceItemTax.deleted_at.is_(None),
)
.all()
):
# Los importes ya están en centavos: volver a redondear la suma no cambia nada y
# esconde de dónde salió la precisión. Un exento tiene importe 0 y no suma.
importe = Decimal(str(t.amount or 0))
if t.is_withholding:
retenido += importe
else:
trasladado += importe
return subtotal, trasladado, retenido
def _recompute(db: Session, invoice: Invoice) -> None:
subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
).scalar()
"""Recalcula los totales de la factura y su estado de cobranza.
El impuesto sale de los impuestos de cada partida (``taxes_per_item``), que es lo que declara
el comprobante. Las facturas creadas antes de ese cambio conservan la fórmula del porcentaje
global: recalcularlas movería el total con el que se emitieron y el que ya vio el cliente.
"""
paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
).scalar()
subtotal = Decimal(subtotal or 0)
rate = Decimal(invoice.tax_rate or 0)
tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
total = subtotal + tax
paid = Decimal(paid or 0)
paid = Decimal(str(paid or 0))
subtotal, trasladado, retenido = _totales_por_partida(db, invoice)
if invoice.taxes_per_item:
tax = trasladado
withheld = retenido
else:
# Fórmula histórica: el % global sobre el subtotal completo, con un solo redondeo. Las
# retenciones no se contemplaban y se dejan fuera para no mover el total de una factura
# vieja por un camino que no existía cuando se emitió.
tax = (subtotal * Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
withheld = Decimal("0.00")
total = subtotal + tax - withheld
invoice.subtotal = subtotal
invoice.tax_amount = tax
invoice.withheld_amount = withheld
invoice.total = total
invoice.paid_amount = paid
invoice.balance = total - paid
@@ -90,6 +140,11 @@ def _recompute(db: Session, invoice: Invoice) -> None:
invoice.paid_at = None
def recompute_invoice(db: Session, invoice: Invoice) -> None:
"""Punto de entrada público de ``_recompute``, para los módulos que mueven impuestos."""
_recompute(db, invoice)
# ----- Invoices -----
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
@@ -158,9 +213,11 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
obj.updated_by = user_id
db.flush()
if "tax_rate" in data:
# El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
# El % es el valor por defecto de las partidas cuyo impuesto se deriva: al cambiarlo se
# propaga a ésas. No toca las que tienen configuración fiscal de su concepto ni las
# capturadas a mano.
taxes_service.sync_invoice_taxes(db, obj)
_recompute(db, obj) # tax_rate pudo cambiar
_recompute(db, obj)
db.commit()
db.refresh(obj)
return obj
@@ -243,6 +300,50 @@ def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)
def _grupos_de_impuesto(db, invoice: Invoice) -> list[dict]:
"""Impuestos de la factura agrupados por ``(impuesto, factor, tasa)``, con su base.
Es el mismo criterio con el que el comprobante arma su nodo ``Impuestos``, y por eso el
desglose del PDF y el del CFDI dicen lo mismo. Vacío para las facturas con la fórmula
anterior: ahí el único desglose que existió fue el porcentaje global.
"""
if not invoice.taxes_per_item:
return []
from ..catalogs.models import Tax # noqa: PLC0415
grupos: dict[tuple, dict] = {}
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.all()
)
for item in items:
base = taxes_service.line_base(item)
for t in (
db.query(InvoiceItemTax)
.filter(InvoiceItemTax.invoice_item_id == item.id, InvoiceItemTax.deleted_at.is_(None))
.all()
):
tax = db.query(Tax).filter(Tax.id == t.tax_id).first()
clave = (t.tax_id, t.factor, str(t.rate or 0), bool(t.is_withholding))
g = grupos.setdefault(
clave,
{
"nombre": (tax.description if tax else "Impuesto"),
"factor": t.factor,
"rate": Decimal(str(t.rate or 0)),
"is_withholding": bool(t.is_withholding),
"base": Decimal("0.00"),
"amount": Decimal("0.00"),
},
)
g["base"] += base
g["amount"] += Decimal(str(t.amount or 0))
# Traslados primero y retenciones después, como se leen en un comprobante.
return sorted(grupos.values(), key=lambda g: (g["is_withholding"], g["nombre"]))
def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
"""Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
items = get_items(db, invoice.id, tenant_id, company_id)
@@ -266,6 +367,8 @@ def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
total=invoice.total,
paid=invoice.paid_amount,
balance=invoice.balance,
tax_groups=_grupos_de_impuesto(db, invoice),
withheld=invoice.withheld_amount,
bank_info=invoice.bank_info,
notes=invoice.notes,
)
@@ -431,6 +534,14 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
# Las partidas se insertan directo, sin pasar por create_item, así que hay que derivar
# sus impuestos a mano o la factura nacería con el desglose vacío.
#
# PENDIENTE: crm.quote_items no tiene concept_id, así que estas partidas nacen sin claves
# fiscales (product_service_id, unit_of_measure_id, tax_object_id) y por tanto sin
# impuestos. Mapear el texto libre del concepto de la cotización contra fin.concepts es su
# propio ticket, con su propia decisión de qué hacer cuando el texto no coincide.
taxes_service.sync_invoice_taxes(db, invoice)
_recompute(db, invoice)
db.commit()
@@ -603,6 +714,9 @@ def delete_item(db, item_id, tenant_id, company_id) -> None:
db, get_invoice(db, invoice_id, tenant_id, company_id), tenant_id, company_id,
motivo="sus partidas no se pueden borrar",
)
# Los impuestos de la partida se van con ella: con el impuesto saliendo de esas filas,
# dejarlas vivas sería seguir cobrando el IVA de una partida que ya no existe.
taxes_service.clear_item_taxes(db, item.id)
item.deleted_at = datetime.now(timezone.utc)
db.flush()
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))

View File

@@ -1,13 +1,24 @@
"""Impuestos de las partidas de la factura.
"""Impuestos de las partidas de la factura — **la fuente del impuesto**, no un detalle.
El CFDI exige el desglose **por partida**, pero la factura ya captura un porcentaje global de
impuesto. Duplicar la captura sería trabajo doble y una fuente de incoherencias, así que el
traslado de IVA se **deriva** de ``invoices.tax_rate`` y se recalcula cuando cambia el importe
o el porcentaje.
El impuesto se declara y se cobra por partida, igual que en el CFDI: ``invoices.tax_amount`` y
``withheld_amount`` son la suma de estas filas, y de ahí sale el total. Antes el dinero salía de
``invoices.tax_rate`` aplicado al subtotal completo, y los dos planos podían divergir — una
partida que no causa IVA cobraba IVA, y una retención capturada dejaba la factura pidiendo un
importe distinto del que declaraba el comprobante.
La derivación no pisa lo capturado a mano: en cuanto alguien ajusta los impuestos de una
partida (una retención, una tasa distinta, un exento), esa partida deja de recalcularse sola.
Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
De dónde sale la tasa de cada partida, en orden:
1. Si la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), no lleva
impuestos. Ni el nodo va en el XML ni el importe suma al total.
2. Si alguien capturó impuestos a mano en esa partida (``is_manual``), no se toca nada.
3. Si su concepto del catálogo trae configuración fiscal, esa manda: es la forma de tener
conceptos exentos o a tasa 0% sin pelear con el % global de la factura.
4. Si no, el traslado de IVA se deriva de ``invoices.tax_rate``.
Los importes se redondean **por renglón** y con ``ROUND_HALF_UP``, no al final y no sobre el
subtotal agregado. Es lo que hace el comprobante: su ``SubTotal`` es la suma de los ``Importe``
ya redondeados de cada concepto, y su ``TotalImpuestosTrasladados`` la suma de los ``Importe`` de
cada traslado. El plano que manda es el XML, y el dinero se le alinea.
"""
from decimal import ROUND_HALF_UP, Decimal
@@ -16,18 +27,43 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..catalogs.models import Tax, TaxObject
from ..concepts.models import Concept
from .models import Invoice, InvoiceItem, InvoiceItemTax
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante.
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante. El 01 «no objeto», el 03
# «objeto no obligado al desglose» y el 04 «objeto que no causa impuesto» NO llevan nodo de
# impuestos en el concepto, así que tampoco generan fila ni suman al total.
_OBJETO_CON_DESGLOSE = {"02"}
# c_Impuesto del IVA.
_IVA = "002"
# c_TipoFactor admitidos al capturar. 'Cuota' se acepta en la base porque el catálogo del SAT lo
# tiene, pero el service lo rechaza: su importe es cuota × cantidad, no base × tasa, y aceptarlo
# sin esa fórmula daría importes plausibles y equivocados.
FACTOR_TASA = "Tasa"
FACTOR_EXENTO = "Exento"
FACTORES_ADMITIDOS = (FACTOR_TASA, FACTOR_EXENTO)
def _cents(value: Decimal) -> Decimal:
def cents(value: Decimal) -> Decimal:
"""Redondea a centavos con la misma regla que el comprobante (``ROUND_HALF_UP``)."""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Alias interno histórico; se conserva para no tocar los llamadores existentes de este módulo.
_cents = cents
def line_base(item: InvoiceItem) -> Decimal:
"""Importe de la partida, ya redondeado.
Es el mismo valor que ``ConceptLine.amount`` del builder, y tiene que salir de una sola
definición: si la factura sumara los productos sin redondear, su subtotal no coincidiría con
la suma de los ``Importe`` del XML y el PAC rechazaría el comprobante.
"""
return cents(Decimal(str(item.quantity or 0)) * Decimal(str(item.unit_amount or 0)))
def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
return (
db.query(InvoiceItemTax)
@@ -58,52 +94,93 @@ def _reject_if_stamped_item(
service._reject_if_stamped(db, invoice, tenant_id, company_id, motivo=motivo)
def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
"""Deja el traslado de IVA de la partida al día con el ``tax_rate`` de la factura.
def clear_item_taxes(db: Session, item_id: int) -> None:
"""Borra los impuestos de una partida.
No hace nada si:
- la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), o
- ya hay impuestos que no son el traslado de IVA derivado — señal de captura manual.
Se llama al borrar la partida. Antes las filas quedaban vivas y era solo ruido; ahora que el
impuesto de la factura sale de ellas, dejarlas sería un cobro fantasma sobre una partida que
ya no existe.
"""
if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
# Si dejó de ser objeto de impuesto, se retira el traslado derivado: un CFDI con
# ObjetoImp 01 y nodo de impuestos es motivo de rechazo.
for t in _item_taxes(db, item.id):
db.delete(t)
return
for t in _item_taxes(db, item_id):
db.delete(t)
iva = db.query(Tax).filter(Tax.code == _IVA).first()
if not iva:
return # sin catálogo no hay nada que derivar; la validación del timbrado lo reportará
def _default_fiscal_del_concepto(db: Session, item: InvoiceItem):
"""``(tax_id, rate, factor)`` del concepto del catálogo, o ``None`` si no lo define."""
if not item.concept_id:
return None
concepto = db.query(Concept).filter(Concept.id == item.concept_id).first()
if not concepto or not concepto.default_tax_id or not concepto.default_tax_factor:
return None
factor = concepto.default_tax_factor
rate = None if factor == FACTOR_EXENTO else Decimal(str(concepto.default_tax_rate or 0))
return concepto.default_tax_id, rate, factor
def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
"""Deja el impuesto derivado de la partida al día. Ver el orden de precedencia arriba."""
if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
# Dejó de ser objeto de impuesto con desglose: se retiran TODOS sus impuestos, incluidos
# los capturados a mano. Una retención sobre una partida que ya no es 02 es inexpresable
# en el XML, y un ObjetoImp 01 con nodo de impuestos es motivo de rechazo.
clear_item_taxes(db, item.id)
return
existentes = _item_taxes(db, item.id)
manuales = [t for t in existentes if t.is_withholding or t.tax_id != iva.id]
if manuales:
return # hay captura manual: no se toca
if any(t.is_manual for t in existentes):
return # captura manual: el automatismo no pisa trabajo ajeno
rate = (Decimal(invoice.tax_rate or 0) / Decimal(100)).quantize(Decimal("0.000001"))
base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
amount = _cents(base * rate)
default = _default_fiscal_del_concepto(db, item)
if default is not None:
tax_id, rate, factor = default
else:
iva = db.query(Tax).filter(Tax.code == _IVA).first()
if not iva:
return # sin catálogo no hay nada que derivar; el timbrado lo reportará
tax_id, factor = iva.id, FACTOR_TASA
rate = (Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(Decimal("0.000001"))
if rate == 0:
# Un cero aquí NO significa "IVA al 0%": significa que nadie capturó el porcentaje, y
# son cosas distintas. No se inventa una tasa: la partida queda sin impuestos y el
# timbrado falla ruidosamente pidiendo el desglose que un ObjetoImp 02 exige. Quien
# de verdad quiere 0% lo declara en el concepto o por el endpoint de captura.
_borra_derivados(db, existentes)
return
traslado = next((t for t in existentes if t.tax_id == iva.id and not t.is_withholding), None)
if rate == 0:
# Tasa 0 no es lo mismo que exento, pero con el % en cero lo que hay es una factura sin
# IVA capturado: se retira el traslado en vez de declarar 0.00 y que el SAT lo cuestione.
if traslado:
db.delete(traslado)
return
# El derivado es uno solo por partida. Si cambió el impuesto (p. ej. el concepto pasó a
# definir otro), el anterior se retira en vez de acumularse.
for t in existentes:
if t.is_withholding or t.tax_id != tax_id:
db.delete(t)
traslado = next(
(t for t in existentes if t.tax_id == tax_id and not t.is_withholding), None
)
if traslado is None:
traslado = InvoiceItemTax(
invoice_item_id=item.id,
tax_id=iva.id,
tax_id=tax_id,
is_withholding=False,
tenant_id=item.tenant_id,
company_id=item.company_id,
)
db.add(traslado)
traslado.rate = rate
traslado.amount = amount
traslado.factor = factor
traslado.is_manual = False
if factor == FACTOR_EXENTO:
# Un exento no lleva TasaOCuota ni Importe en el XML, y no suma a los totales.
traslado.rate = None
traslado.amount = Decimal("0.00")
else:
traslado.rate = rate
traslado.amount = cents(line_base(item) * rate)
def _borra_derivados(db: Session, filas: list[InvoiceItemTax]) -> None:
for t in filas:
if not t.is_manual:
db.delete(t)
def sync_invoice_taxes(db: Session, invoice: Invoice) -> None:
@@ -147,14 +224,42 @@ def set_item_tax(
db: Session,
item_id: int,
tax_id: int,
rate: Decimal,
rate: Decimal | None,
is_withholding: bool,
tenant_id: int,
company_id: int,
factor: str = FACTOR_TASA,
) -> InvoiceItemTax:
"""Alta o ajuste de un impuesto de la partida. El importe se calcula de la base y la tasa."""
"""Alta o ajuste manual de un impuesto de la partida.
Marca la fila como ``is_manual``: desde ese momento la derivación automática no la vuelve a
tocar, ni al cambiar el importe de la partida ni al cambiar el % de la factura.
"""
item = _get_item(db, item_id, tenant_id, company_id)
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
if factor not in FACTORES_ADMITIDOS:
# 'Cuota' existe en el catálogo del SAT pero su importe es cuota × cantidad, no
# base × tasa. Sin esa fórmula, aceptarla produciría un importe plausible y equivocado.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Tipo de factor no admitido: {factor!r}. Por ahora sólo "
f"{' y '.join(FACTORES_ADMITIDOS)}."
),
)
# Un ObjetoImp distinto de 02 no lleva nodo de impuestos: capturar uno aquí sería armar un
# comprobante que el PAC rechaza, y la derivación lo borraría en la siguiente edición.
objeto = _tax_object_code(db, item)
if objeto not in _OBJETO_CON_DESGLOSE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"La partida no es objeto de impuesto con desglose (ObjetoImp "
f"{objeto or 'sin capturar'}): no admite impuestos. Cámbiala a 02 primero."
),
)
tax = db.query(Tax).filter(Tax.id == tax_id).first()
if not tax:
raise HTTPException(
@@ -171,8 +276,12 @@ def set_item_tax(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
)
if factor == FACTOR_TASA and rate is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Un impuesto con factor Tasa requiere la tasa",
)
base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
obj = (
db.query(InvoiceItemTax)
.filter(
@@ -192,13 +301,39 @@ def set_item_tax(
company_id=company_id,
)
db.add(obj)
obj.rate = Decimal(rate).quantize(Decimal("0.000001"))
obj.amount = _cents(base * Decimal(rate))
obj.factor = factor
obj.is_manual = True
if factor == FACTOR_EXENTO:
# Se limpian tasa e importe: una fila exenta que conservara el importe de una tasa
# anterior descuadraría el Total del comprobante, que excluye los exentos de sus totales.
obj.rate = None
obj.amount = Decimal("0.00")
else:
obj.rate = Decimal(str(rate)).quantize(Decimal("0.000001"))
obj.amount = cents(line_base(item) * Decimal(str(rate)))
db.flush()
_recalcula_factura(db, item)
db.commit()
db.refresh(obj)
return obj
def _recalcula_factura(db: Session, item: InvoiceItem) -> None:
"""Recalcula los totales de la factura dueña de la partida.
Import diferido: ``service`` importa este módulo, y al revés sería circular. Capturar o
borrar un impuesto tiene que mover el total, o la factura queda mintiendo hasta que alguien
toque otra cosa.
"""
from . import service # noqa: PLC0415
invoice = db.query(Invoice).filter(Invoice.id == item.invoice_id).first()
if invoice is not None:
service.recompute_invoice(db, invoice)
def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: int) -> None:
obj = (
db.query(InvoiceItemTax)
@@ -215,4 +350,6 @@ def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: in
item = _get_item(db, obj.invoice_item_id, tenant_id, company_id)
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
db.delete(obj)
db.flush()
_recalcula_factura(db, item)
db.commit()

View File

@@ -184,8 +184,17 @@ class CfdiData:
@property
def transferred(self) -> Decimal:
# Los exentos se excluyen, igual que en ``_add_totals``: no llevan importe en el XML y no
# entran en TotalImpuestosTrasladados. Sin este filtro, una fila exenta que trajera un
# importe distinto de cero inflaría el Total y el comprobante quedaría inconsistente
# consigo mismo.
return sum(
(t.amount for c in self.concepts for t in c.taxes if not t.is_withholding),
(
t.amount
for c in self.concepts
for t in c.taxes
if not t.is_withholding and t.factor != "Exento"
),
Decimal("0"),
)

View File

@@ -220,6 +220,9 @@ def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int)
rate=Decimal(str(t.rate or 0)),
amount=Decimal(str(t.amount or 0)),
is_withholding=bool(t.is_withholding),
# Sin pasar el factor, un exento se timbraría como gravado al 0%: un CFDI
# incorrecto que el PAC acepta y que queda así ante el SAT.
factor=t.factor or "Tasa",
)
)
concepts.append(
@@ -267,6 +270,36 @@ def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int)
)
def _verifica_cuadre_con_la_factura(invoice: Invoice, data: builder.CfdiData) -> None:
"""Comprueba que la factura y el comprobante digan el mismo total antes de sellar.
Con el impuesto por partida la igualdad es exacta por construcción: mismo importe de línea,
mismos importes de impuesto y misma composición (subtotal + trasladado retenido). Una
diferencia aquí significa que los totales guardados quedaron desincronizados por un camino que
el service no controla —una fila insertada por fuera, una migración a medias—.
Se **falla y no se corrige**: el timbrado es el punto donde el dinero se vuelve irreversible,
y recalcular en silencio cambiaría montos dentro de la operación de timbrado, que es
exactamente lo que no debe pasar sin que nadie lo vea. Sale como 422 junto al resto de los
faltantes, por el ``except`` que ya envuelve la construcción.
Las facturas anteriores al cálculo por partida no se verifican: su total viene de la fórmula
del porcentaje global y no tiene por qué coincidir con el desglose del comprobante.
"""
if not invoice.taxes_per_item:
return
guardado = Decimal(str(invoice.total or 0)).quantize(Decimal("0.01"))
del_comprobante = data.total.quantize(Decimal("0.01"))
if guardado != del_comprobante:
raise builder.CfdiBuildError(
[
f"los totales de la factura no cuadran con el comprobante: la factura dice "
f"{guardado} y el CFDI {del_comprobante}. Vuelve a guardar una partida para "
f"recalcular antes de timbrar."
]
)
def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
"""Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.
@@ -363,6 +396,7 @@ def stamp_invoice(
# ----- Datos, CSD, XML y sello -----
try:
data = _build_data(db, invoice, tenant_id, company_id)
_verifica_cuadre_con_la_factura(invoice, data)
cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)

View File

@@ -522,3 +522,80 @@ def test_factura_en_dolares_sin_tipo_de_cambio_no_pasa_la_validacion(db):
assert data.exchange_rate is None
assert _FALTA_TIPO_DE_CAMBIO in _faltantes(data)
# ---------- El invariante: la factura y el comprobante dicen el mismo total ----------
#
# Con el impuesto por partida la igualdad es exacta por construcción: mismo importe de línea,
# mismos importes de impuesto y misma composición. Antes divergían — con una retención capturada
# la factura cobraba 1160 y el CFDI declaraba 1060.
def _factura_mixta(db):
"""Un caso de cada tipo: gravada, con retención, no objeto y exenta."""
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.fin.catalogs.models import Tax
from api.v1.modules.fin.invoices import taxes_service
issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester")
account = accounts_service.create_account(db, AccountCreate(name="Cliente Mixto"), T, C)
invoice = invoices_service.create_invoice(
db, InvoiceCreate(reference="F-MIX", account_id=account.id, tax_rate=Decimal("16")), T, C
)
obj02 = db.query(TaxObject).filter(TaxObject.code == "02").one().id
obj01 = db.query(TaxObject).filter(TaxObject.code == "01").one().id
iva = db.query(Tax).filter(Tax.code == "002").one()
isr = db.query(Tax).filter(Tax.code == "001").one()
# 1) gravada al 16%
invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete", unit_amount=Decimal("1000"), tax_object_id=obj02), T, C
)
# 2) gravada con retención capturada a mano
con_ret = invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept="honorarios", unit_amount=Decimal("500"), tax_object_id=obj02), T, C
)
taxes_service.set_item_tax(db, con_ret.id, isr.id, Decimal("0.10"), True, T, C)
# 3) no objeto de impuesto
invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept="reembolso", unit_amount=Decimal("300"), tax_object_id=obj01), T, C
)
# 4) exenta
exenta = invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept="exento", unit_amount=Decimal("200"), tax_object_id=obj02), T, C
)
taxes_service.set_item_tax(db, exenta.id, iva.id, None, False, T, C, factor="Exento")
return invoices_service.get_invoice(db, invoice.id, T, C)
def test_los_totales_de_la_factura_son_los_del_comprobante(db):
from api.v1.modules.fin.stamping import service as stamping_service
invoice = _factura_mixta(db)
data = stamping_service._build_data(db, invoice, T, C)
assert Decimal(str(invoice.subtotal)) == data.subtotal
assert Decimal(str(invoice.tax_amount)) == data.transferred
assert Decimal(str(invoice.withheld_amount)) == data.withheld
assert Decimal(str(invoice.total)) == data.total
# 1000 + 500 + 300 + 200 = 2000; IVA 160 + 80 (la exenta no suma); ISR retenido 50
assert float(invoice.subtotal) == 2000.0
assert float(invoice.tax_amount) == 240.0
assert float(invoice.withheld_amount) == 50.0
assert float(invoice.total) == 2190.0
def test_el_timbrado_rechaza_una_factura_descuadrada(db):
"""Se falla y no se corrige: recalcular en silencio movería montos dentro del timbrado."""
from api.v1.modules.fin.stamping import service as stamping_service
from api.v1.modules.fin.stamping.cfdi_builder import CfdiBuildError
invoice = _factura_mixta(db)
invoice.total = Decimal("9999.99") # desincronización por un camino que el service no controla
db.commit()
data = stamping_service._build_data(db, invoice, T, C)
with pytest.raises(CfdiBuildError) as exc:
stamping_service._verifica_cuadre_con_la_factura(invoice, data)
assert "no cuadran con el comprobante" in str(exc.value)

View File

@@ -18,17 +18,51 @@ from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
T, C = 1, 1
def test_invoice_totals_with_tax(db):
def test_invoice_totals_con_iva_por_partida(db):
"""Sustituye a ``test_invoice_totals_with_tax``, que era incompatible con el cambio.
El test viejo fijaba los mismos 240 de IVA pero sobre partidas **sin** objeto de impuesto:
pasaba porque el % global se aplicaba al subtotal completo sin mirar si las partidas lo
causaban. Ahora el impuesto sale de las partidas, así que se les captura su ObjetoImp 02 y la
exigencia es idéntica, por la razón correcta.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500), T, C)
obj02 = _obj_imp(db, "02")
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000, tax_object_id=obj02), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500, tax_object_id=obj02), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 240.0 # 16% de 1500
assert float(inv.tax_amount) == 240.0 # 16% de cada partida: 160 + 80
assert float(inv.total) == 1740.0
assert float(inv.balance) == 1740.0
def test_partida_que_no_causa_iva_no_se_lo_cobra(db):
"""El bug que motivó el cambio.
Con la fórmula anterior el % global se aplicaba al subtotal completo, así que una partida no
objeto de impuesto —que en el CFDI va sin nodo de impuestos— igual le cobraba IVA al cliente:
esta misma factura daba 1740 en vez de 1660.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOIVA", tax_rate=Decimal("16")), T, C)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
T, C,
)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=500,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 160.0, "se le cobró IVA a una partida que no lo causa"
assert float(inv.total) == 1660.0
def test_payment_marks_paid(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
@@ -413,3 +447,247 @@ def test_no_se_puede_editar_el_desglose_de_impuestos_de_una_factura_timbrada(db)
with pytest.raises(HTTPException) as exc:
taxes_service.set_item_tax(db, item.id, iva.id, Decimal("0.16"), False, T, C)
assert exc.value.status_code == 409
# ── IVA por partida: redondeo, exentos, tasa 0% y el invariante ──────────────
def _iva(db):
from api.v1.modules.fin.catalogs.models import Tax
return db.query(Tax).filter(Tax.code == "002").first()
def _isr(db):
from api.v1.modules.fin.catalogs.models import Tax
return db.query(Tax).filter(Tax.code == "001").first()
def _concepto(db, code="FLETE-EX", ps_code="78101600", **fiscal):
from api.v1.modules.fin.catalogs.models import ProductService, UnitOfMeasure
from api.v1.modules.fin.concepts import service as concepts_service
from api.v1.modules.fin.concepts.dto import ConceptCreate
return concepts_service.create_concept(
db,
ConceptCreate(
code=code,
description="Concepto de prueba",
product_service_id=db.query(ProductService).filter(ProductService.code == ps_code).one().id,
unit_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id,
tax_object_id=_obj_imp(db, "02"),
**fiscal,
),
T, C,
)
def test_el_iva_se_redondea_por_partida_no_sobre_el_subtotal(db):
"""El comprobante suma los Importe de cada traslado, así que la factura tiene que hacer igual.
3 × 10.10 al 16%: por partida son 1.62 × 3 = 4.86; sobre el subtotal agregado darían
cents(30.30 × 0.16) = 4.85. El PAC valida que TotalImpuestosTrasladados sea la suma de los
importes, así que el centavo lo decide el renglón.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-RED", tax_rate=Decimal("16")), T, C)
for i in range(3):
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept=f"p{i}", quantity=1,
unit_amount=Decimal("10.10"), tax_object_id=_obj_imp(db, "02")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 30.30
assert float(inv.tax_amount) == 4.86
def test_el_subtotal_suma_importes_ya_redondeados(db):
"""``Σ round(qty × precio)``, no ``round(Σ)``: es la definición del SubTotal del comprobante."""
inv = service.create_invoice(db, InvoiceCreate(reference="F-SUB", tax_rate=Decimal("0")), T, C)
for i in range(3):
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept=f"p{i}", quantity=Decimal("0.50"),
unit_amount=Decimal("0.05")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
# cada renglón: cents(0.50 × 0.05) = cents(0.025) = 0.03 → 0.09, no 0.08
assert float(inv.subtotal) == 0.09
def test_borrar_una_partida_retira_su_iva(db):
"""Con el impuesto saliendo de las filas, dejarlas vivas sería un cobro fantasma."""
inv = service.create_invoice(db, InvoiceCreate(reference="F-DEL", tax_rate=Decimal("16")), T, C)
obj02 = _obj_imp(db, "02")
a = service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000, tax_object_id=obj02), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="b", quantity=1, unit_amount=500, tax_object_id=obj02), T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 240.0
service.delete_item(db, a.id, T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 500.0
assert float(inv.tax_amount) == 80.0, "quedó vivo el IVA de la partida borrada"
assert float(inv.total) == 580.0
assert _iva_de(db, a.id) == []
def test_la_retencion_resta_del_total(db):
"""El total de la factura tiene que ser el del comprobante: subtotal + trasladado - retenido.
Antes ``total = subtotal + tax`` ignoraba las retenciones: la factura pedía 1160 y el CFDI
declaraba 1060, así que cobranza perseguía un adeudo inexistente para siempre.
"""
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-RET", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="honorarios", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
taxes_service.set_item_tax(db, item.id, _isr(db).id, Decimal("0.10"), True, T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 160.0
assert float(inv.withheld_amount) == 100.0
assert float(inv.total) == 1060.0
assert float(inv.balance) == 1060.0
def test_capturar_un_impuesto_recalcula_la_factura_al_instante(db):
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-CAP", tax_rate=Decimal("0")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
fila = taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.08"), False, T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 80.0
taxes_service.delete_item_tax(db, fila.id, T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 0.0
def test_un_impuesto_capturado_no_se_pisa_al_editar_la_partida(db):
"""``is_manual`` como hecho registrado, no inferido de la forma de la fila.
El guard anterior sólo reconocía retenciones o impuestos distintos del IVA, así que un IVA al
8% capturado a mano volvía al 16% nada más por cambiarle la cantidad a la partida.
"""
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-MAN", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.08"), False, T, C)
service.update_item(db, item.id, InvoiceItemUpdate(quantity=Decimal("2")), T, C)
filas = _iva_de(db, item.id)
assert len(filas) == 1
assert float(filas[0].rate) == 0.08, "la derivación pisó una tasa capturada a mano"
def test_un_concepto_exento_no_genera_impuesto(db):
"""Exento se declara con ObjetoImp 02 + TipoFactor Exento: la fila existe con importe 0."""
concepto = _concepto(
db, default_tax_id=_iva(db).id, default_tax_factor="Exento",
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-EXE", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
filas = _iva_de(db, item.id)
assert len(filas) == 1, "un exento sí lleva fila: el CFDI lo declara"
assert filas[0].factor == "Exento"
assert filas[0].rate is None
assert float(filas[0].amount) == 0.0
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 0.0
assert float(inv.total) == 1000.0
def test_un_concepto_a_tasa_cero_declara_la_tasa(db):
"""Tasa 0% NO es lo mismo que exento: se declara con TasaOCuota 0.000000.
Antes era incapturable: el ``rate == 0`` borraba el traslado y el timbrado fallaba con
"es objeto de impuesto (02) pero no tiene impuestos capturados".
"""
concepto = _concepto(
db, code="FLETE-0", default_tax_id=_iva(db).id, default_tax_factor="Tasa",
default_tax_rate=Decimal("0"),
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-CERO", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
filas = _iva_de(db, item.id)
assert len(filas) == 1
assert filas[0].factor == "Tasa"
assert float(filas[0].rate) == 0.0
assert float(filas[0].amount) == 0.0
assert float(service.get_invoice(db, inv.id, T, C).total) == 1000.0
def test_el_concepto_manda_sobre_el_porcentaje_de_la_factura(db):
concepto = _concepto(
db, code="FLETE-8", default_tax_id=_iva(db).id, default_tax_factor="Tasa",
default_tax_rate=Decimal("0.08"),
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-CONC", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
assert float(_iva_de(db, item.id)[0].rate) == 0.08
# Mover el % de la factura no toca una partida cuyo concepto define su impuesto.
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("16")), T, C)
assert float(_iva_de(db, item.id)[0].rate) == 0.08
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 80.0
def test_no_se_capturan_impuestos_en_una_partida_que_no_es_objeto(db):
import pytest
from fastapi import HTTPException
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOOBJ2", tax_rate=Decimal("0")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
with pytest.raises(HTTPException) as exc:
taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.16"), False, T, C)
assert exc.value.status_code == 422
def test_una_factura_previa_conserva_su_formula(db):
"""``taxes_per_item=false``: las facturas de antes del cambio no se recalculan solas.
Es lo que evita que registrarle un pago meses después le baje el total, la deje con saldo
negativo, la marque 'pagada' y le pise el paid_at.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-VIEJA", tax_rate=Decimal("16")), T, C)
inv.taxes_per_item = False
db.commit()
# Partidas SIN objeto de impuesto, como las que existían antes del cambio.
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 160.0, "una factura vieja debe conservar el % global"
assert float(inv.total) == 1160.0

View File

@@ -14,6 +14,12 @@ export interface Concept {
product_service_id: number;
unit_of_measure_id: number | null;
tax_object_id: number | null;
// Configuración fiscal por defecto: es lo que permite tener conceptos que no causan IVA.
// La tasa va como FRACCIÓN (0.16 es el 16%), no como el porcentaje de la factura.
// 'Exento' y una tasa 0 son distintos: el exento no se declara con TasaOCuota.
default_tax_id: number | null;
default_tax_rate: number | null;
default_tax_factor: 'Tasa' | 'Exento' | null;
unit_price: number | null;
currency: string;
is_active: boolean;
@@ -21,6 +27,7 @@ export interface Concept {
product_service: SatCatalogItem | null;
unit_of_measure: SatUnitOfMeasure | null;
tax_object: SatCatalogItem | null;
default_tax: SatCatalogItem | null;
tenant_id: number;
company_id: number;
created_by: string | null;
@@ -35,6 +42,12 @@ export interface ConceptInput {
product_service_id: number;
unit_of_measure_id?: number | null;
tax_object_id?: number | null;
// Configuración fiscal por defecto: es lo que permite tener conceptos que no causan IVA.
// La tasa va como FRACCIÓN (0.16 es el 16%), no como el porcentaje de la factura.
// 'Exento' y una tasa 0 son distintos: el exento no se declara con TasaOCuota.
default_tax_id?: number | null;
default_tax_rate?: number | null;
default_tax_factor?: 'Tasa' | 'Exento' | null;
unit_price?: number | null;
currency?: string;
is_active?: boolean;

View File

@@ -24,8 +24,13 @@ export interface Invoice {
issue_date: string | null;
due_date: string | null;
subtotal: number;
/** % de IVA por DEFECTO de las partidas nuevas objeto de impuesto; no determina el total. */
tax_rate: number;
tax_amount: number;
/** Impuestos retenidos: restan del total, igual que en el comprobante. */
withheld_amount: number;
/** false en las facturas anteriores al cálculo por partida: conservan el % global. */
taxes_per_item: boolean;
total: number;
paid_amount: number;
balance: number;

View File

@@ -24,6 +24,42 @@
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
let taxObjects = $state<SatCatalogItem[]>([]);
let taxes = $state<SatCatalogItem[]>([]);
/** Objeto de impuesto elegido; solo el 02 lleva desglose y por tanto impuesto. */
const objetoElegido = $derived(taxObjects.find((o) => o.id === form.tax_object_id) ?? null);
const causaImpuesto = $derived(objetoElegido?.code === '02');
/** La tasa se captura en % porque es como la piensa quien configura; se guarda como fracción. */
let tasaPct = $state<number | null>(null);
let tasaInicializada = false;
$effect(() => {
if (tasaInicializada) return;
tasaInicializada = true;
tasaPct = form.default_tax_rate == null ? null : Number(form.default_tax_rate) * 100;
});
function aplicaTasa() {
form.default_tax_rate = tasaPct == null ? null : Number((tasaPct / 100).toFixed(6));
}
/** Un exento no lleva tasa; limpiar el impuesto limpia toda la terna. */
function aplicaFactor() {
if (form.default_tax_factor === 'Exento') {
tasaPct = null;
form.default_tax_rate = null;
}
}
function aplicaImpuesto() {
if (!form.default_tax_id) {
form.default_tax_factor = null;
form.default_tax_rate = null;
tasaPct = null;
} else if (!form.default_tax_factor) {
form.default_tax_factor = 'Tasa';
}
}
let productServiceQuery = $state('');
let productServiceOptions = $state<SatCatalogItem[]>([]);
@@ -37,9 +73,10 @@
async function loadCatalogs(cid: number) {
try {
[unitsOfMeasure, taxObjects] = await Promise.all([
[unitsOfMeasure, taxObjects, taxes] = await Promise.all([
satCatalogsAPI.unitsOfMeasure(cid),
satCatalogsAPI.taxObjects(cid)
satCatalogsAPI.taxObjects(cid),
satCatalogsAPI.taxes(cid)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
@@ -169,6 +206,58 @@
</select>
</label>
{#if causaImpuesto}
<!-- Configuración fiscal del concepto. Es lo que permite tener conceptos que no causan
IVA sin pelear con el % global de la factura: la partida hereda esto y el % deja de
aplicarle. Solo tiene sentido con objeto de impuesto 02, que es el único que lleva
desglose en el comprobante. -->
<div class="rounded-md border p-3 sm:col-span-2">
<p class="text-sm font-medium">Impuesto por defecto</p>
<p class="mb-3 text-xs text-muted-foreground">
Las partidas que usen este concepto lo heredan, y el % de IVA de la factura deja de
aplicarles. Déjalo vacío para que usen ese %.
</p>
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Impuesto</span>
<select class={inputCls} bind:value={form.default_tax_id} onchange={aplicaImpuesto}>
<option value={null}>Usar el % de la factura</option>
{#each taxes as tax (tax.id)}
<option value={tax.id}>{tax.code} {tax.description}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tipo de factor</span>
<select
class={inputCls}
bind:value={form.default_tax_factor}
onchange={aplicaFactor}
disabled={!form.default_tax_id}
>
<option value="Tasa">Tasa</option>
<option value="Exento">Exento</option>
</select>
<span class="text-xs text-muted-foreground">Exento no declara tasa; 0% sí.</span>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tasa (%)</span>
<input
type="number"
step="0.0001"
min="0"
max="100"
class={inputCls}
bind:value={tasaPct}
oninput={aplicaTasa}
disabled={!form.default_tax_id || form.default_tax_factor === 'Exento'}
/>
<span class="text-xs text-muted-foreground">16 para el 16%; 0 para tasa cero.</span>
</label>
</div>
</div>
{/if}
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Moneda</span>
<input class={inputCls} bind:value={form.currency} maxlength="3" />

View File

@@ -391,9 +391,15 @@
<p class="text-xs text-muted-foreground">Revisión del cliente: {invoice.client_approved ? 'aprobada' : 'con observaciones'}{#if invoice.review_notes} — {invoice.review_notes}{/if}</p>
{/if}
<div class="grid gap-4 sm:grid-cols-4">
<!-- El impuesto sale de cada partida, así que ya no hay un único porcentaje que sea cierto:
una factura puede mezclar tasas o traer partidas exentas. Se muestra el importe, y las
retenciones en su propia tarjeta cuando hay, porque restan del total. -->
<div class="grid gap-4 sm:grid-cols-4 lg:grid-cols-{invoice.withheld_amount > 0 ? 5 : 4}">
<Card.Root><Card.Header><Card.Description>Subtotal</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.subtotal, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Impuesto ({invoice.tax_rate}%)</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.tax_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>{invoice.taxes_per_item ? 'Impuestos trasladados' : `Impuesto (${invoice.tax_rate}%)`}</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.tax_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>
{#if invoice.withheld_amount > 0}
<Card.Root><Card.Header><Card.Description>Retenciones</Card.Description><Card.Title class="text-lg text-amber-600">-{formatMoney(invoice.withheld_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>
{/if}
<Card.Root><Card.Header><Card.Description>Total</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.total, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Saldo</Card.Description><Card.Title class="text-lg {invoice.balance > 0 ? 'text-amber-600' : 'text-emerald-600'}">{formatMoney(invoice.balance, invoice.currency)}</Card.Title></Card.Header></Card.Root>
</div>
@@ -489,8 +495,9 @@
{#each taxObjects as o (o.id)}<option value={o.id}>{o.code} {o.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">
Con «02 — Sí objeto de impuesto» el IVA se calcula solo, usando el {invoice.tax_rate}%
de la factura.
Solo «02 — Sí objeto de impuesto» lleva impuestos; con las demás claves la partida
no causa IVA y no se le cobra. Si el concepto del catálogo define su impuesto, ése
manda; si no, se usa el {invoice.tax_rate}% de la factura.
</span>
</label>
<div class="flex justify-end gap-2 sm:col-span-2">
@@ -572,7 +579,7 @@
<input class={inputCls} maxlength="3" bind:value={form.currency} disabled={!!stamp} />
<span class="text-xs text-muted-foreground">Se hereda de la ficha del cliente.</span>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} disabled={!!stamp} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% IVA por defecto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} disabled={!!stamp} /><span class="text-xs text-muted-foreground">Se aplica a las partidas objeto de impuesto que no traen impuesto propio del concepto ni capturado a mano.</span></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} disabled={!!stamp} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<!-- Claves fiscales del comprobante. Sin ellas el timbrado se detiene en la