diff --git a/backend/alembic/versions/l7m8n9o0p1q2_fin_invoice_exchange_rate.py b/backend/alembic/versions/l7m8n9o0p1q2_fin_invoice_exchange_rate.py new file mode 100644 index 0000000..9884239 --- /dev/null +++ b/backend/alembic/versions/l7m8n9o0p1q2_fin_invoice_exchange_rate.py @@ -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") diff --git a/backend/api/v1/modules/fin/catalogs/service.py b/backend/api/v1/modules/fin/catalogs/service.py index 7ea97b4..ed9b926 100644 --- a/backend/api/v1/modules/fin/catalogs/service.py +++ b/backend/api/v1/modules/fin/catalogs/service.py @@ -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, diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 9318b33..743fb36 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -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 diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index 98efd2f..2247665 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -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) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index c897b07..6c75e01 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -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)) diff --git a/backend/api/v1/modules/fin/invoices/taxes_service.py b/backend/api/v1/modules/fin/invoices/taxes_service.py index df3c444..c1c253a 100644 --- a/backend/api/v1/modules/fin/invoices/taxes_service.py +++ b/backend/api/v1/modules/fin/invoices/taxes_service.py @@ -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() diff --git a/backend/api/v1/modules/fin/stamping/service.py b/backend/api/v1/modules/fin/stamping/service.py index 151f978..526a751 100644 --- a/backend/api/v1/modules/fin/stamping/service.py +++ b/backend/api/v1/modules/fin/stamping/service.py @@ -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(), diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index ea5a21a..a8a5cdb 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -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) diff --git a/backend/tests/test_invoices.py b/backend/tests/test_invoices.py index d9ff242..7b7f3cd 100644 --- a/backend/tests/test_invoices.py +++ b/backend/tests/test_invoices.py @@ -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 diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index 0ce03c0..20661fe 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -16,7 +16,10 @@ 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; @@ -38,7 +41,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; diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte index 9ecb072..ff1e101 100644 --- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -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; + const delta: Record = {}; + 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) { @@ -534,36 +557,59 @@ {/if} {:else} + {#if stamp} +

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

+ {/if}
- - - - - + + + + + + validación, antes de llegar al PAC. Se heredan de la ficha del cliente al crear + la factura y al cambiarle el cliente. --> + {#if (form.currency ?? 'MXN').toUpperCase() !== 'MXN'} + + {/if} + diff --git a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte index ce567a7..151d98a 100644 --- a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte @@ -9,10 +9,14 @@ import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ 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({}); let accounts = $state([]); 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 @@
- - +
+ {#if clienteElegido} +

+ Forma y método de pago se toman de la ficha de {clienteElegido.name}. + Se pueden ajustar después en la factura. +

+ {/if}