diff --git a/backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py b/backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py new file mode 100644 index 0000000..a9014e1 --- /dev/null +++ b/backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py @@ -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") diff --git a/backend/api/v1/modules/fin/concepts/dto.py b/backend/api/v1/modules/fin/concepts/dto.py index 49f9a62..614d7e2 100644 --- a/backend/api/v1/modules/fin/concepts/dto.py +++ b/backend/api/v1/modules/fin/concepts/dto.py @@ -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 diff --git a/backend/api/v1/modules/fin/concepts/models.py b/backend/api/v1/modules/fin/concepts/models.py index 96838b5..3a1eec8 100644 --- a/backend/api/v1/modules/fin/concepts/models.py +++ b/backend/api/v1/modules/fin/concepts/models.py @@ -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") diff --git a/backend/api/v1/modules/fin/concepts/service.py b/backend/api/v1/modules/fin/concepts/service.py index c3aeb09..a4e12c5 100644 --- a/backend/api/v1/modules/fin/concepts/service.py +++ b/backend/api/v1/modules/fin/concepts/service.py @@ -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, diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 743fb36..40027d7 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -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 diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index 2247665..ba02bde 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -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): diff --git a/backend/api/v1/modules/fin/invoices/pdf.py b/backend/api/v1/modules/fin/invoices/pdf.py index e4024d4..eba0475 100644 --- a/backend/api/v1/modules/fin/invoices/pdf.py +++ b/backend/api/v1/modules/fin/invoices/pdf.py @@ -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) diff --git a/backend/api/v1/modules/fin/invoices/routes.py b/backend/api/v1/modules/fin/invoices/routes.py index 5751194..e1f4740 100644 --- a/backend/api/v1/modules/fin/invoices/routes.py +++ b/backend/api/v1/modules/fin/invoices/routes.py @@ -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, ) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 6c75e01..a3798ac 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -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)) diff --git a/backend/api/v1/modules/fin/invoices/taxes_service.py b/backend/api/v1/modules/fin/invoices/taxes_service.py index c1c253a..6d9cdfd 100644 --- a/backend/api/v1/modules/fin/invoices/taxes_service.py +++ b/backend/api/v1/modules/fin/invoices/taxes_service.py @@ -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() diff --git a/backend/api/v1/modules/fin/stamping/cfdi_builder.py b/backend/api/v1/modules/fin/stamping/cfdi_builder.py index 0523abe..7105072 100644 --- a/backend/api/v1/modules/fin/stamping/cfdi_builder.py +++ b/backend/api/v1/modules/fin/stamping/cfdi_builder.py @@ -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"), ) diff --git a/backend/api/v1/modules/fin/stamping/service.py b/backend/api/v1/modules/fin/stamping/service.py index 526a751..ef85a60 100644 --- a/backend/api/v1/modules/fin/stamping/service.py +++ b/backend/api/v1/modules/fin/stamping/service.py @@ -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) diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index a8a5cdb..d7b57c9 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -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) diff --git a/backend/tests/test_invoices.py b/backend/tests/test_invoices.py index 7b7f3cd..531f871 100644 --- a/backend/tests/test_invoices.py +++ b/backend/tests/test_invoices.py @@ -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 diff --git a/frontend/src/lib/api/fin/concepts.ts b/frontend/src/lib/api/fin/concepts.ts index 975aba2..f389ba8 100644 --- a/frontend/src/lib/api/fin/concepts.ts +++ b/frontend/src/lib/api/fin/concepts.ts @@ -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; diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index 20661fe..a058462 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -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; diff --git a/frontend/src/lib/components/fin/ConceptFields.svelte b/frontend/src/lib/components/fin/ConceptFields.svelte index b6e7523..9fc8ef5 100644 --- a/frontend/src/lib/components/fin/ConceptFields.svelte +++ b/frontend/src/lib/components/fin/ConceptFields.svelte @@ -24,6 +24,42 @@ let unitsOfMeasure = $state([]); let taxObjects = $state([]); + let taxes = $state([]); + + /** 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(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([]); @@ -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 @@ + {#if causaImpuesto} + +
+

Impuesto por defecto

+

+ 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 %. +

+
+ + + +
+
+ {/if} +