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:
163
backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py
Normal file
163
backend/alembic/versions/m8n9o0p1q2r3_fin_item_level_taxes.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user