Compare commits
7 Commits
feature/AS
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5708e12d7b | |||
| abdf1ce790 | |||
| f9258bad05 | |||
| db4f3b1d53 | |||
| 5a62c31d93 | |||
| 1f8fdf2866 | |||
| dbda0b5755 |
@@ -0,0 +1,40 @@
|
||||
"""Tipo de cambio de la factura (``fin.invoices.exchange_rate``).
|
||||
|
||||
La factura hereda la moneda de la ficha del cliente, y una factura en moneda distinta de MXN
|
||||
**no se puede timbrar** sin tipo de cambio: ``CfdiData.validate`` lo exige y ``_build_data``
|
||||
pasaba ``exchange_rate=None`` siempre, así que el campo no existía en ninguna parte. Un cliente
|
||||
con ``currency='USD'`` producía facturas que fallaban al timbrar sin pista del porqué.
|
||||
|
||||
Nullable a propósito: en MXN no aplica y el CFDI no lleva ``TipoCambio``. La validación de
|
||||
"falta el tipo de cambio" la sigue haciendo el builder, que acumula todos los faltantes y los
|
||||
reporta juntos.
|
||||
|
||||
Escala 6: el SAT admite hasta seis decimales en ``TipoCambio``. El builder redondea a cuatro al
|
||||
escribir el XML, que es una decisión previa suya y no se toca aquí.
|
||||
|
||||
Revision ID: l7m8n9o0p1q2
|
||||
Revises: k6l7m8n9o0p1
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "l7m8n9o0p1q2"
|
||||
down_revision: Union[str, None] = "k6l7m8n9o0p1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column("exchange_rate", sa.Numeric(precision=14, scale=6), nullable=True),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invoices", "exchange_rate", schema="fin")
|
||||
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")
|
||||
@@ -23,6 +23,36 @@ from .models import (
|
||||
_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)}
|
||||
|
||||
|
||||
def find_by_code(db: Session, model, code: str | None, active_only: bool = True):
|
||||
"""Resuelve una clave del SAT a su fila del catálogo. ``None`` si no hay coincidencia.
|
||||
|
||||
Existe para traducir a id los datos fiscales que el CRM guarda como TEXTO. En
|
||||
``crm.accounts`` la forma y el método de pago son ``String(60)`` sin FK ni validación: lo
|
||||
normal es que traigan la clave del SAT ('03', 'PUE'), porque la ficha se llena con el
|
||||
``code`` de los catálogos del CRM, pero nada garantiza que no haya texto histórico como
|
||||
'Transferencia'.
|
||||
|
||||
Devuelve ``None`` en vez de lanzar, y es la decisión importante: una ficha de cliente mal
|
||||
capturada **no puede impedir crear una factura**. El faltante lo reporta la validación del
|
||||
timbrado, que acumula todos los pendientes y los entrega juntos — el mismo criterio que
|
||||
``stamping.service._code``.
|
||||
|
||||
``active_only`` por defecto: una clave que el SAT retiró no debe entrar en un comprobante
|
||||
nuevo.
|
||||
"""
|
||||
limpio = (code or "").strip().upper()
|
||||
if not limpio:
|
||||
return None
|
||||
# c_FormaPago son dos dígitos: una ficha con '3' en vez de '03' es la misma forma de pago.
|
||||
if model is PaymentForm and limpio.isdigit():
|
||||
limpio = limpio.zfill(2)
|
||||
|
||||
q = db.query(model).filter(model.code == limpio)
|
||||
if active_only:
|
||||
q = q.filter(model.is_active.is_(True))
|
||||
return q.first()
|
||||
|
||||
|
||||
def search_catalog(
|
||||
db: Session,
|
||||
model,
|
||||
|
||||
@@ -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):
|
||||
@@ -31,6 +31,9 @@ class InvoiceItemCreate(InvoiceItemBase):
|
||||
invoice_id: int
|
||||
# Opcional solo si viene concept_id: el service copia la descripción del concepto.
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
# Opcional para poder heredar el precio del concepto: con el default 0 de InvoiceItemBase
|
||||
# siempre llegaría un valor y el service no podría distinguir "no lo capturó" de "capturó 0".
|
||||
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
|
||||
|
||||
class InvoiceItemUpdate(InvoiceItemSatFields):
|
||||
@@ -81,10 +84,16 @@ class InvoiceBase(BaseModel):
|
||||
shipment_id: int | None = None
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str = Field("MXN", max_length=3)
|
||||
# Opcionales a propósito: con un default no nulo, ``model_dump()`` los incluiría siempre y
|
||||
# el service no podría distinguir "no lo eligió" de "eligió eso" — con lo que la herencia de
|
||||
# los datos del cliente nunca se activaría. Si ni el alta ni la ficha los traen, manda el
|
||||
# server_default de la columna.
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
# Tipo de cambio a MXN, obligatorio para timbrar si la moneda no es MXN.
|
||||
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
|
||||
issue_date: date | None = None
|
||||
due_date: date | None = None
|
||||
tax_rate: Decimal = Field(Decimal(0), ge=0, le=100, max_digits=5, decimal_places=2)
|
||||
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
@@ -108,6 +117,7 @@ class InvoiceUpdate(BaseModel):
|
||||
quote_id: int | None = None
|
||||
account_id: int | None = None
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
|
||||
issue_date: date | None = None
|
||||
due_date: date | None = None
|
||||
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
|
||||
@@ -127,11 +137,20 @@ class InvoiceResponse(InvoiceBase):
|
||||
id: int
|
||||
case_id: int | None = None
|
||||
status: str
|
||||
# Se redeclaran porque en InvoiceBase son opcionales para habilitar la herencia; en la
|
||||
# respuesta corresponden a columnas NOT NULL y aflojarlas relajaría el contrato de salida.
|
||||
currency: str
|
||||
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
|
||||
@@ -155,8 +174,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):
|
||||
@@ -168,3 +201,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
|
||||
@@ -37,13 +49,24 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
# Tipo de cambio a MXN. Obligatorio para timbrar cuando la moneda no es MXN (lo exige
|
||||
# c_Moneda del SAT vía CfdiData.validate); en MXN se queda en NULL y el CFDI no lo lleva.
|
||||
exchange_rate: Mapped[float | None] = mapped_column(Numeric(14, 6), nullable=True)
|
||||
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
|
||||
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
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"))
|
||||
@@ -116,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"
|
||||
@@ -129,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"},
|
||||
)
|
||||
|
||||
@@ -141,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]]:
|
||||
@@ -84,14 +86,20 @@ def _build_lines(
|
||||
qty = Decimal(str(it.get("quantity") or 0))
|
||||
unit = Decimal(str(it.get("unit_amount") or 0))
|
||||
amount = (qty * unit).quantize(Decimal("0.01"))
|
||||
label = concept if not desc else f"{concept} — {desc}"
|
||||
label = label[:42].ljust(42)
|
||||
label = etiqueta_partida(concept, desc)[:42].ljust(42)
|
||||
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
|
||||
L.append((row, 10))
|
||||
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 +116,42 @@ def _build_lines(
|
||||
return L
|
||||
|
||||
|
||||
def etiqueta_partida(concept: str, description: str) -> str:
|
||||
"""Cómo se lee la partida en el renglón del PDF.
|
||||
|
||||
Los dos campos vienen del mismo texto cuando la partida usa un concepto del catálogo:
|
||||
``concept`` es la descripción recortada a 60 caracteres y ``description`` la completa.
|
||||
Imprimir ambos repetiría el texto —una vez cortado y otra entero—, así que se detecta por
|
||||
prefijo y se imprime sólo el largo.
|
||||
|
||||
Cuando son textos distintos —una clave genérica más el detalle que alguien escribió— se
|
||||
imprimen los dos, que es lo que hacía siempre.
|
||||
"""
|
||||
if not description:
|
||||
return concept
|
||||
if not concept or description.startswith(concept):
|
||||
return description
|
||||
return f"{concept} — {description}"
|
||||
|
||||
|
||||
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,5 +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
|
||||
@@ -11,6 +12,8 @@ from api.v1.modules.crm.common.folios import next_folio
|
||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||
from api.v1.modules.ops.shipments.models import Shipment
|
||||
|
||||
from ..catalogs import service as catalogs_service
|
||||
from ..catalogs.models import PaymentForm, PaymentMethod
|
||||
from ..concepts.models import Concept
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
@@ -21,9 +24,11 @@ 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__)
|
||||
|
||||
|
||||
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
|
||||
if _id is None:
|
||||
@@ -36,6 +41,18 @@ def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# Campos que el DTO acepta como nulos —para poder heredarlos del cliente— pero cuya columna es
|
||||
# NOT NULL con server_default. Un None explícito tiene que retirarse del payload para que mande
|
||||
# el default de la base, en vez de reventar en el flush.
|
||||
_COLUMNAS_CON_DEFAULT = ("currency", "tax_rate")
|
||||
|
||||
|
||||
def _drop_nulls_de_columnas_obligatorias(data: dict) -> None:
|
||||
for campo in _COLUMNAS_CON_DEFAULT:
|
||||
if campo in data and data[campo] is None:
|
||||
data.pop(campo)
|
||||
|
||||
|
||||
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
for field, model, msg in [
|
||||
("account_id", Account, "El cliente asociado no existe"),
|
||||
@@ -46,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
|
||||
@@ -73,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]:
|
||||
@@ -98,6 +170,8 @@ def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
|
||||
def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
_inherit_account_billing(db, data, tenant_id, company_id)
|
||||
_drop_nulls_de_columnas_obligatorias(data)
|
||||
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
# Folio F... auto-generado (mensual) si no viene uno explícito
|
||||
if not obj.reference:
|
||||
@@ -120,41 +194,88 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
_reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
|
||||
_reject_if_stamped(db, obj, tenant_id, company_id, data=data)
|
||||
# Cambiar de cliente vuelve a heredar sus datos de facturación: facturar al cliente B con la
|
||||
# forma de pago del cliente A es un error silencioso. Se re-hereda ANTES del setattr y sólo
|
||||
# sobre lo que el PATCH no manda explícito, igual que update_item con el concepto.
|
||||
if "account_id" in data:
|
||||
for campo, _, _ in _ACCOUNT_INHERITED_BILLING:
|
||||
data.setdefault(campo, None)
|
||||
data.setdefault("currency", None)
|
||||
_inherit_account_billing(db, data, tenant_id, company_id)
|
||||
# Lo que no se pudo heredar se retira del PATCH para no pisar con NULL lo ya capturado.
|
||||
for campo in ("currency", *(c for c, _, _ in _ACCOUNT_INHERITED_BILLING)):
|
||||
if data.get(campo) is None:
|
||||
data.pop(campo, None)
|
||||
_drop_nulls_de_columnas_obligatorias(data)
|
||||
for f, v in data.items():
|
||||
setattr(obj, f, v)
|
||||
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
|
||||
|
||||
|
||||
def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
|
||||
"""El modo de timbrado es inmutable una vez que la factura tiene timbre.
|
||||
# Campos del comprobante que dejan de ser editables en cuanto la factura tiene timbre. Son los
|
||||
# que el CFDI ya declaró ante el SAT: cambiarlos aquí haría que la factura y su comprobante
|
||||
# contaran cosas distintas, y el comprobante es el que vale.
|
||||
_INMUTABLES_CON_TIMBRE = (
|
||||
"account_id",
|
||||
"reference",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"issue_date",
|
||||
"payment_form_id",
|
||||
"payment_method_id",
|
||||
"expedition_zip_code",
|
||||
"voucher_type_id",
|
||||
"stamping_mode",
|
||||
"tax_rate",
|
||||
)
|
||||
|
||||
Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
|
||||
CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
|
||||
hecho no se edita.
|
||||
|
||||
def _reject_if_stamped(
|
||||
db, obj: Invoice, tenant_id, company_id, data: dict | None = None, motivo: str | None = None
|
||||
) -> None:
|
||||
"""Rechaza con 409 la edición de una factura ya timbrada.
|
||||
|
||||
Con ``data`` sólo protege los campos de ``_INMUTABLES_CON_TIMBRE`` y únicamente cuando el
|
||||
valor que llega es distinto del actual: guardar el encabezado sin tocarlos sigue permitido.
|
||||
Sin ``data`` no admite nada, y así se usa desde las partidas y sus impuestos — el desglose
|
||||
del comprobante no se corrige editándolo, se corrige cancelando y refacturando.
|
||||
|
||||
Cobrar NO pasa por aquí: registrar o borrar un pago no altera el CFDI.
|
||||
"""
|
||||
nuevo = data.get("stamping_mode")
|
||||
if nuevo is None or nuevo == obj.stamping_mode:
|
||||
return
|
||||
# Import diferido: stamping importa invoices, y al revés sería circular.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
if get_stamp(db, obj.id, tenant_id, company_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"La factura ya está timbrada: el modo de timbrado no se puede cambiar "
|
||||
f"(sigue en {obj.stamping_mode!r})."
|
||||
),
|
||||
if data is not None:
|
||||
cambiados = [
|
||||
campo
|
||||
for campo in _INMUTABLES_CON_TIMBRE
|
||||
if campo in data and data[campo] != getattr(obj, campo)
|
||||
]
|
||||
if not cambiados:
|
||||
return
|
||||
detalle = (
|
||||
f"La factura ya está timbrada: no se puede cambiar {', '.join(cambiados)}. "
|
||||
"El CFDI ya existe ante el SAT; para corregirlo hay que cancelarlo y refacturar."
|
||||
)
|
||||
else:
|
||||
detalle = (
|
||||
f"La factura ya está timbrada: {motivo or 'no admite cambios'}. El CFDI ya existe "
|
||||
"ante el SAT; para corregirlo hay que cancelarlo y refacturar."
|
||||
)
|
||||
|
||||
if get_stamp(db, obj.id, tenant_id, company_id):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detalle)
|
||||
|
||||
|
||||
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
|
||||
@@ -179,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)
|
||||
@@ -202,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,
|
||||
)
|
||||
@@ -220,10 +387,20 @@ def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice
|
||||
if not obj.issue_date:
|
||||
obj.issue_date = date.today()
|
||||
db.flush()
|
||||
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
|
||||
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
|
||||
obj.pdf_file_key = key
|
||||
# Con timbre no se regenera el PDF: el documento que acompaña a un CFDI es el que se emitió
|
||||
# con él. Regenerarlo sobre la misma llave de MinIO reescribiría lo que el cliente ya recibió,
|
||||
# y con cualquier cambio posterior en la factura diría algo distinto del comprobante.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
ya_timbrada = get_stamp(db, obj.id, tenant_id, company_id) is not None
|
||||
if not (ya_timbrada and obj.pdf_file_key):
|
||||
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
|
||||
key = (
|
||||
f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/"
|
||||
f"factura-{obj.reference or obj.id}.pdf"
|
||||
)
|
||||
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
|
||||
obj.pdf_file_key = key
|
||||
obj.status = "enviada"
|
||||
obj.sent_at = datetime.now(timezone.utc)
|
||||
if not obj.issue_date:
|
||||
@@ -319,13 +496,24 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
|
||||
if shipment.quote_id:
|
||||
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
|
||||
|
||||
# Datos de facturación del cliente. La moneda del EMBARQUE gana sobre la de la ficha: es la
|
||||
# que se coteó y en la que se operó de verdad, mientras la del cliente es una preferencia
|
||||
# comercial. El cliente entra sólo como último recurso, antes del default MXN.
|
||||
datos = {
|
||||
"account_id": shipment.account_id,
|
||||
"currency": (shipment.cost_currency or (quote.currency if quote else None)),
|
||||
}
|
||||
_inherit_account_billing(db, datos, tenant_id, company_id)
|
||||
|
||||
invoice = Invoice(
|
||||
reference=shipment.reference,
|
||||
case_id=shipment.case_id,
|
||||
shipment_id=shipment.id,
|
||||
quote_id=shipment.quote_id,
|
||||
account_id=shipment.account_id,
|
||||
currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
|
||||
currency=(datos.get("currency") or "MXN"),
|
||||
payment_form_id=datos.get("payment_form_id"),
|
||||
payment_method_id=datos.get("payment_method_id"),
|
||||
ops_cost_total=shipment.actual_cost_total,
|
||||
status="borrador",
|
||||
tenant_id=tenant_id,
|
||||
@@ -346,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()
|
||||
@@ -376,18 +572,93 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
||||
# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
|
||||
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")
|
||||
|
||||
# Campos que la partida hereda del concepto con OTRO nombre: (campo de la partida, del concepto).
|
||||
_CONCEPT_RENAMED_FIELDS = (("unit_amount", "unit_price"),)
|
||||
|
||||
# Datos de facturación que la factura hereda de la ficha del cliente: (campo de la factura,
|
||||
# campo del Account, catálogo del SAT contra el que se resuelve la clave).
|
||||
_ACCOUNT_INHERITED_BILLING = (
|
||||
("payment_form_id", "payment_form", PaymentForm),
|
||||
("payment_method_id", "payment_method", PaymentMethod),
|
||||
)
|
||||
|
||||
|
||||
def _inherit_account_billing(db, data: dict, tenant_id, company_id) -> None:
|
||||
"""Completa los datos de facturación de la factura desde la ficha del cliente.
|
||||
|
||||
Hereda tres cosas y sólo tres: forma de pago, método de pago y moneda. Las dos primeras son
|
||||
justo las que detienen el timbrado en validación si quedan vacías, y estaban capturándose a
|
||||
mano en cada factura aunque ya vivieran en la ficha.
|
||||
|
||||
Dos reglas, las mismas que ``_resolve_item_concept``:
|
||||
|
||||
- **Lo que el cliente sí envía manda sobre la ficha**: sólo se escribe donde no hay valor.
|
||||
- **Completa, nunca borra**: si la ficha trae un texto que no resuelve a ninguna clave del
|
||||
SAT, no se asigna nada. Así cambiar de cliente no puede vaciar un dato ya capturado.
|
||||
|
||||
NO hereda ``due_date`` a partir de ``Account.credit_days``, ni ``commercial_terms`` hacia
|
||||
las notas. Se decidió dejarlos fuera: el vencimiento depende de la fecha de emisión, que
|
||||
puede no estar fijada todavía, y las notas de la factura son texto que alguien escribe.
|
||||
"""
|
||||
account_id = data.get("account_id")
|
||||
if not account_id:
|
||||
return
|
||||
account = (
|
||||
db.query(Account)
|
||||
.filter(
|
||||
Account.id == account_id,
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
return
|
||||
|
||||
for campo, campo_account, modelo in _ACCOUNT_INHERITED_BILLING:
|
||||
if data.get(campo) is not None:
|
||||
continue
|
||||
texto = getattr(account, campo_account, None)
|
||||
fila = catalogs_service.find_by_code(db, modelo, texto)
|
||||
if fila is not None:
|
||||
data[campo] = fila.id
|
||||
elif texto:
|
||||
# Se avisa para que se limpie el CRM: la factura se crea igual y el timbrado
|
||||
# reportará la clave faltante junto al resto de los pendientes.
|
||||
logger.info(
|
||||
"factura: la clave %r de %s del cliente %s no existe en el catálogo del SAT; "
|
||||
"no se hereda",
|
||||
texto, campo_account, account_id,
|
||||
)
|
||||
|
||||
if not data.get("currency"):
|
||||
moneda = (account.currency or "").strip().upper()[:3]
|
||||
if moneda:
|
||||
data["currency"] = moneda
|
||||
|
||||
|
||||
def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||
"""Completa la partida a partir del concepto del catálogo.
|
||||
|
||||
Hereda dos cosas cuando el cliente no las manda:
|
||||
Hereda, siempre y sólo cuando el cliente no lo manda:
|
||||
|
||||
- ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así
|
||||
que ahí va la descripción del concepto (recortada al largo de la columna).
|
||||
- ``concept`` y ``description``: la descripción del concepto va a las dos, recortada a 60 en
|
||||
la primera —que es lo que el PDF lee y lo que la columna admite— y completa en la segunda,
|
||||
que es la que el CFDI prefiere. Antes sólo se llenaba ``concept``, así que el comprobante
|
||||
declaraba el texto truncado aunque el catálogo lo tuviera entero.
|
||||
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
|
||||
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
|
||||
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
|
||||
para poder facturar una partida con una unidad distinta a la del concepto.
|
||||
- ``unit_amount`` desde el ``unit_price`` del concepto: si el catálogo ya tiene el precio,
|
||||
volver a teclearlo en cada partida es trabajo doble y una fuente de discrepancias. Se
|
||||
hereda en el service y no sólo en la pantalla, para que cualquier cliente de la API lo
|
||||
obtenga igual — antes el precio lo prellenaba únicamente el formulario web.
|
||||
|
||||
El impuesto NO se hereda aquí: vive en filas propias y lo resuelve
|
||||
``taxes_service.sync_item_taxes`` después del insert, que es quien sabe leer la
|
||||
configuración fiscal del concepto.
|
||||
"""
|
||||
concept_id = data.get("concept_id")
|
||||
if concept_id is not None:
|
||||
@@ -402,9 +673,21 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||
)
|
||||
if not data.get("concept"):
|
||||
data["concept"] = catalog_concept.description[:60]
|
||||
if not data.get("description"):
|
||||
# La descripción COMPLETA va al campo largo. El CFDI la prefiere sobre ``concept``,
|
||||
# que está recortado a 60 caracteres, así que sin esto el comprobante declaraba un
|
||||
# texto truncado de un concepto que el catálogo tiene entero.
|
||||
data["description"] = catalog_concept.description[:255]
|
||||
for field in _CONCEPT_INHERITED_FIELDS:
|
||||
if data.get(field) is None:
|
||||
data[field] = getattr(catalog_concept, field)
|
||||
for campo_partida, campo_concepto in _CONCEPT_RENAMED_FIELDS:
|
||||
if data.get(campo_partida) is None:
|
||||
data[campo_partida] = getattr(catalog_concept, campo_concepto)
|
||||
# unit_amount es NOT NULL con server_default: un None que nadie llenó se retira para que
|
||||
# mande el default de la columna, en vez de reventar en el flush.
|
||||
if "unit_amount" in data and data["unit_amount"] is None:
|
||||
data.pop("unit_amount")
|
||||
if not data.get("concept"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -414,6 +697,7 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||
|
||||
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
||||
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
||||
_reject_if_stamped(db, invoice, tenant_id, company_id, motivo="no se le pueden agregar partidas")
|
||||
data = payload.model_dump()
|
||||
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
@@ -428,6 +712,10 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
|
||||
|
||||
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
_reject_if_stamped(
|
||||
db, get_invoice(db, item.invoice_id, tenant_id, company_id), tenant_id, company_id,
|
||||
motivo="sus partidas no se pueden editar",
|
||||
)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
|
||||
# descripción y claves fiscales del concepto nuevo.
|
||||
@@ -447,6 +735,13 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
|
||||
def delete_item(db, item_id, tenant_id, company_id) -> None:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
invoice_id = item.invoice_id
|
||||
_reject_if_stamped(
|
||||
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)
|
||||
@@ -44,52 +80,107 @@ def _tax_object_code(db: Session, item: InvoiceItem) -> str:
|
||||
return row.code if row else ""
|
||||
|
||||
|
||||
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 _reject_if_stamped_item(
|
||||
db: Session, item: InvoiceItem, tenant_id: int, company_id: int, motivo: str
|
||||
) -> None:
|
||||
"""Bloquea la captura manual de impuestos sobre la partida de una factura ya timbrada.
|
||||
|
||||
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.
|
||||
El desglose viajó al CFDI y ahí quedó. Import diferido de ``service`` porque ese módulo
|
||||
importa a este; al revés sería circular.
|
||||
"""
|
||||
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
|
||||
from . import service # noqa: PLC0415
|
||||
|
||||
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á
|
||||
invoice = service.get_invoice(db, item.invoice_id, tenant_id, company_id)
|
||||
service._reject_if_stamped(db, invoice, tenant_id, company_id, motivo=motivo)
|
||||
|
||||
|
||||
def clear_item_taxes(db: Session, item_id: int) -> None:
|
||||
"""Borra los impuestos de una partida.
|
||||
|
||||
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.
|
||||
"""
|
||||
for t in _item_taxes(db, item_id):
|
||||
db.delete(t)
|
||||
|
||||
|
||||
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:
|
||||
@@ -133,13 +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(
|
||||
@@ -156,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(
|
||||
@@ -177,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)
|
||||
@@ -197,5 +347,9 @@ def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: in
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Impuesto no encontrado")
|
||||
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(
|
||||
@@ -248,7 +251,11 @@ def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int)
|
||||
payment_form=_code(db, PaymentForm, invoice.payment_form_id),
|
||||
payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
|
||||
currency=(invoice.currency or "MXN").upper(),
|
||||
exchange_rate=None,
|
||||
# En MXN queda en None y el comprobante no lleva TipoCambio; con otra moneda es
|
||||
# obligatorio y su ausencia la reporta la validación del builder junto al resto.
|
||||
exchange_rate=(
|
||||
Decimal(str(invoice.exchange_rate)) if invoice.exchange_rate is not None else None
|
||||
),
|
||||
expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
|
||||
payment_conditions=None,
|
||||
issuer_rfc=(issuer.rfc or "").strip().upper(),
|
||||
@@ -263,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.
|
||||
|
||||
@@ -359,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)
|
||||
|
||||
@@ -217,15 +217,25 @@ def test_el_cliente_extrae_el_code_del_formato_de_error_del_contrato():
|
||||
|
||||
# ── El API de usuario del CRM ────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"El contrato declara nueve endpoints /expedientes/* (ensure, completar, documentos, "
|
||||
"archivo) que este repo NO implementa: venían del expediente paralelo que 5c4df59 "
|
||||
"descartó al integrar el carril sobre crm.cases, y hoy sólo existen tres rutas de "
|
||||
"lectura en /cases. El archivo del contrato es la mitad de un acuerdo que EFC afirma "
|
||||
"contra una copia idéntica, así que no se toca sin hablar con ese equipo: hay que "
|
||||
"decidir si el API se implementa o si el contrato se recorta. PENDIENTE DECISIÓN."
|
||||
)
|
||||
)
|
||||
def test_las_rutas_registradas_del_crm_son_las_del_contrato():
|
||||
"""Cubre las dos direcciones: ninguna del contrato sin registrar, y ninguna registrada de más
|
||||
en estos dos routers. Un endpoint que aparece sin estar en el contrato es un endpoint que
|
||||
nadie del otro lado sabe que existe."""
|
||||
from api.v1.modules.crm.cases.routes import router as cases_router
|
||||
from api.v1.modules.crm.expediente_gateway.routes import router as gateway_router
|
||||
from api.v1.modules.crm.expedientes.routes import router as expedientes_router
|
||||
|
||||
registradas = set()
|
||||
for router in (expedientes_router, gateway_router):
|
||||
for router in (cases_router, gateway_router):
|
||||
for ruta in router.routes:
|
||||
# ``ruta.path`` ya trae el prefijo del router aplicado: concatenarlo lo duplicaría.
|
||||
for metodo in ruta.methods:
|
||||
@@ -239,12 +249,22 @@ def test_las_rutas_registradas_del_crm_son_las_del_contrato():
|
||||
assert registradas - del_contrato == set(), "hay rutas fuera del contrato"
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason=(
|
||||
"DocumentResponse (crm/documents/dto.py) expone hoy file_key y file_url, que el contrato "
|
||||
"prohíbe, y le faltan expediente_id, efc_sync_state y efc_document_ref, que exige. No es "
|
||||
"un arreglo de una línea: el frontend usa file_key para descargar (RelatedManager y la "
|
||||
"pantalla de embarques), y las columnas efc_* no existen en crm.documents. Quitarlo "
|
||||
"rompería la descarga; agregarlas es una migración. PENDIENTE DECISIÓN, junto con el test "
|
||||
"de rutas de arriba."
|
||||
)
|
||||
)
|
||||
def test_la_respuesta_de_un_documento_nunca_expone_la_copia_local():
|
||||
"""La copia local se borra al confirmar la entrega a EFC: una referencia expuesta al frontend
|
||||
es una referencia que va a dejar de existir."""
|
||||
from api.v1.modules.crm.expedientes.dto import ExpedienteDocumentResponse
|
||||
from api.v1.modules.crm.documents.dto import DocumentResponse
|
||||
|
||||
campos = set(ExpedienteDocumentResponse.model_fields)
|
||||
campos = set(DocumentResponse.model_fields)
|
||||
|
||||
for prohibido in API_CRM["documento_response_prohibido"]:
|
||||
assert prohibido not in campos, f"la respuesta expone '{prohibido}'"
|
||||
|
||||
@@ -18,7 +18,7 @@ from api.v1.modules.crm.expediente_gateway.models import (
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.cases.models import Case
|
||||
from api.v1.modules.crm.documents.models import Document
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
@@ -77,29 +77,37 @@ def entorno(db, monkeypatch):
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
expediente = db.query(Case).filter(Case.id == solicitud.case_id).one()
|
||||
return {"db": db, "expediente": expediente, "borrados": borrados}
|
||||
|
||||
|
||||
def _documento_local(db, expediente) -> Document:
|
||||
"""Documento del CRM con las columnas que la tabla tiene HOY.
|
||||
|
||||
``crm.documents`` no lleva columnas ``efc_*`` ni ``expediente_id``: eran del expediente
|
||||
paralelo que se descartó al integrar el carril. Lo que sí existe —y es lo que estas pruebas
|
||||
ejercitan— es la entrega en sí: ``deliver_file_row`` trabaja contra la fila del outbox.
|
||||
"""
|
||||
doc = Document(
|
||||
doc_type="MBL",
|
||||
name="guia.pdf",
|
||||
file_key=S3_KEY,
|
||||
content_type="application/pdf",
|
||||
size_bytes=len(CONTENIDO),
|
||||
expediente_id=expediente.id,
|
||||
efc_sync_state="PENDING",
|
||||
service_request_id=None,
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}"
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _ref(documento) -> str:
|
||||
"""El handle con el que EFC reconoce el documento, igual que lo arma producción."""
|
||||
return f"CRMDOC-{COMPANY_ID}-{documento.id}"
|
||||
|
||||
|
||||
def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
||||
row = EfcFileOutbox(
|
||||
kind=FILE_KIND_DOCUMENTO,
|
||||
@@ -109,7 +117,7 @@ def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
||||
efc_tipo="MBL",
|
||||
source_table=SOURCE_CRM_DOCUMENTS,
|
||||
source_id=documento.id,
|
||||
crm_document_ref=documento.efc_document_ref,
|
||||
crm_document_ref=_ref(documento),
|
||||
expediente_ref=expediente.id,
|
||||
delete_local=kwargs.pop("delete_local", True),
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
@@ -124,7 +132,15 @@ def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
||||
|
||||
# ── Camino feliz ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
|
||||
def test_entrega_feliz_marca_la_fila_y_suelta_la_copia_local(entorno):
|
||||
"""El acuse de EFC queda en la fila del outbox, y el documento suelta su llave local.
|
||||
|
||||
Antes este test afirmaba además ``documento.efc_sync_state == "SYNCED"``, pero
|
||||
``crm.documents`` NO tiene columnas ``efc_*``: eran del expediente paralelo que se descartó.
|
||||
Hoy ``_marcar_documento_entregado`` les asigna atributos que no están mapeados y no se
|
||||
persiste nada — lo único que surte efecto es limpiar ``file_key``, y es lo que se fija aquí.
|
||||
Reflejar el estado de sincronización en la ficha del documento sigue pendiente.
|
||||
"""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
@@ -135,9 +151,8 @@ def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
|
||||
assert row.status == STATUS_SENT
|
||||
assert row.efc_document_id == "doc-1"
|
||||
assert row.sent_at is not None
|
||||
assert documento.efc_sync_state == "SYNCED"
|
||||
assert documento.efc_document_id == "doc-1"
|
||||
assert documento.efc_synced_at is not None
|
||||
# El objeto local ya se borró: dejar la llave apuntaría a algo inexistente.
|
||||
assert documento.file_key is None
|
||||
|
||||
|
||||
def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno):
|
||||
@@ -150,7 +165,7 @@ def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entor
|
||||
|
||||
assert len(cliente.uploads) == 1
|
||||
subida = cliente.uploads[0]
|
||||
assert subida["crm_document_ref"] == documento.efc_document_ref
|
||||
assert subida["crm_document_ref"] == _ref(documento)
|
||||
assert subida["content"] == CONTENIDO
|
||||
assert subida["tipo"] == "MBL"
|
||||
assert subida["expediente_id"] == expediente.id
|
||||
@@ -173,7 +188,7 @@ def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno):
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert len(cliente.ingests) == 1
|
||||
assert cliente.ingests[0]["folio"] == expediente.folio
|
||||
assert cliente.ingests[0]["folio"] == expediente.reference
|
||||
assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token
|
||||
assert len(cliente.uploads) == 2 # el que falló + UNO de reintento
|
||||
assert row.status == STATUS_SENT
|
||||
@@ -262,7 +277,8 @@ def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monke
|
||||
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||
|
||||
assert row.status == STATUS_SENT
|
||||
assert documento.efc_sync_state == "SYNCED"
|
||||
# La entrega vale aunque el borrado local falle: el archivo ya está en EFC.
|
||||
assert row.efc_document_id == "doc-1"
|
||||
|
||||
|
||||
def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno):
|
||||
@@ -285,7 +301,7 @@ def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(
|
||||
|
||||
# ── Fallos ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
|
||||
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_la_fila(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1)
|
||||
@@ -297,9 +313,11 @@ def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
|
||||
gateway.deliver_file_row(db, row, cliente) # no lanza
|
||||
|
||||
assert row.status == STATUS_FAILED
|
||||
assert documento.efc_sync_state == "FAILED"
|
||||
assert "EFC caído" in documento.efc_error_detail
|
||||
assert documento.efc_attempts == MAX_ATTEMPTS
|
||||
# El diagnóstico vive en la fila del outbox, que es lo que el tablero de ops muestra.
|
||||
assert "EFC caído" in row.last_error
|
||||
assert row.attempts == MAX_ATTEMPTS
|
||||
# El original NO se borra cuando la entrega falla: es la única copia que hay.
|
||||
assert documento.file_key == S3_KEY
|
||||
|
||||
|
||||
def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno):
|
||||
|
||||
@@ -431,7 +431,12 @@ def test_account_free_text_fiscal_fields_are_preserved(db):
|
||||
|
||||
|
||||
def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch):
|
||||
"""Las facturas previas, sin claves del SAT, siguen listándose y generando PDF."""
|
||||
"""Las facturas previas, sin claves del SAT, siguen listándose y generando PDF.
|
||||
|
||||
Cubre además que la herencia de datos del cliente no inventa nada: la cuenta se crea solo
|
||||
con ``name``, así que sin forma ni método de pago en la ficha no hay nada que heredar y las
|
||||
claves de la factura siguen en NULL.
|
||||
"""
|
||||
stored = {}
|
||||
monkeypatch.setattr(
|
||||
"core.storage_s3.put_object_bytes",
|
||||
@@ -451,3 +456,289 @@ def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch):
|
||||
|
||||
sent = invoices_service.send_invoice(db, invoice.id, T, C)
|
||||
assert sent.status == "enviada" and stored["len"] > 0
|
||||
|
||||
|
||||
# ---------- Tipo de cambio de la factura ----------
|
||||
#
|
||||
# La factura hereda la moneda de la ficha del cliente, y una factura en moneda distinta de MXN
|
||||
# no se puede timbrar sin tipo de cambio. Antes el campo no existía y `_build_data` mandaba
|
||||
# `exchange_rate=None` siempre: un cliente en USD producía facturas que fallaban al timbrar sin
|
||||
# pista del porqué.
|
||||
|
||||
def _factura_en_dolares(db, exchange_rate=None):
|
||||
from api.v1.modules.crm.accounts.dto import AccountCreate
|
||||
|
||||
issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester")
|
||||
account = accounts_service.create_account(
|
||||
db, AccountCreate(name="Cliente Extranjero", currency="USD"), T, C
|
||||
)
|
||||
invoice = invoices_service.create_invoice(
|
||||
db,
|
||||
InvoiceCreate(reference="F-USD", account_id=account.id, exchange_rate=exchange_rate),
|
||||
T, C,
|
||||
)
|
||||
invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional", unit_amount=1000),
|
||||
T, C,
|
||||
)
|
||||
return invoices_service.get_invoice(db, invoice.id, T, C)
|
||||
|
||||
|
||||
_FALTA_TIPO_DE_CAMBIO = "falta el tipo de cambio (moneda distinta de MXN)"
|
||||
|
||||
|
||||
def _faltantes(data) -> list[str]:
|
||||
"""``validate`` no devuelve la lista: lanza con TODOS los faltantes dentro."""
|
||||
from api.v1.modules.fin.stamping.cfdi_builder import CfdiBuildError
|
||||
|
||||
try:
|
||||
data.validate()
|
||||
except CfdiBuildError as exc:
|
||||
return exc.missing
|
||||
return []
|
||||
|
||||
|
||||
def test_el_tipo_de_cambio_de_la_factura_llega_al_comprobante(db):
|
||||
from api.v1.modules.fin.stamping import service as stamping_service
|
||||
|
||||
invoice = _factura_en_dolares(db, exchange_rate=Decimal("17.25"))
|
||||
assert invoice.currency == "USD", "la moneda debió heredarse de la ficha del cliente"
|
||||
|
||||
data = stamping_service._build_data(db, invoice, T, C)
|
||||
|
||||
assert data.currency == "USD"
|
||||
assert data.exchange_rate == Decimal("17.250000")
|
||||
assert _FALTA_TIPO_DE_CAMBIO not in _faltantes(data)
|
||||
|
||||
|
||||
def test_factura_en_dolares_sin_tipo_de_cambio_no_pasa_la_validacion(db):
|
||||
"""Es el 422 que motiva el campo, y se reporta junto al resto de los faltantes, no aparte."""
|
||||
from api.v1.modules.fin.stamping import service as stamping_service
|
||||
|
||||
invoice = _factura_en_dolares(db)
|
||||
|
||||
data = stamping_service._build_data(db, invoice, T, C)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------- La partida hereda del concepto ya configurado ----------
|
||||
|
||||
def _concepto_configurado(db, **fiscal):
|
||||
"""Concepto con precio, claves del SAT y —si se pide— su impuesto por defecto."""
|
||||
payload = _concept_payload(db)
|
||||
datos = payload.model_dump()
|
||||
datos.update(fiscal)
|
||||
return concepts_service.create_concept(db, ConceptCreate(**datos), T, C, "tester")
|
||||
|
||||
|
||||
def test_la_partida_hereda_el_precio_del_concepto(db):
|
||||
"""Si el catálogo ya trae el precio, no hay que volver a teclearlo en cada partida.
|
||||
|
||||
Se hereda en el service y no sólo en el formulario web, para que cualquier cliente de la API
|
||||
obtenga lo mismo.
|
||||
"""
|
||||
concepto = _concepto_configurado(db)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC1"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, quantity=Decimal("2")), T, C
|
||||
)
|
||||
|
||||
assert float(item.unit_amount) == 1500.0
|
||||
assert item.product_service_id == concepto.product_service_id
|
||||
assert item.unit_of_measure_id == concepto.unit_of_measure_id
|
||||
assert item.tax_object_id == concepto.tax_object_id
|
||||
assert item.concept == "Flete marítimo internacional"[:60]
|
||||
assert float(invoices_service.get_invoice(db, invoice.id, T, C).subtotal) == 3000.0
|
||||
|
||||
|
||||
def test_el_precio_capturado_manda_sobre_el_del_concepto(db):
|
||||
concepto = _concepto_configurado(db)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC2"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, unit_amount=Decimal("999")),
|
||||
T, C,
|
||||
)
|
||||
|
||||
assert float(item.unit_amount) == 999.0
|
||||
|
||||
|
||||
def test_un_precio_cero_capturado_no_se_sustituye_por_el_del_concepto(db):
|
||||
"""Cero es una decisión válida —una partida de cortesía— y no un campo vacío."""
|
||||
concepto = _concepto_configurado(db)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC3"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, unit_amount=Decimal("0")),
|
||||
T, C,
|
||||
)
|
||||
|
||||
assert float(item.unit_amount) == 0.0
|
||||
|
||||
|
||||
def test_una_partida_generica_sin_precio_queda_en_cero(db):
|
||||
"""Sin concepto no hay de dónde heredar: manda el default de la columna."""
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC4"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional"), T, C
|
||||
)
|
||||
|
||||
assert float(item.unit_amount) == 0.0
|
||||
|
||||
|
||||
def test_la_partida_hereda_el_impuesto_configurado_en_el_concepto(db):
|
||||
"""La terna fiscal del concepto llega a la partida como su fila de impuesto."""
|
||||
from api.v1.modules.fin.catalogs.models import Tax
|
||||
from api.v1.modules.fin.invoices.models import InvoiceItemTax
|
||||
|
||||
iva = db.query(Tax).filter(Tax.code == "002").one()
|
||||
concepto = _concepto_configurado(
|
||||
db, default_tax_id=iva.id, default_tax_factor="Tasa", default_tax_rate=Decimal("0.08")
|
||||
)
|
||||
invoice = invoices_service.create_invoice(
|
||||
db, InvoiceCreate(reference="F-HC5", tax_rate=Decimal("16")), T, C
|
||||
)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id), T, C
|
||||
)
|
||||
|
||||
filas = db.query(InvoiceItemTax).filter(
|
||||
InvoiceItemTax.invoice_item_id == item.id, InvoiceItemTax.deleted_at.is_(None)
|
||||
).all()
|
||||
assert len(filas) == 1
|
||||
assert float(filas[0].rate) == 0.08, "el % de la factura pisó el impuesto del concepto"
|
||||
# 1500 del precio del concepto × 8%
|
||||
assert float(filas[0].amount) == 120.0
|
||||
assert float(invoices_service.get_invoice(db, invoice.id, T, C).tax_amount) == 120.0
|
||||
|
||||
|
||||
def test_la_partida_hereda_la_descripcion_completa_del_concepto(db):
|
||||
"""``concept`` está recortado a 60; el CFDI declara ``description``, que va completa.
|
||||
|
||||
Sin esto el comprobante llevaba el texto truncado de un concepto que el catálogo tenía
|
||||
entero.
|
||||
"""
|
||||
largo = "Flete marítimo internacional puerta a puerta con seguro de carga incluido y maniobras"
|
||||
assert len(largo) > 60
|
||||
payload = _concept_payload(db).model_dump()
|
||||
payload["description"] = largo
|
||||
concepto = concepts_service.create_concept(db, ConceptCreate(**payload), T, C, "tester")
|
||||
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-DESC"), T, C)
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id), T, C
|
||||
)
|
||||
|
||||
assert item.concept == largo[:60]
|
||||
assert item.description == largo, "el CFDI declararía el texto truncado"
|
||||
|
||||
|
||||
def test_la_descripcion_capturada_manda_sobre_la_del_concepto(db):
|
||||
concepto = _concepto_configurado(db)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-DESC2"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, description="Contenedor 40HC"),
|
||||
T, C,
|
||||
)
|
||||
|
||||
assert item.description == "Contenedor 40HC"
|
||||
|
||||
|
||||
def test_la_etiqueta_del_pdf_no_repite_el_texto_del_concepto():
|
||||
"""El renglón del PDF imprime un solo texto cuando los dos campos son el mismo."""
|
||||
from api.v1.modules.fin.invoices.pdf import etiqueta_partida
|
||||
|
||||
largo = "Flete marítimo internacional puerta a puerta con seguro de carga incluido"
|
||||
# Heredados del catálogo: concept es el recorte de description.
|
||||
assert etiqueta_partida(largo[:60], largo) == largo
|
||||
# Textos distintos: se imprimen los dos, como siempre.
|
||||
assert etiqueta_partida("flete_internacional", "Contenedor 40HC") == "flete_internacional — Contenedor 40HC"
|
||||
# Sin descripción, sólo el concepto.
|
||||
assert etiqueta_partida("flete_internacional", "") == "flete_internacional"
|
||||
|
||||
@@ -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)
|
||||
@@ -174,3 +208,486 @@ def test_la_derivacion_no_pisa_una_retencion_capturada(db):
|
||||
# Éste es el assert que distingue: sin el guard, el IVA habría bajado a 0.08 / 80.0.
|
||||
assert float(taxes[iva.id].rate) == 0.16, "el automatismo recalculó una partida intervenida"
|
||||
assert float(taxes[iva.id].amount) == 160.0
|
||||
|
||||
|
||||
# ── Herencia de los datos de facturación del cliente ─────────────────────────
|
||||
#
|
||||
# Forma y método de pago se capturaban a mano en cada factura aunque ya vivieran en la ficha del
|
||||
# cliente, y son justo las claves que detienen el timbrado en validación si faltan.
|
||||
|
||||
def _cliente_con_datos_fiscales(db, **overrides):
|
||||
datos = {
|
||||
"name": "Importadora Delta",
|
||||
"payment_form": "03", # Transferencia electrónica de fondos
|
||||
"payment_method": "PPD", # Pago en parcialidades o diferido
|
||||
"currency": "MXN",
|
||||
}
|
||||
datos.update(overrides)
|
||||
return accounts_service.create_account(db, AccountCreate(**datos), T, C)
|
||||
|
||||
|
||||
def _clave(db, model, code: str) -> int:
|
||||
from api.v1.modules.fin.catalogs import service as catalogs_service
|
||||
|
||||
fila = catalogs_service.find_by_code(db, model, code)
|
||||
assert fila is not None, f"el catálogo de pruebas no tiene la clave {code!r}"
|
||||
return fila.id
|
||||
|
||||
|
||||
def test_la_factura_hereda_forma_metodo_y_moneda_del_cliente(db):
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm, PaymentMethod
|
||||
|
||||
acc = _cliente_con_datos_fiscales(db, currency="USD")
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H1", account_id=acc.id), T, C)
|
||||
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
|
||||
assert inv.payment_method_id == _clave(db, PaymentMethod, "PPD")
|
||||
assert inv.currency == "USD"
|
||||
|
||||
|
||||
def test_lo_explicito_manda_sobre_la_ficha_del_cliente(db):
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm
|
||||
|
||||
acc = _cliente_con_datos_fiscales(db)
|
||||
efectivo = _clave(db, PaymentForm, "01")
|
||||
inv = service.create_invoice(
|
||||
db,
|
||||
InvoiceCreate(reference="F-H2", account_id=acc.id, payment_form_id=efectivo, currency="EUR"),
|
||||
T, C,
|
||||
)
|
||||
|
||||
assert inv.payment_form_id == efectivo, "la ficha del cliente pisó un dato capturado"
|
||||
assert inv.currency == "EUR"
|
||||
|
||||
|
||||
def test_una_forma_de_pago_no_resoluble_no_impide_facturar(db):
|
||||
"""``crm.accounts.payment_form`` es texto libre sin FK: puede traer basura histórica.
|
||||
|
||||
Una ficha que diga "Transferencia" en vez de "03" no puede impedir crear una factura. El
|
||||
faltante lo reporta después la validación del timbrado, junto al resto de los pendientes.
|
||||
"""
|
||||
acc = _cliente_con_datos_fiscales(db, payment_form="Transferencia", payment_method="Contado")
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H3", account_id=acc.id), T, C)
|
||||
|
||||
assert inv.payment_form_id is None
|
||||
assert inv.payment_method_id is None
|
||||
|
||||
|
||||
def test_clave_de_forma_de_pago_de_un_digito_se_normaliza(db):
|
||||
"""c_FormaPago son dos dígitos: '3' y '03' son la misma forma de pago."""
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm
|
||||
|
||||
acc = _cliente_con_datos_fiscales(db, payment_form="3", payment_method="pue")
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H4", account_id=acc.id), T, C)
|
||||
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
|
||||
assert inv.payment_method_id is not None, "el método de pago en minúsculas debió resolverse"
|
||||
|
||||
|
||||
def test_sin_cliente_no_se_hereda_nada(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H5"), T, C)
|
||||
|
||||
assert inv.payment_form_id is None
|
||||
assert inv.payment_method_id is None
|
||||
assert inv.currency == "MXN", "sin ficha ni captura debe mandar el default de la columna"
|
||||
|
||||
|
||||
def test_cambiar_de_cliente_rehereda_los_datos_de_facturacion(db):
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm
|
||||
|
||||
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
|
||||
otro = _cliente_con_datos_fiscales(db, name="Cliente Dos", payment_form="01", currency="USD")
|
||||
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H6", account_id=uno.id), T, C)
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
|
||||
|
||||
inv = service.update_invoice(db, inv.id, InvoiceUpdate(account_id=otro.id), T, C)
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "01"), "no se re-heredó al cambiar cliente"
|
||||
assert inv.currency == "USD"
|
||||
|
||||
|
||||
def test_al_cambiar_de_cliente_lo_explicito_del_patch_gana(db):
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm
|
||||
|
||||
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
|
||||
otro = _cliente_con_datos_fiscales(db, name="Cliente Dos", payment_form="01")
|
||||
cheque = _clave(db, PaymentForm, "02")
|
||||
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H7", account_id=uno.id), T, C)
|
||||
inv = service.update_invoice(
|
||||
db, inv.id, InvoiceUpdate(account_id=otro.id, payment_form_id=cheque), T, C
|
||||
)
|
||||
|
||||
assert inv.payment_form_id == cheque
|
||||
|
||||
|
||||
def test_cambiar_a_un_cliente_sin_datos_no_vacia_lo_capturado(db):
|
||||
"""Completar, nunca borrar: una ficha vacía no puede tirar un dato que ya se capturó."""
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm
|
||||
|
||||
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
|
||||
pelon = accounts_service.create_account(db, AccountCreate(name="Cliente Sin Datos"), T, C)
|
||||
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-H8", account_id=uno.id), T, C)
|
||||
inv = service.update_invoice(db, inv.id, InvoiceUpdate(account_id=pelon.id), T, C)
|
||||
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
|
||||
assert inv.currency == "MXN"
|
||||
|
||||
|
||||
def test_generate_from_shipment_hereda_datos_de_facturacion(db):
|
||||
"""La moneda del EMBARQUE gana sobre la de la ficha: es la que se coteó y se operó."""
|
||||
from api.v1.modules.fin.catalogs.models import PaymentForm, PaymentMethod
|
||||
|
||||
acc = _cliente_con_datos_fiscales(db, currency="EUR")
|
||||
quote = quotes_service.create_quote(
|
||||
db, QuoteCreate(reference="COT-H", account_id=acc.id, currency="USD"), T, C
|
||||
)
|
||||
quotes_service.create_quote_item(
|
||||
db,
|
||||
QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500),
|
||||
T, C,
|
||||
)
|
||||
quotes_service.accept_quote(db, quote.id, T, C)
|
||||
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
|
||||
shipments_service.close_shipment(
|
||||
db, shipment.id, ShipmentCloseInput(actual_cost_total=Decimal("1000"), cost_currency="USD"), T, C
|
||||
)
|
||||
|
||||
inv = service.generate_from_shipment(db, shipment.id, T, C)
|
||||
|
||||
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
|
||||
assert inv.payment_method_id == _clave(db, PaymentMethod, "PPD")
|
||||
assert inv.currency == "USD", "la moneda del embarque debe ganar sobre la de la ficha"
|
||||
|
||||
|
||||
# ── Una factura timbrada no admite cambios ───────────────────────────────────
|
||||
#
|
||||
# El CFDI ya existe ante el SAT: editar la factura después haría que ella y su comprobante
|
||||
# contaran cosas distintas. Se corrige cancelando y refacturando.
|
||||
|
||||
def _timbra(db, invoice) -> None:
|
||||
from api.v1.modules.fin.stamping.models import STATUS_STAMPED, InvoiceStamp
|
||||
|
||||
db.add(
|
||||
InvoiceStamp(
|
||||
invoice_id=invoice.id, mode="pruebas", status=STATUS_STAMPED,
|
||||
uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
tenant_id=T, company_id=C,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _factura_timbrada(db, reference="F-T1"):
|
||||
acc = _cliente_con_datos_fiscales(db)
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference=reference, account_id=acc.id), T, C)
|
||||
item = service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C
|
||||
)
|
||||
_timbra(db, inv)
|
||||
return inv, item
|
||||
|
||||
|
||||
def test_no_se_puede_cambiar_el_cliente_de_una_factura_timbrada(db):
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
inv, _ = _factura_timbrada(db)
|
||||
otro = _cliente_con_datos_fiscales(db, name="Otro")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.update_invoice(db, inv.id, InvoiceUpdate(account_id=otro.id), T, C)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_una_factura_timbrada_si_admite_notas_y_pagos(db):
|
||||
"""El candado es una lista cerrada: cobrar y anotar no alteran el comprobante."""
|
||||
inv, _ = _factura_timbrada(db, reference="F-T2")
|
||||
|
||||
inv = service.update_invoice(db, inv.id, InvoiceUpdate(notes="Pagada por transferencia"), T, C)
|
||||
assert inv.notes == "Pagada por transferencia"
|
||||
|
||||
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("100")), T, C)
|
||||
assert float(service.get_invoice(db, inv.id, T, C).paid_amount) == 100.0
|
||||
|
||||
|
||||
def test_no_se_pueden_tocar_las_partidas_de_una_factura_timbrada(db):
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
inv, item = _factura_timbrada(db, reference="F-T3")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=50), T, C
|
||||
)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.update_item(db, item.id, InvoiceItemUpdate(unit_amount=Decimal("2000")), T, C)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
service.delete_item(db, item.id, T, C)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
def test_no_se_puede_editar_el_desglose_de_impuestos_de_una_factura_timbrada(db):
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.fin.invoices import taxes_service
|
||||
|
||||
from api.v1.modules.fin.catalogs.models import Tax
|
||||
|
||||
inv, item = _factura_timbrada(db, reference="F-T4")
|
||||
iva = db.query(Tax).filter(Tax.code == "002").first()
|
||||
|
||||
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;
|
||||
|
||||
@@ -16,13 +16,21 @@ export interface Invoice {
|
||||
shipment_id: number | null;
|
||||
quote_id: number | null;
|
||||
account_id: number | null;
|
||||
/** Se hereda de la ficha del cliente al crear la factura. */
|
||||
currency: string;
|
||||
/** Tipo de cambio a MXN. Obligatorio para timbrar cuando la moneda no es MXN. */
|
||||
exchange_rate: number | null;
|
||||
status: InvoiceStatus;
|
||||
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;
|
||||
@@ -38,7 +46,8 @@ export interface Invoice {
|
||||
owner_user_id: string | null;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
// Claves fiscales del CFDI (catálogos SAT); nulas mientras no se capturen.
|
||||
// Claves fiscales del CFDI (catálogos SAT). Forma y método de pago se heredan de la ficha del
|
||||
// cliente al crear la factura y al cambiarle el cliente; lo capturado a mano manda.
|
||||
voucher_type_id: number | null;
|
||||
payment_form_id: number | null;
|
||||
payment_method_id: number | null;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -90,11 +90,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo los campos que cambiaron respecto de la factura cargada.
|
||||
*
|
||||
* Mandar el objeto completo rompía la re-herencia: al cambiar de cliente, el PATCH llevaba el
|
||||
* account_id nuevo junto con la forma y el método de pago del anterior, así que el backend no
|
||||
* tenía cómo saber que esos valores no eran una decisión del usuario. Además hacía que cada
|
||||
* guardado del encabezado reenviara tax_rate y disparara el recálculo de los impuestos.
|
||||
*/
|
||||
function headerDelta(): InvoiceInput {
|
||||
if (!invoice) return {};
|
||||
const actual = invoice as unknown as Record<string, unknown>;
|
||||
const delta: Record<string, unknown> = {};
|
||||
for (const [campo, valor] of Object.entries(form)) {
|
||||
if (valor !== actual[campo]) delta[campo] = valor;
|
||||
}
|
||||
return delta as InvoiceInput;
|
||||
}
|
||||
|
||||
async function saveHeader() {
|
||||
if (!companyId || !invoice) return;
|
||||
const delta = headerDelta();
|
||||
if (Object.keys(delta).length === 0) {
|
||||
toast.info('No hay cambios por guardar');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
invoice = await invoicesAPI.update(invoice.id, form, companyId);
|
||||
invoice = await invoicesAPI.update(invoice.id, delta, companyId);
|
||||
await reload();
|
||||
toast.success('Cambios guardados');
|
||||
} catch (e) {
|
||||
@@ -241,7 +264,13 @@
|
||||
addingItem = true;
|
||||
}
|
||||
|
||||
/** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */
|
||||
/** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico.
|
||||
*
|
||||
* Al elegir un concepto ya configurado se copian sus valores a los campos de la partida, para
|
||||
* que se VEAN antes de guardar. El backend hereda lo mismo si no se le manda, pero dejar los
|
||||
* selects del SAT vacíos en pantalla hacía parecer que faltaban datos que el catálogo ya tenía
|
||||
* —y no dejaba ver qué impuesto iba a aplicar—. Lo que se cambie aquí manda sobre el catálogo.
|
||||
*/
|
||||
function applyConceptChoice() {
|
||||
if (conceptChoice.startsWith('cat:')) {
|
||||
const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4)));
|
||||
@@ -250,12 +279,45 @@
|
||||
newItem.concept_id = c.id;
|
||||
newItem.concept = undefined;
|
||||
if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price);
|
||||
newItem.product_service_id = c.product_service_id;
|
||||
newItem.unit_of_measure_id = c.unit_of_measure_id;
|
||||
newItem.tax_object_id = c.tax_object_id;
|
||||
} else {
|
||||
newItem.concept_id = null;
|
||||
newItem.concept = conceptChoice.slice(4);
|
||||
// Una partida genérica no tiene de dónde heredar: se limpia para no arrastrar las
|
||||
// claves del concepto anterior a algo que no las tiene.
|
||||
newItem.product_service_id = null;
|
||||
newItem.unit_of_measure_id = null;
|
||||
newItem.tax_object_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Concepto del catálogo elegido en el formulario de alta, si hay uno. */
|
||||
const conceptoElegido = $derived(
|
||||
conceptChoice.startsWith('cat:')
|
||||
? (activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4))) ?? null)
|
||||
: null
|
||||
);
|
||||
|
||||
/** Qué impuesto va a llevar la partida, resuelto con la misma precedencia que el backend. */
|
||||
const impuestoQueAplica = $derived.by(() => {
|
||||
const objeto = taxObjects.find((o) => o.id === newItem.tax_object_id);
|
||||
if (objeto?.code !== '02') {
|
||||
return objeto
|
||||
? `${objeto.code} — no causa impuesto, no se le cobra IVA`
|
||||
: 'Sin objeto de impuesto: la partida no llevará IVA y no se podrá timbrar';
|
||||
}
|
||||
const c = conceptoElegido;
|
||||
if (c?.default_tax_id && c.default_tax_factor) {
|
||||
const nombre = c.default_tax?.description ?? 'Impuesto';
|
||||
return c.default_tax_factor === 'Exento'
|
||||
? `${nombre} exento (del concepto)`
|
||||
: `${nombre} ${(Number(c.default_tax_rate ?? 0) * 100).toFixed(2)}% (del concepto)`;
|
||||
}
|
||||
return `IVA ${Number(invoice?.tax_rate ?? 0).toFixed(2)}% (por defecto de la factura)`;
|
||||
});
|
||||
|
||||
/** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */
|
||||
function itemConceptLabel(it: InvoiceItem): string {
|
||||
const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined;
|
||||
@@ -368,9 +430,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>
|
||||
@@ -432,9 +500,20 @@
|
||||
{#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, con el % de la factura.
|
||||
Solo «02 — Sí objeto de impuesto» lleva desglose; con las demás claves la partida
|
||||
no causa IVA.
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs sm:col-span-2">
|
||||
<span class="text-muted-foreground">Impuesto que se aplicará:</span>
|
||||
<span class="font-medium">{impuestoQueAplica}</span>
|
||||
</p>
|
||||
{#if conceptoElegido}
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||
Los valores vienen del concepto <span class="font-mono">{conceptoElegido.code}</span>;
|
||||
cambiarlos aquí solo afecta a esta partida.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -466,8 +545,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">
|
||||
@@ -534,36 +614,59 @@
|
||||
</Table.Root>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if stamp}
|
||||
<p class="mb-4 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
La factura ya está timbrada. Los datos del comprobante quedaron fijos ante el SAT:
|
||||
para corregirlos hay que cancelar el CFDI y refacturar. Las notas y los datos
|
||||
bancarios sí se pueden editar.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></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} /></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} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} disabled={!!stamp} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id} disabled={!!stamp}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Moneda</span>
|
||||
<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">% 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
|
||||
validación, antes de llegar al PAC. -->
|
||||
validación, antes de llegar al PAC. Se heredan de la ficha del cliente al crear
|
||||
la factura y al cambiarle el cliente. -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Forma de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_form_id}>
|
||||
<select class={inputCls} bind:value={form.payment_form_id} disabled={!!stamp}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentForms as f (f.id)}<option value={f.id}>{f.code} — {f.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia…</span>
|
||||
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia… Heredada del cliente.</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Método de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_method_id}>
|
||||
<select class={inputCls} bind:value={form.payment_method_id} disabled={!!stamp}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentMethods as m (m.id)}<option value={m.id}>{m.code} — {m.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades.</span>
|
||||
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades. Heredado del cliente.</span>
|
||||
</label>
|
||||
|
||||
{#if (form.currency ?? 'MXN').toUpperCase() !== 'MXN'}
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tipo de cambio *</span>
|
||||
<input type="number" min="0" step="0.000001" class={inputCls} bind:value={form.exchange_rate} disabled={!!stamp} />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Obligatorio para timbrar cuando la moneda no es MXN: cuántos pesos vale una
|
||||
unidad de {(form.currency ?? '').toUpperCase()}.
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">CP del lugar de expedición</span>
|
||||
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} />
|
||||
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} disabled={!!stamp} />
|
||||
<span class="text-xs text-muted-foreground">Si se deja vacío se usa el del emisor.</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
|
||||
// Sin moneda ni % de impuesto sembrados a propósito: lo que se mande aquí gana sobre la ficha
|
||||
// del cliente, así que un valor por defecto en el formulario anularía la herencia. La moneda
|
||||
// llega del cliente y, si su ficha no la trae, del default de la columna.
|
||||
let form = $state<InvoiceInput>({});
|
||||
let accounts = $state<Account[]>([]);
|
||||
let saving = $state(false);
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const clienteElegido = $derived(accounts.find((a) => a.id === form.account_id) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
@@ -48,11 +52,16 @@
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class="{inputCls} bg-muted/40" bind:value={form.reference} readonly placeholder="Se genera automáticamente (F2026-08-001)" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} — {m.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>Heredar del cliente</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} — {m.label}</option>{/each}</select></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>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
|
||||
</div>
|
||||
{#if clienteElegido}
|
||||
<p class="mt-4 text-xs text-muted-foreground">
|
||||
Forma y método de pago se toman de la ficha de <span class="font-medium">{clienteElegido.name}</span>.
|
||||
Se pueden ajustar después en la factura.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" href="/dashboard/fin/facturas">Cancelar</Button>
|
||||
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
|
||||
|
||||
Reference in New Issue
Block a user