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:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
@@ -534,36 +557,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">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} disabled={!!stamp} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} disabled={!!stamp} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
|
||||
<!-- Claves fiscales del comprobante. Sin ellas el timbrado se detiene en la
|
||||
validación, antes de llegar al PAC. -->
|
||||
validación, antes de llegar al PAC. Se heredan de la ficha del cliente al crear
|
||||
la factura y al cambiarle el cliente. -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Forma de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_form_id}>
|
||||
<select class={inputCls} bind:value={form.payment_form_id} disabled={!!stamp}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentForms as f (f.id)}<option value={f.id}>{f.code} — {f.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia…</span>
|
||||
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia… Heredada del cliente.</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Método de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_method_id}>
|
||||
<select class={inputCls} bind:value={form.payment_method_id} disabled={!!stamp}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentMethods as m (m.id)}<option value={m.id}>{m.code} — {m.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades.</span>
|
||||
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades. Heredado del cliente.</span>
|
||||
</label>
|
||||
|
||||
{#if (form.currency ?? 'MXN').toUpperCase() !== 'MXN'}
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tipo de cambio *</span>
|
||||
<input type="number" min="0" step="0.000001" class={inputCls} bind:value={form.exchange_rate} disabled={!!stamp} />
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Obligatorio para timbrar cuando la moneda no es MXN: cuántos pesos vale una
|
||||
unidad de {(form.currency ?? '').toUpperCase()}.
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">CP del lugar de expedición</span>
|
||||
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} />
|
||||
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} disabled={!!stamp} />
|
||||
<span class="text-xs text-muted-foreground">Si se deja vacío se usa el del emisor.</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
|
||||
// Sin moneda ni % de impuesto sembrados a propósito: lo que se mande aquí gana sobre la ficha
|
||||
// del cliente, así que un valor por defecto en el formulario anularía la herencia. La moneda
|
||||
// llega del cliente y, si su ficha no la trae, del default de la columna.
|
||||
let form = $state<InvoiceInput>({});
|
||||
let accounts = $state<Account[]>([]);
|
||||
let saving = $state(false);
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const clienteElegido = $derived(accounts.find((a) => a.id === form.account_id) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
@@ -48,11 +52,16 @@
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class="{inputCls} bg-muted/40" bind:value={form.reference} readonly placeholder="Se genera automáticamente (F2026-08-001)" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} — {m.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>Heredar del cliente</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} — {m.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
|
||||
</div>
|
||||
{#if clienteElegido}
|
||||
<p class="mt-4 text-xs text-muted-foreground">
|
||||
Forma y método de pago se toman de la ficha de <span class="font-medium">{clienteElegido.name}</span>.
|
||||
Se pueden ajustar después en la factura.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" href="/dashboard/fin/facturas">Cancelar</Button>
|
||||
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
|
||||
|
||||
Reference in New Issue
Block a user