feat(fin): la factura hereda los datos de facturación del cliente

Forma de pago, método de pago y moneda 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. Ni el alta manual ni generate_from_shipment las prellenaban.

- _inherit_account_billing espeja _resolve_item_concept, el patrón de herencia que ya
  usa el módulo: lo explícito manda sobre la ficha, y se completa sin borrar. Si la
  ficha trae texto que no resuelve a una clave del SAT no se asigna nada, así que
  cambiar de cliente nunca vacía un dato ya capturado.
- find_by_code traduce el texto del Account a id de catálogo. Normaliza ('3' -> '03',
  'pue' -> 'PUE') y devuelve None sin lanzar: una ficha mal capturada no puede impedir
  facturar, el faltante lo reporta el timbrado junto al resto.
- Se invoca al crear, al cambiar de cliente (re-herencia) y en generate_from_shipment,
  donde la moneda del embarque gana sobre la de la ficha: es la que se coteó y operó.

Incluye el candado de inmutabilidad con timbre, que la herencia hacía necesario: había
un solo campo protegido (stamping_mode) y todo lo demás de una factura ya timbrada era
editable — cliente, folio, moneda, partidas e impuestos — con lo que la factura y su
CFDI podían contar cosas distintas. _reject_if_stamped generaliza esa guarda sobre una
lista cerrada de campos del comprobante, y sin lista en partidas e impuestos. Cobrar y
anotar siguen permitidos: no alteran el CFDI. send_invoice deja de regenerar el PDF de
una factura timbrada, que reescribía en MinIO el documento que el cliente ya recibió.

Dos cosas que la herencia obligaba a arreglar:

1. currency y tax_rate tenían default no nulo en el DTO y el frontend sembraba
   {currency:'MXN', tax_rate:16}, así que el backend nunca podía distinguir "no lo
   eligió" de "eligió eso" y la herencia habría sido código muerto. Ahora son
   opcionales; un None se retira del payload para que mande el default de la columna.
2. saveHeader mandaba el objeto completo, con lo que al cambiar de cliente el PATCH
   llevaba las claves del cliente anterior. Ahora manda solo el delta.

Se agrega fin.invoices.exchange_rate: heredar una moneda distinta de MXN producía
facturas no timbrables en silencio, porque _build_data pasaba exchange_rate=None
siempre y el validador lo exige. La validación sigue siendo del builder, que acumula
todos los faltantes juntos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:14:56 -05:00
parent 16aca537be
commit dbda0b5755
12 changed files with 671 additions and 41 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

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

@@ -81,10 +81,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 +114,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,6 +134,10 @@ 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
total: Decimal

View File

@@ -37,6 +37,9 @@ 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)

View File

@@ -1,3 +1,4 @@
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
@@ -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,
@@ -24,6 +27,8 @@ from . import taxes_service
from .models import Invoice, InvoiceItem, 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"),
@@ -98,6 +115,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,7 +139,20 @@ 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
@@ -134,27 +166,59 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
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:
@@ -220,10 +284,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 +393,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,
@@ -376,6 +461,68 @@ 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")
# 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.
@@ -414,6 +561,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 +576,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 +599,10 @@ 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",
)
item.deleted_at = datetime.now(timezone.utc)
db.flush()
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))

View File

@@ -44,6 +44,20 @@ def _tax_object_code(db: Session, item: InvoiceItem) -> str:
return row.code if row else ""
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.
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.
"""
from . import service # noqa: PLC0415
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 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.
@@ -140,6 +154,7 @@ def set_item_tax(
) -> InvoiceItemTax:
"""Alta o ajuste de un impuesto de la partida. El importe se calcula de la base y la tasa."""
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")
tax = db.query(Tax).filter(Tax.id == tax_id).first()
if not tax:
raise HTTPException(
@@ -197,5 +212,7 @@ 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.commit()

View File

@@ -248,7 +248,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(),

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

View File

@@ -174,3 +174,242 @@ 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