Merge pull request 'feature/AS-herencia-datos-facturacion' (#12) from feature/AS-herencia-datos-facturacion into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 5s
Build Producción & Push a Harbor / build (push) Has been skipped

Reviewed-on: #12
This commit is contained in:
2026-08-11 20:52:54 +00:00
23 changed files with 2175 additions and 153 deletions

View File

@@ -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")

View File

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

View File

@@ -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,

View File

@@ -3,17 +3,62 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from typing import Literal
from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
from ..catalogs.dto import (
ProductServiceResponse,
TaxObjectResponse,
TaxResponse,
UnitOfMeasureResponse,
)
# Configuración fiscal por defecto del concepto: es lo que permite tener conceptos que no
# causan IVA. La tasa va como FRACCIÓN (0.16), igual que en la partida y en el XML, NO como el
# porcentaje de la factura (16.00).
_FISCAL_DEFAULTS = ("default_tax_id", "default_tax_rate", "default_tax_factor")
class ConceptBase(BaseModel):
class _FiscalDefaultsMixin(BaseModel):
"""Valida que la configuración fiscal esté completa o vacía, nunca a medias.
Espeja el CHECK de la base para que el error salga como un 422 legible en vez de un
IntegrityError, y para que el service no tenga que adivinar un estado medio capturado.
"""
@model_validator(mode="after")
def _valida_defaults_fiscales(self):
puestos = {c for c in _FISCAL_DEFAULTS if getattr(self, c, None) is not None}
if not puestos:
return self
if self.default_tax_id is None or self.default_tax_factor is None:
raise ValueError(
"La configuración fiscal del concepto necesita impuesto y tipo de factor"
)
if self.default_tax_factor == "Exento":
if self.default_tax_rate:
raise ValueError("Un concepto exento no lleva tasa")
elif self.default_tax_rate is None:
raise ValueError("Un concepto con factor Tasa necesita su tasa (0 para el 0%)")
return self
class ConceptBase(_FiscalDefaultsMixin):
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
description: str = Field(..., min_length=1, max_length=500)
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str = Field("MXN", min_length=3, max_length=3)
is_active: bool = True
@@ -24,7 +69,7 @@ class ConceptCreate(ConceptBase):
pass
class ConceptUpdate(BaseModel):
class ConceptUpdate(_FiscalDefaultsMixin):
"""Actualización parcial: solo se tocan los campos enviados."""
code: str | None = Field(None, min_length=1, max_length=40)
@@ -32,6 +77,15 @@ class ConceptUpdate(BaseModel):
product_service_id: int | None = None
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, min_length=3, max_length=3)
is_active: bool | None = None
@@ -49,6 +103,7 @@ class ConceptResponse(ConceptBase):
product_service: ProductServiceResponse | None = None
unit_of_measure: UnitOfMeasureResponse | None = None
tax_object: TaxObjectResponse | None = None
default_tax: TaxResponse | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime

View File

@@ -11,13 +11,23 @@ descripción corresponde a la clave, así que la unicidad se garantiza por índi
valida además en el service para devolver un 409 con mensaje entendible.
"""
from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text
from sqlalchemy import (
Boolean,
CheckConstraint,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
from ..catalogs.models import Tax, ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
# lógica libera su clave y su código para uno nuevo.
@@ -39,6 +49,18 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
"tenant_id", "company_id", "product_service_id",
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
),
# Duplicados de la migración a propósito: las pruebas construyen el esquema con
# ``create_all``, así que sin esto validarían una base distinta de la de producción.
CheckConstraint(
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
name="ck_fin_concepts_default_tax_factor",
),
CheckConstraint(
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
name="ck_fin_concepts_default_tax_coherente",
),
{"schema": "fin"},
)
@@ -54,6 +76,19 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
tax_object_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
)
# ── Configuración fiscal por defecto ────────────────────────────────────────────────────
# La partida hereda de aquí su impuesto cuando el concepto lo define, y entonces el % global
# de la factura deja de aplicarle. Es lo que permite tener conceptos que no causan IVA:
# exentos (factor 'Exento') o a tasa 0% (factor 'Tasa' con tasa 0), que fiscalmente NO son lo
# mismo ni entre sí ni que un ObjetoImp 01 «no objeto de impuesto».
default_tax_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.taxes.id"), nullable=True
)
# FRACCIÓN con 6 decimales (0.160000), igual que ``invoice_item_taxes.rate`` y que el
# TasaOCuota del XML. NO es el porcentaje de ``invoices.tax_rate`` (16.00): el tipo coincide
# con el destino justo para que la copia sea trivial y no haya un factor 100 en el camino.
default_tax_rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True)
default_tax_factor: Mapped[str | None] = mapped_column(String(7), nullable=True)
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
@@ -65,3 +100,4 @@ class Concept(Base, TenantScopedMixin, TimestampMixin):
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
default_tax: Mapped["Tax | None"] = relationship("Tax", lazy="selectin")

View File

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

View File

@@ -2,7 +2,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
from pydantic import BaseModel, ConfigDict, Field, model_validator, computed_field
class InvoiceClientReviewInput(BaseModel):
@@ -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

View File

@@ -1,6 +1,18 @@
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -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):

View File

@@ -63,6 +63,8 @@ def _build_lines(
total,
paid,
balance,
tax_groups: Sequence[dict] | None = None,
withheld=0,
bank_info: str | None,
notes: str | None,
) -> list[tuple[str, int]]:
@@ -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)

View File

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

View File

@@ -1,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))

View File

@@ -1,13 +1,24 @@
"""Impuestos de las partidas de la factura.
"""Impuestos de las partidas de la factura — **la fuente del impuesto**, no un detalle.
El CFDI exige el desglose **por partida**, pero la factura ya captura un porcentaje global de
impuesto. Duplicar la captura sería trabajo doble y una fuente de incoherencias, así que el
traslado de IVA se **deriva** de ``invoices.tax_rate`` y se recalcula cuando cambia el importe
o el porcentaje.
El impuesto se declara y se cobra por partida, igual que en el CFDI: ``invoices.tax_amount`` y
``withheld_amount`` son la suma de estas filas, y de ahí sale el total. Antes el dinero salía de
``invoices.tax_rate`` aplicado al subtotal completo, y los dos planos podían divergir — una
partida que no causa IVA cobraba IVA, y una retención capturada dejaba la factura pidiendo un
importe distinto del que declaraba el comprobante.
La derivación no pisa lo capturado a mano: en cuanto alguien ajusta los impuestos de una
partida (una retención, una tasa distinta, un exento), esa partida deja de recalcularse sola.
Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
De dónde sale la tasa de cada partida, en orden:
1. Si la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), no lleva
impuestos. Ni el nodo va en el XML ni el importe suma al total.
2. Si alguien capturó impuestos a mano en esa partida (``is_manual``), no se toca nada.
3. Si su concepto del catálogo trae configuración fiscal, esa manda: es la forma de tener
conceptos exentos o a tasa 0% sin pelear con el % global de la factura.
4. Si no, el traslado de IVA se deriva de ``invoices.tax_rate``.
Los importes se redondean **por renglón** y con ``ROUND_HALF_UP``, no al final y no sobre el
subtotal agregado. Es lo que hace el comprobante: su ``SubTotal`` es la suma de los ``Importe``
ya redondeados de cada concepto, y su ``TotalImpuestosTrasladados`` la suma de los ``Importe`` de
cada traslado. El plano que manda es el XML, y el dinero se le alinea.
"""
from decimal import ROUND_HALF_UP, Decimal
@@ -16,18 +27,43 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..catalogs.models import Tax, TaxObject
from ..concepts.models import Concept
from .models import Invoice, InvoiceItem, InvoiceItemTax
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante.
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante. El 01 «no objeto», el 03
# «objeto no obligado al desglose» y el 04 «objeto que no causa impuesto» NO llevan nodo de
# impuestos en el concepto, así que tampoco generan fila ni suman al total.
_OBJETO_CON_DESGLOSE = {"02"}
# c_Impuesto del IVA.
_IVA = "002"
# c_TipoFactor admitidos al capturar. 'Cuota' se acepta en la base porque el catálogo del SAT lo
# tiene, pero el service lo rechaza: su importe es cuota × cantidad, no base × tasa, y aceptarlo
# sin esa fórmula daría importes plausibles y equivocados.
FACTOR_TASA = "Tasa"
FACTOR_EXENTO = "Exento"
FACTORES_ADMITIDOS = (FACTOR_TASA, FACTOR_EXENTO)
def _cents(value: Decimal) -> Decimal:
def cents(value: Decimal) -> Decimal:
"""Redondea a centavos con la misma regla que el comprobante (``ROUND_HALF_UP``)."""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Alias interno histórico; se conserva para no tocar los llamadores existentes de este módulo.
_cents = cents
def line_base(item: InvoiceItem) -> Decimal:
"""Importe de la partida, ya redondeado.
Es el mismo valor que ``ConceptLine.amount`` del builder, y tiene que salir de una sola
definición: si la factura sumara los productos sin redondear, su subtotal no coincidiría con
la suma de los ``Importe`` del XML y el PAC rechazaría el comprobante.
"""
return cents(Decimal(str(item.quantity or 0)) * Decimal(str(item.unit_amount or 0)))
def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
return (
db.query(InvoiceItemTax)
@@ -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()

View File

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

View File

@@ -220,6 +220,9 @@ def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int)
rate=Decimal(str(t.rate or 0)),
amount=Decimal(str(t.amount or 0)),
is_withholding=bool(t.is_withholding),
# Sin pasar el factor, un exento se timbraría como gravado al 0%: un CFDI
# incorrecto que el PAC acepta y que queda así ante el SAT.
factor=t.factor or "Tasa",
)
)
concepts.append(
@@ -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)

View File

@@ -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}'"

View File

@@ -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):

View File

@@ -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"

View File

@@ -18,17 +18,51 @@ from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
T, C = 1, 1
def test_invoice_totals_with_tax(db):
def test_invoice_totals_con_iva_por_partida(db):
"""Sustituye a ``test_invoice_totals_with_tax``, que era incompatible con el cambio.
El test viejo fijaba los mismos 240 de IVA pero sobre partidas **sin** objeto de impuesto:
pasaba porque el % global se aplicaba al subtotal completo sin mirar si las partidas lo
causaban. Ahora el impuesto sale de las partidas, así que se les captura su ObjetoImp 02 y la
exigencia es idéntica, por la razón correcta.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500), T, C)
obj02 = _obj_imp(db, "02")
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000, tax_object_id=obj02), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500, tax_object_id=obj02), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 240.0 # 16% de 1500
assert float(inv.tax_amount) == 240.0 # 16% de cada partida: 160 + 80
assert float(inv.total) == 1740.0
assert float(inv.balance) == 1740.0
def test_partida_que_no_causa_iva_no_se_lo_cobra(db):
"""El bug que motivó el cambio.
Con la fórmula anterior el % global se aplicaba al subtotal completo, así que una partida no
objeto de impuesto —que en el CFDI va sin nodo de impuestos— igual le cobraba IVA al cliente:
esta misma factura daba 1740 en vez de 1660.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOIVA", tax_rate=Decimal("16")), T, C)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
T, C,
)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=500,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 160.0, "se le cobró IVA a una partida que no lo causa"
assert float(inv.total) == 1660.0
def test_payment_marks_paid(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
@@ -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

View File

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

View File

@@ -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;

View File

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

View File

@@ -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>

View File

@@ -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>