From e24435c74beea86686b042f96508400aea41672e Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:57:26 -0500 Subject: [PATCH 01/10] =?UTF-8?q?feat(fin):=20cat=C3=A1logos=20SAT=20en=20?= =?UTF-8?q?schema=20sat=20con=20seeds=20idempotentes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agrega los 8 catálogos oficiales del SAT (c_RegimenFiscal, c_Impuesto, c_FormaPago, c_ClaveUnidad, c_ClaveProdServ, c_TipoDeComprobante, c_MetodoPago y c_ObjetoImp) como tablas globales de solo lectura en el schema sat: sin tenant_id, sin CRUD y sin baja física (las claves retiradas se desactivan para no romper los CFDI históricos). Las semillas viven en catalogs/seed_data.py, no dentro de una migración, para que corregir un dato del catálogo no exija escribir una migración nueva. sync_catalogs() hace upsert por clave: inserta lo que falta, actualiza descripción y banderas, y nunca borra. El subset de c_ClaveProdServ (11 claves de logística) queda pendiente de validación con el área Fiscal antes de producción. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/v1/modules/fin/catalogs/__init__.py | 1 + backend/api/v1/modules/fin/catalogs/dto.py | 57 ++++ backend/api/v1/modules/fin/catalogs/models.py | 130 ++++++++ backend/api/v1/modules/fin/catalogs/routes.py | 126 ++++++++ .../api/v1/modules/fin/catalogs/seed_data.py | 279 ++++++++++++++++++ .../api/v1/modules/fin/catalogs/service.py | 101 +++++++ 6 files changed, 694 insertions(+) create mode 100644 backend/api/v1/modules/fin/catalogs/__init__.py create mode 100644 backend/api/v1/modules/fin/catalogs/dto.py create mode 100644 backend/api/v1/modules/fin/catalogs/models.py create mode 100644 backend/api/v1/modules/fin/catalogs/routes.py create mode 100644 backend/api/v1/modules/fin/catalogs/seed_data.py create mode 100644 backend/api/v1/modules/fin/catalogs/service.py diff --git a/backend/api/v1/modules/fin/catalogs/__init__.py b/backend/api/v1/modules/fin/catalogs/__init__.py new file mode 100644 index 0000000..0778b59 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/__init__.py @@ -0,0 +1 @@ +"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura.""" diff --git a/backend/api/v1/modules/fin/catalogs/dto.py b/backend/api/v1/modules/fin/catalogs/dto.py new file mode 100644 index 0000000..0a80605 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/dto.py @@ -0,0 +1,57 @@ +"""Esquemas de respuesta de los catálogos del SAT (solo lectura).""" + +from pydantic import BaseModel, ConfigDict + + +class SatCatalogItem(BaseModel): + """Forma común de todo catálogo del SAT: clave + descripción.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + code: str + description: str + is_active: bool + + +class TaxRegimeResponse(SatCatalogItem): + """``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen.""" + + applies_to_individual: bool # persona física + applies_to_legal_entity: bool # persona moral + + +class TaxResponse(SatCatalogItem): + """``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse.""" + + is_withholding: bool + is_transferred: bool + is_local: bool + + +class UnitOfMeasureResponse(SatCatalogItem): + """``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo.""" + + description: str | None = None + name: str + symbol: str | None = None + + +class PaymentFormResponse(SatCatalogItem): + """``c_FormaPago``.""" + + +class ProductServiceResponse(SatCatalogItem): + """``c_ClaveProdServ``.""" + + +class VoucherTypeResponse(SatCatalogItem): + """``c_TipoDeComprobante``.""" + + +class PaymentMethodResponse(SatCatalogItem): + """``c_MetodoPago``.""" + + +class TaxObjectResponse(SatCatalogItem): + """``c_ObjetoImp``.""" diff --git a/backend/api/v1/modules/fin/catalogs/models.py b/backend/api/v1/modules/fin/catalogs/models.py new file mode 100644 index 0000000..3c19338 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/models.py @@ -0,0 +1,130 @@ +"""Modelos de los catálogos oficiales del SAT — schema ``sat``. + +Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y +compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT +retira una clave, el registro se marca ``is_active = false`` para que las facturas +históricas que la usan sigan resolviendo su descripción (de ahí que se use +``BaseTimestampMixin``, sin ``deleted_at``). + +La API los expone únicamente en modo lectura; el alta y la actualización pasan por +``seed_data.sync_catalogs()``. +""" + +from sqlalchemy import Boolean, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import BaseTimestampMixin +from core.database import Base + + +class SatCatalogMixin(BaseTimestampMixin): + """Campos comunes a todo catálogo del SAT. + + ``code`` (la clave oficial) se declara en cada modelo porque su longitud + cambia de catálogo en catálogo. + """ + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + description: Mapped[str] = mapped_column(String(500), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + + +class TaxRegime(Base, SatCatalogMixin): + """``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI. + + Las banderas indican a qué tipo de persona aplica el régimen: una persona física + no puede declararse en el 601 (General de Ley Personas Morales) y viceversa. + """ + + __tablename__ = "tax_regimes" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + applies_to_individual: Mapped[bool] = mapped_column( # persona física + Boolean, nullable=False, server_default=text("false") + ) + applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral + Boolean, nullable=False, server_default=text("false") + ) + + +class Tax(Base, SatCatalogMixin): + """``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse.""" + + __tablename__ = "taxes" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + is_withholding: Mapped[bool] = mapped_column( # puede retenerse + Boolean, nullable=False, server_default=text("false") + ) + is_transferred: Mapped[bool] = mapped_column( # puede trasladarse + Boolean, nullable=False, server_default=text("false") + ) + # Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos + # Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para + # cuando el negocio defina ese catálogo. + is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + + +class PaymentForm(Base, SatCatalogMixin): + """``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…).""" + + __tablename__ = "payment_forms" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True) + + +class UnitOfMeasure(Base, SatCatalogMixin): + """``c_ClaveUnidad`` — unidad de medida de la partida. + + Único catálogo que separa nombre corto y definición: ``name`` es lo que se + muestra al capturar y ``description`` la nota larga del SAT, que puede venir + vacía. + """ + + __tablename__ = "units_of_measure" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + symbol: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo. + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + + +class ProductService(Base, SatCatalogMixin): + """``c_ClaveProdServ`` — clave de producto o servicio de la partida.""" + + __tablename__ = "products_services" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True) + + +class VoucherType(Base, SatCatalogMixin): + """``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago.""" + + __tablename__ = "voucher_types" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True) + + +class PaymentMethod(Base, SatCatalogMixin): + """``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido).""" + + __tablename__ = "payment_methods" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True) + + +class TaxObject(Base, SatCatalogMixin): + """``c_ObjetoImp`` — si la partida es o no objeto de impuesto.""" + + __tablename__ = "tax_objects" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True) diff --git a/backend/api/v1/modules/fin/catalogs/routes.py b/backend/api/v1/modules/fin/catalogs/routes.py new file mode 100644 index 0000000..8396ab8 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/routes.py @@ -0,0 +1,126 @@ +"""Endpoints de los catálogos del SAT — **solo lectura**. + +No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por +el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API. + +Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``, +permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando +``company_id`` en la query string. +""" + +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import ( + PaymentFormResponse, + PaymentMethodResponse, + ProductServiceResponse, + TaxObjectResponse, + TaxRegimeResponse, + TaxResponse, + UnitOfMeasureResponse, + VoucherTypeResponse, +) + +router = APIRouter() + +_SEARCH = Query(None, description="Búsqueda por clave o descripción") +_ACTIVE_ONLY = Query(True, description="Solo claves vigentes") + + +@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse]) +def list_tax_regimes( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + person_type: Literal["fisica", "moral"] | None = Query( + None, description="Acota al régimen de persona física o moral" + ), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI.""" + return service.get_tax_regimes(db, search, active_only, person_type) + + +@router.get("/catalogs/taxes", response_model=list[TaxResponse]) +def list_taxes( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_Impuesto`` — impuestos federales trasladados y retenidos.""" + return service.get_taxes(db, search, active_only) + + +@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse]) +def list_payment_forms( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_FormaPago`` — medio con el que se liquidó el comprobante.""" + return service.get_payment_forms(db, search, active_only) + + +@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse]) +def list_units_of_measure( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ClaveUnidad`` — unidad de medida de la partida.""" + return service.get_units_of_measure(db, search, active_only) + + +@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse]) +def list_products_services( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado.""" + return service.get_products_services(db, search, active_only, limit) + + +@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse]) +def list_voucher_types( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago.""" + return service.get_voucher_types(db, search, active_only) + + +@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse]) +def list_payment_methods( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_MetodoPago`` — PUE o PPD.""" + return service.get_payment_methods(db, search, active_only) + + +@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse]) +def list_tax_objects( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_ObjetoImp`` — si la partida es objeto de impuesto.""" + return service.get_tax_objects(db, search, active_only) diff --git a/backend/api/v1/modules/fin/catalogs/seed_data.py b/backend/api/v1/modules/fin/catalogs/seed_data.py new file mode 100644 index 0000000..60ebe35 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/seed_data.py @@ -0,0 +1,279 @@ +"""Datos semilla de los catálogos del SAT y su sincronización idempotente. + +Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el +SAT corrige una descripción o publica una clave nueva, basta editar estas listas y +volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema. + +Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con +los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la +definición ORM, que sigue evolucionando. +""" + +import sqlalchemy as sa + +_metadata = sa.MetaData() + + +def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table: + """Tabla mínima de catálogo: las columnas que toca el upsert, nada más.""" + return sa.Table( + name, + _metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("code", sa.String, nullable=False), + sa.Column("description", sa.String), + sa.Column("is_active", sa.Boolean), + *extra_columns, + schema="sat", + ) + + +tax_regimes_table = _catalog_table( + "tax_regimes", + sa.Column("applies_to_individual", sa.Boolean), + sa.Column("applies_to_legal_entity", sa.Boolean), +) +taxes_table = _catalog_table( + "taxes", + sa.Column("is_withholding", sa.Boolean), + sa.Column("is_transferred", sa.Boolean), + sa.Column("is_local", sa.Boolean), +) +payment_forms_table = _catalog_table("payment_forms") +units_of_measure_table = _catalog_table( + "units_of_measure", + sa.Column("name", sa.String), + sa.Column("symbol", sa.String), +) +products_services_table = _catalog_table("products_services") +voucher_types_table = _catalog_table("voucher_types") +payment_methods_table = _catalog_table("payment_methods") +tax_objects_table = _catalog_table("tax_objects") + + +# --------------------------------------------------------------------------- +# c_RegimenFiscal (CFDI 4.0) +# --------------------------------------------------------------------------- + +def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict: + return { + "code": code, + "description": description, + "applies_to_individual": individual, + "applies_to_legal_entity": legal_entity, + "is_active": True, + } + + +TAX_REGIMES: list[dict] = [ + _regime("601", "General de Ley Personas Morales", False, True), + _regime("603", "Personas Morales con Fines no Lucrativos", False, True), + _regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False), + _regime("606", "Arrendamiento", True, False), + _regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False), + _regime("608", "Demás ingresos", True, False), + _regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True), + _regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False), + _regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False), + _regime("614", "Ingresos por intereses", True, False), + _regime("615", "Régimen de los ingresos por obtención de premios", True, False), + _regime("616", "Sin obligaciones fiscales", True, False), + _regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True), + _regime("621", "Incorporación Fiscal", True, False), + _regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True), + _regime("623", "Opcional para Grupos de Sociedades", False, True), + _regime("624", "Coordinados", False, True), + _regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False), + _regime("626", "Régimen Simplificado de Confianza", True, True), +] + + +# --------------------------------------------------------------------------- +# c_Impuesto +# --------------------------------------------------------------------------- +# is_local queda en false para los tres: los impuestos locales (ISH y similares) +# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen +# a c_Impuesto. No se siembran registros locales inventados. + +TAXES: list[dict] = [ + {"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True}, + {"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True}, + {"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_FormaPago +# --------------------------------------------------------------------------- + +PAYMENT_FORMS: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("01", "Efectivo"), + ("02", "Cheque nominativo"), + ("03", "Transferencia electrónica de fondos"), + ("04", "Tarjeta de crédito"), + ("05", "Monedero electrónico"), + ("06", "Dinero electrónico"), + ("08", "Vales de despensa"), + ("12", "Dación en pago"), + ("13", "Pago por subrogación"), + ("14", "Pago por consignación"), + ("15", "Condonación"), + ("17", "Compensación"), + ("23", "Novación"), + ("24", "Confusión"), + ("25", "Remisión de deuda"), + ("26", "Prescripción o caducidad"), + ("27", "A satisfacción del acreedor"), + ("28", "Tarjeta de débito"), + ("29", "Tarjeta de servicios"), + ("30", "Aplicación de anticipos"), + ("31", "Intermediario pagos"), + ("99", "Por definir"), + ] +] + + +# --------------------------------------------------------------------------- +# c_TipoDeComprobante +# --------------------------------------------------------------------------- + +VOUCHER_TYPES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("I", "Ingreso"), + ("E", "Egreso"), + ("T", "Traslado"), + ("N", "Nómina"), + ("P", "Pago"), + ] +] + + +# --------------------------------------------------------------------------- +# c_MetodoPago +# --------------------------------------------------------------------------- + +PAYMENT_METHODS: list[dict] = [ + {"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True}, + {"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_ObjetoImp +# --------------------------------------------------------------------------- +# Versiones posteriores del catálogo incorporan las claves 05–07; no se siembran +# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN). + +TAX_OBJECTS: list[dict] = [ + {"code": "01", "description": "No objeto de impuesto", "is_active": True}, + {"code": "02", "description": "Sí objeto de impuesto", "is_active": True}, + {"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True}, + {"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True}, +] + + +# --------------------------------------------------------------------------- +# c_ClaveUnidad — subset operativo +# --------------------------------------------------------------------------- +# description queda en NULL: es la nota larga del catálogo, que aquí no aporta. + +UNITS_OF_MEASURE: list[dict] = [ + {"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True} + for code, name, symbol in [ + ("H87", "Pieza", "pz"), + ("E48", "Unidad de servicio", None), + ("ACT", "Actividad", None), + ("C62", "Uno", None), + ("KGM", "Kilogramo", "kg"), + ("TNE", "Tonelada métrica", "t"), + ("GRM", "Gramo", "g"), + ("LTR", "Litro", "l"), + ("MTR", "Metro", "m"), + ("MTK", "Metro cuadrado", "m²"), + ("MTQ", "Metro cúbico", "m³"), + ("KMT", "Kilómetro", "km"), + ("CMT", "Centímetro", "cm"), + ("DAY", "Día", "d"), + ("HUR", "Hora", "h"), + ("MON", "Mes", None), + ("XBX", "Caja", None), + ("XPK", "Paquete", None), + ("XPX", "Paleta / tarima", None), + ("XLT", "Lote", None), + ("E51", "Trabajo", None), + ] +] + + +# --------------------------------------------------------------------------- +# c_ClaveProdServ — subset de logística +# --------------------------------------------------------------------------- +# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con +# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo +# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como +# PENDIENTE DECISIÓN: no se deduce ni se inventa. + +PRODUCTS_SERVICES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("78101500", "Transporte de carga por carretera"), + ("78101600", "Transporte de carga marítimo"), + ("78101700", "Transporte de carga por ferrocarril"), + ("78101800", "Transporte de carga aérea"), + ("78102200", "Servicios postales de paqueteo y courrier"), + ("78121600", "Embalaje"), + ("78131600", "Almacenaje"), + ("78141500", "Servicios de planificación logística"), + ("78141600", "Servicios de expedición de fletes"), + ("84131500", "Seguros de vida, salud y accidentes / seguros de carga"), + ("80101500", "Servicios de consultoría de negocios y administración corporativa"), + ] +] + + +# Orden estable de sincronización: (tabla, filas). +CATALOGS: list[tuple[sa.Table, list[dict]]] = [ + (tax_regimes_table, TAX_REGIMES), + (taxes_table, TAXES), + (payment_forms_table, PAYMENT_FORMS), + (units_of_measure_table, UNITS_OF_MEASURE), + (products_services_table, PRODUCTS_SERVICES), + (voucher_types_table, VOUCHER_TYPES), + (payment_methods_table, PAYMENT_METHODS), + (tax_objects_table, TAX_OBJECTS), +] + + +def sync_catalogs(connection) -> dict[str, int]: + """Sincroniza los catálogos del SAT contra la base, de forma idempotente. + + Inserta las claves que faltan y actualiza descripción y banderas de las que ya + existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para + no romper los CFDI históricos que la referencian. + + Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de + la migración. + + Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la + conexión de una sesión en pruebas. + """ + inserted: dict[str, int] = {} + for table, rows in CATALOGS: + key = f"sat.{table.name}" + inserted[key] = 0 + for row in rows: + existing = connection.execute( + sa.select(table.c.id).where(table.c.code == row["code"]) + ).scalar() + values = {k: v for k, v in row.items() if k != "code"} + if existing is None: + connection.execute(table.insert().values(code=row["code"], **values)) + inserted[key] += 1 + else: + connection.execute( + table.update().where(table.c.id == existing).values(**values) + ) + return inserted diff --git a/backend/api/v1/modules/fin/catalogs/service.py b/backend/api/v1/modules/fin/catalogs/service.py new file mode 100644 index 0000000..6393fe0 --- /dev/null +++ b/backend/api/v1/modules/fin/catalogs/service.py @@ -0,0 +1,101 @@ +"""Consultas de los catálogos del SAT. + +Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas, +cambios ni bajas, únicamente búsqueda para llenar los selectores de captura. +""" + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from .models import ( + PaymentForm, + PaymentMethod, + ProductService, + Tax, + TaxObject, + TaxRegime, + UnitOfMeasure, + VoucherType, +) + +# Catálogos que además del código y la descripción buscan por nombre corto. +_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)} + + +def search_catalog( + db: Session, + model, + search: str | None = None, + active_only: bool = True, + limit: int | None = None, +) -> list: + """Devuelve las claves de un catálogo, filtradas por texto libre. + + ``search`` compara contra la clave o la descripción sin distinguir mayúsculas. + """ + q = db.query(model) + if active_only: + q = q.filter(model.is_active.is_(True)) + if search: + term = f"%{search.strip()}%" + fields = [model.code, model.description] + for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()): + fields.append(getattr(model, extra)) + q = q.filter(or_(*[f.ilike(term) for f in fields])) + q = q.order_by(model.code.asc()) + if limit is not None: + q = q.limit(limit) + return q.all() + + +def get_tax_regimes( + db: Session, + search: str | None = None, + active_only: bool = True, + person_type: str | None = None, +) -> list[TaxRegime]: + """``c_RegimenFiscal``, opcionalmente acotado al tipo de persona. + + ``person_type='fisica'`` deja solo los regímenes que puede usar una persona + física; ``'moral'``, los de persona moral. + """ + q = db.query(TaxRegime) + if active_only: + q = q.filter(TaxRegime.is_active.is_(True)) + if search: + term = f"%{search.strip()}%" + q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term))) + if person_type == "fisica": + q = q.filter(TaxRegime.applies_to_individual.is_(True)) + elif person_type == "moral": + q = q.filter(TaxRegime.applies_to_legal_entity.is_(True)) + return q.order_by(TaxRegime.code.asc()).all() + + +def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]: + return search_catalog(db, Tax, search, active_only) + + +def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]: + return search_catalog(db, PaymentForm, search, active_only) + + +def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]: + return search_catalog(db, UnitOfMeasure, search, active_only) + + +def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]: + """``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado.""" + return search_catalog(db, ProductService, search, active_only, limit=limit) + + +def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]: + return search_catalog(db, VoucherType, search, active_only) + + +def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]: + return search_catalog(db, PaymentMethod, search, active_only) + + +def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]: + return search_catalog(db, TaxObject, search, active_only) From 9cf142add6669a167bc06b55c06e9bad47798f7e Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:57:39 -0500 Subject: [PATCH 02/10] =?UTF-8?q?feat(fin):=20CRUD=20de=20conceptos=20con?= =?UTF-8?q?=20relaci=C3=B3n=201:1=20a=20clave=20ProdServ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fin.concepts es el catálogo de conceptos facturables de cada empresa, ligado a una clave de producto/servicio del SAT. La relación es 1:1 por empresa: si dos conceptos compartieran la misma clave, al timbrar no habría forma de saber qué descripción corresponde. La unicidad se garantiza por índice único parcial (WHERE deleted_at IS NULL) y se valida además en el service para devolver 409 con mensaje en español en vez de un IntegrityError crudo. La baja lógica libera la clave y el código. Las respuestas traen los objetos del catálogo ya resueltos (selectin) para que el frontend no dispare N+1. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/v1/modules/fin/concepts/__init__.py | 1 + backend/api/v1/modules/fin/concepts/dto.py | 55 +++++++ backend/api/v1/modules/fin/concepts/models.py | 67 ++++++++ backend/api/v1/modules/fin/concepts/routes.py | 96 ++++++++++++ .../api/v1/modules/fin/concepts/service.py | 146 ++++++++++++++++++ 5 files changed, 365 insertions(+) create mode 100644 backend/api/v1/modules/fin/concepts/__init__.py create mode 100644 backend/api/v1/modules/fin/concepts/dto.py create mode 100644 backend/api/v1/modules/fin/concepts/models.py create mode 100644 backend/api/v1/modules/fin/concepts/routes.py create mode 100644 backend/api/v1/modules/fin/concepts/service.py diff --git a/backend/api/v1/modules/fin/concepts/__init__.py b/backend/api/v1/modules/fin/concepts/__init__.py new file mode 100644 index 0000000..6fd4a40 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/__init__.py @@ -0,0 +1 @@ +"""Catálogo de conceptos de facturación por empresa.""" diff --git a/backend/api/v1/modules/fin/concepts/dto.py b/backend/api/v1/modules/fin/concepts/dto.py new file mode 100644 index 0000000..49f9a62 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/dto.py @@ -0,0 +1,55 @@ +"""Esquemas del catálogo de conceptos de facturación.""" + +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse + + +class ConceptBase(BaseModel): + code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto") + description: str = Field(..., min_length=1, max_length=500) + product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)") + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str = Field("MXN", min_length=3, max_length=3) + is_active: bool = True + notes: str | None = None + + +class ConceptCreate(ConceptBase): + pass + + +class ConceptUpdate(BaseModel): + """Actualización parcial: solo se tocan los campos enviados.""" + + code: str | None = Field(None, min_length=1, max_length=40) + description: str | None = Field(None, min_length=1, max_length=500) + product_service_id: int | None = None + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + currency: str | None = Field(None, min_length=3, max_length=3) + is_active: bool | None = None + notes: str | None = None + + +class ConceptResponse(ConceptBase): + """Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + product_service: ProductServiceResponse | None = None + unit_of_measure: UnitOfMeasureResponse | None = None + tax_object: TaxObjectResponse | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/fin/concepts/models.py b/backend/api/v1/modules/fin/concepts/models.py new file mode 100644 index 0000000..96838b5 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/models.py @@ -0,0 +1,67 @@ +"""Catálogo de conceptos de facturación — ``fin.concepts``. + +A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada +concepto que la empresa factura (flete internacional, despacho, almacenaje…) se +registra una vez y queda amarrado a la clave de producto/servicio del SAT que le +corresponde. + +La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos +compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál +descripción corresponde a la clave, así que la unicidad se garantiza por índice y se +valida además en el service para devolver un 409 con mensaje entendible. +""" + +from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones) + +# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja +# lógica libera su clave y su código para uno nuevo. +_ALIVE = text("deleted_at IS NULL") + + +class Concept(Base, TenantScopedMixin, TimestampMixin): + """Concepto facturable de una empresa, ligado a una clave ProdServ del SAT.""" + + __tablename__ = "concepts" + __table_args__ = ( + Index( + "uq_fin_concepts_code", + "tenant_id", "company_id", "code", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + Index( + "uq_fin_concepts_product_service", + "tenant_id", "company_id", "product_service_id", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto + description: Mapped[str] = mapped_column(String(500), nullable=False) + product_service_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True + ) + unit_of_measure_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.units_of_measure.id"), nullable=True + ) + tax_object_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_objects.id"), nullable=True + ) + unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) + currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'")) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # Cargadas con selectinload para que el listado no dispare N+1 consultas. + product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin") + unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin") + tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin") diff --git a/backend/api/v1/modules/fin/concepts/routes.py b/backend/api/v1/modules/fin/concepts/routes.py new file mode 100644 index 0000000..cce3e45 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/routes.py @@ -0,0 +1,96 @@ +"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa).""" + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from api.v1.modules.core.permissions.dependencies import PermissionChecker +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import ConceptCreate, ConceptResponse, ConceptUpdate + +router = APIRouter() + + +def _uid(current_user: dict) -> str | None: + return current_user.get("sub") or current_user.get("id") + + +@router.get( + "/concepts", + response_model=list[ConceptResponse], + dependencies=[Depends(PermissionChecker(["fin.concept.view"]))], +) +def list_concepts( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None, description="Búsqueda por clave o descripción"), + active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"), + product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_concepts( + db, current_user["tenant_id"], company_id, search, active_only, product_service_id + ) + + +@router.get( + "/concepts/{concept_id}", + response_model=ConceptResponse, + dependencies=[Depends(PermissionChecker(["fin.concept.view"]))], +) +def get_concept( + concept_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_concept(db, concept_id, current_user["tenant_id"], company_id) + + +@router.post( + "/concepts", + response_model=ConceptResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(PermissionChecker(["fin.concept.create"]))], +) +def create_concept( + payload: ConceptCreate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user)) + + +@router.patch( + "/concepts/{concept_id}", + response_model=ConceptResponse, + dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))], +) +def update_concept( + concept_id: int, + payload: ConceptUpdate, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.update_concept( + db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user) + ) + + +@router.delete( + "/concepts/{concept_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))], +) +def delete_concept( + concept_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Baja lógica del concepto (``deleted_at``).""" + service.delete_concept(db, concept_id, current_user["tenant_id"], company_id) diff --git a/backend/api/v1/modules/fin/concepts/service.py b/backend/api/v1/modules/fin/concepts/service.py new file mode 100644 index 0000000..c3aeb09 --- /dev/null +++ b/backend/api/v1/modules/fin/concepts/service.py @@ -0,0 +1,146 @@ +"""Lógica del catálogo de conceptos de facturación. + +Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``: +el catálogo es privado de cada empresa dentro de cada tenant. +""" + +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure +from .dto import ConceptCreate, ConceptUpdate +from .models import Concept + + +def _check_sat_refs(db: Session, data: dict) -> None: + """Verifica que las claves del SAT referidas existan antes de guardar.""" + for field, model, msg in [ + ("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"), + ("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"), + ("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"), + ]: + value = data.get(field) + if field in data and value is not None: + if db.query(model.id).filter(model.id == value).first() is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) + + +def _check_unique( + db: Session, + tenant_id: int, + company_id: int, + code: str | None, + product_service_id: int | None, + exclude_id: int | None = None, +) -> None: + """Aplica en el service las mismas reglas que los índices únicos parciales. + + Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se + traduce a un 409 con mensaje en español. + """ + base = db.query(Concept).filter( + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ) + if exclude_id is not None: + base = base.filter(Concept.id != exclude_id) + + if code is not None and base.filter(Concept.code == code).first() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe un concepto con la clave '{code}' en esta empresa", + ) + # Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa. + if product_service_id is not None and base.filter( + Concept.product_service_id == product_service_id + ).first() is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa", + ) + + +def get_concepts( + db: Session, + tenant_id: int, + company_id: int, + search: str | None = None, + active_only: bool | None = None, + product_service_id: int | None = None, +) -> list[Concept]: + q = db.query(Concept).filter( + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ) + if active_only is not None: + q = q.filter(Concept.is_active.is_(active_only)) + if product_service_id is not None: + q = q.filter(Concept.product_service_id == product_service_id) + if search: + term = f"%{search.strip()}%" + q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term))) + return q.order_by(Concept.code.asc()).all() + + +def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept: + obj = db.query(Concept).filter( + Concept.id == concept_id, + Concept.tenant_id == tenant_id, + Concept.company_id == company_id, + Concept.deleted_at.is_(None), + ).first() + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") + return obj + + +def create_concept( + db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None +) -> Concept: + data = payload.model_dump() + _check_sat_refs(db, data) + _check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"]) + obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_concept( + db: Session, + concept_id: int, + payload: ConceptUpdate, + tenant_id: int, + company_id: int, + user_id: str | None = None, +) -> Concept: + obj = get_concept(db, concept_id, tenant_id, company_id) + data = payload.model_dump(exclude_unset=True) + _check_sat_refs(db, data) + _check_unique( + db, + tenant_id, + company_id, + data.get("code"), + data.get("product_service_id"), + exclude_id=obj.id, + ) + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj + + +def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None: + """Baja lógica: libera la clave ProdServ y el código para un concepto nuevo.""" + obj = get_concept(db, concept_id, tenant_id, company_id) + obj.deleted_at = datetime.now(timezone.utc) + db.commit() From b8b8311ece04c91f1e12c2e37459a9712e86386e Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:57:39 -0500 Subject: [PATCH 03/10] feat(fin): datos fiscales del emisor por empresa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fin.issuer_settings guarda la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen fiscal y CP del lugar de expedición. Una sola configuración vigente por empresa, garantizada con índice único parcial; el guardado es un upsert (GET + PUT, sin DELETE). El RFC se valida con la expresión oficial y se normaliza a mayúsculas sin espacios antes de aplicar la restricción de longitud. Co-Authored-By: Claude Opus 5 (1M context) --- backend/api/v1/modules/fin/issuer/__init__.py | 1 + backend/api/v1/modules/fin/issuer/dto.py | 60 +++++++++++++++++++ backend/api/v1/modules/fin/issuer/models.py | 42 +++++++++++++ backend/api/v1/modules/fin/issuer/routes.py | 48 +++++++++++++++ backend/api/v1/modules/fin/issuer/service.py | 58 ++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 backend/api/v1/modules/fin/issuer/__init__.py create mode 100644 backend/api/v1/modules/fin/issuer/dto.py create mode 100644 backend/api/v1/modules/fin/issuer/models.py create mode 100644 backend/api/v1/modules/fin/issuer/routes.py create mode 100644 backend/api/v1/modules/fin/issuer/service.py diff --git a/backend/api/v1/modules/fin/issuer/__init__.py b/backend/api/v1/modules/fin/issuer/__init__.py new file mode 100644 index 0000000..83dc841 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/__init__.py @@ -0,0 +1 @@ +"""Datos fiscales del emisor por empresa.""" diff --git a/backend/api/v1/modules/fin/issuer/dto.py b/backend/api/v1/modules/fin/issuer/dto.py new file mode 100644 index 0000000..9f8df25 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/dto.py @@ -0,0 +1,60 @@ +"""Esquemas de los datos fiscales del emisor.""" + +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ..catalogs.dto import TaxRegimeResponse + +# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. +RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$") +ZIP_PATTERN = re.compile(r"^\d{5}$") + + +class IssuerSettingsInput(BaseModel): + """Alta o actualización de los datos fiscales del emisor.""" + + legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social") + rfc: str = Field(..., max_length=13, description="RFC del emisor") + tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)") + zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición") + + # mode="before": la normalización corre antes que el max_length del campo, para que + # un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo. + @field_validator("rfc", mode="before") + @classmethod + def _validate_rfc(cls, value: str) -> str: + """Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC.""" + if not isinstance(value, str): + raise ValueError("El RFC debe ser texto") + normalized = value.replace(" ", "").replace("-", "").upper() + if not RFC_PATTERN.match(normalized): + raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)") + return normalized + + @field_validator("zip_code") + @classmethod + def _validate_zip(cls, value: str | None) -> str | None: + if value is None or value == "": + return None + normalized = value.strip() + if not ZIP_PATTERN.match(normalized): + raise ValueError("El código postal debe tener 5 dígitos") + return normalized + + +class IssuerSettingsResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + tenant_id: int + company_id: int + legal_name: str + rfc: str + tax_regime_id: int + tax_regime: TaxRegimeResponse | None = None + zip_code: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/api/v1/modules/fin/issuer/models.py b/backend/api/v1/modules/fin/issuer/models.py new file mode 100644 index 0000000..85eb025 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/models.py @@ -0,0 +1,42 @@ +"""Datos fiscales del emisor — ``fin.issuer_settings``. + +Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen +fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente +por empresa**, garantizada con un índice único parcial. +""" + +from sqlalchemy import ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + +from ..catalogs.models import TaxRegime # noqa: F401 (resuelve la relación) + +_ALIVE = text("deleted_at IS NULL") + + +class IssuerSettings(Base, TenantScopedMixin, TimestampMixin): + """Configuración fiscal del emisor de la empresa.""" + + __tablename__ = "issuer_settings" + __table_args__ = ( + Index( + "uq_fin_issuer_settings_company", + "tenant_id", "company_id", + unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE, + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social + rfc: Mapped[str] = mapped_column(String(13), nullable=False) + tax_regime_id: Mapped[int] = mapped_column( + Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True + ) + # CP del lugar de expedición del comprobante + zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + + tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin") diff --git a/backend/api/v1/modules/fin/issuer/routes.py b/backend/api/v1/modules/fin/issuer/routes.py new file mode 100644 index 0000000..3f60490 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/routes.py @@ -0,0 +1,48 @@ +"""Endpoints de los datos fiscales del emisor (una configuración por empresa).""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from api.v1.modules.core.permissions.dependencies import PermissionChecker +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import IssuerSettingsInput, IssuerSettingsResponse + +router = APIRouter() + + +@router.get( + "/settings/issuer", + response_model=IssuerSettingsResponse, + dependencies=[Depends(PermissionChecker(["fin.settings.view"]))], +) +def get_issuer_settings( + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Devuelve 404 mientras la empresa no haya capturado sus datos fiscales.""" + return service.get_issuer_settings(db, current_user["tenant_id"], company_id) + + +@router.put( + "/settings/issuer", + response_model=IssuerSettingsResponse, + dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))], +) +def save_issuer_settings( + payload: IssuerSettingsInput, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Alta o actualización (upsert) de los datos fiscales del emisor.""" + return service.save_issuer_settings( + db, + payload, + current_user["tenant_id"], + company_id, + current_user.get("sub") or current_user.get("id"), + ) diff --git a/backend/api/v1/modules/fin/issuer/service.py b/backend/api/v1/modules/fin/issuer/service.py new file mode 100644 index 0000000..bc170d9 --- /dev/null +++ b/backend/api/v1/modules/fin/issuer/service.py @@ -0,0 +1,58 @@ +"""Lógica de los datos fiscales del emisor. + +Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no +un alta que pueda duplicar filas. +""" + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from ..catalogs.models import TaxRegime +from .dto import IssuerSettingsInput +from .models import IssuerSettings + + +def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None: + return db.query(IssuerSettings).filter( + IssuerSettings.tenant_id == tenant_id, + IssuerSettings.company_id == company_id, + IssuerSettings.deleted_at.is_(None), + ).first() + + +def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings: + obj = _find(db, tenant_id, company_id) + if not obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="La empresa aún no tiene datos fiscales del emisor configurados", + ) + return obj + + +def save_issuer_settings( + db: Session, + payload: IssuerSettingsInput, + tenant_id: int, + company_id: int, + user_id: str | None = None, +) -> IssuerSettings: + """Crea la configuración la primera vez y la actualiza en adelante.""" + if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="El régimen fiscal indicado no existe en el catálogo del SAT", + ) + + obj = _find(db, tenant_id, company_id) + data = payload.model_dump() + if obj is None: + obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id) + db.add(obj) + else: + for field, value in data.items(): + setattr(obj, field, value) + obj.updated_by = user_id + db.commit() + db.refresh(obj) + return obj From 8d9db3505d4abe6f6f4cc9306365e0d0c9e4447e Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:57:51 -0500 Subject: [PATCH 04/10] =?UTF-8?q?feat(fin):=20amarre=20de=20facturas=20y?= =?UTF-8?q?=20partidas=20a=20cat=C3=A1logos=20SAT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fin.invoices gana tipo de comprobante, forma y método de pago y CP de expedición; fin.invoice_items gana concepto de catálogo y las claves ProdServ, unidad y objeto de impuesto. Todas nullable: las facturas ya emitidas no las tienen y siguen funcionando igual (listado, detalle, PDF, envío). La columna de texto libre invoice_items.concept se conserva obligatoria porque la consume el PDF actual; al capturar por catálogo, el service hereda ahí la descripción del concepto cuando el cliente no la envía. Nueva tabla fin.invoice_item_taxes para el detalle de impuestos trasladados y retenidos por partida. No interviene en el cálculo de subtotal/IVA/total, que sigue saliendo de invoices.tax_rate. Incluye la migración e6f7a8b9c0d1 (crea el schema sat, siembra los catálogos con sync_catalogs y monta las tablas e índices nuevos) y registra los permisos fin.concept.* y fin.settings.{view,edit}. Co-Authored-By: Claude Opus 5 (1M context) --- ...b9c0d1_fin_sat_catalogs_concepts_issuer.py | 269 ++++++++++++++++++ backend/api/v1/modules/fin/invoices/dto.py | 24 +- backend/api/v1/modules/fin/invoices/models.py | 69 ++++- .../api/v1/modules/fin/invoices/service.py | 32 ++- backend/api/v1/modules/fin/permissions.py | 7 +- backend/api/v1/modules/fin/router.py | 6 + 6 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py diff --git a/backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py b/backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py new file mode 100644 index 0000000..edeac6f --- /dev/null +++ b/backend/alembic/versions/e6f7a8b9c0d1_fin_sat_catalogs_concepts_issuer.py @@ -0,0 +1,269 @@ +"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor +y amarre de facturas y partidas a los catálogos. + +Revision ID: e6f7a8b9c0d1 +Revises: d5e6f7a8b9c0 +Create Date: 2026-08-07 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs + +revision: str = "e6f7a8b9c0d1" +down_revision: Union[str, None] = "d5e6f7a8b9c0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Índices únicos parciales: la baja lógica (deleted_at) libera la clave. +_ALIVE = "deleted_at IS NULL" + +# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo). +_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [ + ("tax_regimes", 3, [ + sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ]), + ("taxes", 3, [ + sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ]), + ("payment_forms", 2, []), + ("units_of_measure", 20, [ + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("symbol", sa.String(length=20), nullable=True), + ]), + ("products_services", 8, []), + ("voucher_types", 1, []), + ("payment_methods", 3, []), + ("tax_objects", 2, []), +] + +# units_of_measure guarda el nombre corto aparte, así que su description es opcional. +_NULLABLE_DESCRIPTION = {"units_of_measure"} + + +def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]: + columns = [ + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + ] + if with_soft_delete: + columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True)) + return columns + + +def upgrade() -> None: + # ---------- Schema y catálogos globales del SAT ---------- + op.execute("CREATE SCHEMA IF NOT EXISTS sat") + + for table, code_length, extra_columns in _SAT_CATALOGS: + op.create_table( + table, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=code_length), nullable=False), + sa.Column( + "description", + sa.String(length=500), + nullable=table in _NULLABLE_DESCRIPTION, + ), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + *extra_columns, + *_timestamp_columns(with_soft_delete=False), + sa.PrimaryKeyConstraint("id"), + schema="sat", + ) + op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat") + # La clave oficial del SAT es única dentro de su catálogo. + op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat") + + # Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar). + sync_catalogs(op.get_bind()) + + # ---------- fin.concepts ---------- + op.create_table( + "concepts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=40), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("product_service_id", sa.Integer(), nullable=False), + sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), + sa.Column("tax_object_id", sa.Integer(), nullable=True), + sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"), + sa.ForeignKeyConstraint( + ["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id" + ), + sa.ForeignKeyConstraint( + ["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id" + ), + sa.ForeignKeyConstraint( + ["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id" + ), + schema="fin", + ) + op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin") + op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin") + op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin") + op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin") + # La clave interna del concepto es única por empresa. + op.create_index( + "uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + # Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre + # los conceptos vigentes de la misma empresa. + op.create_index( + "uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.issuer_settings ---------- + op.create_table( + "issuer_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("legal_name", sa.String(length=255), nullable=False), + sa.Column("rfc", sa.String(length=13), nullable=False), + sa.Column("tax_regime_id", sa.Integer(), nullable=False), + sa.Column("zip_code", sa.String(length=5), nullable=True), + sa.Column("updated_by", sa.String(length=64), nullable=True), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"), + sa.ForeignKeyConstraint( + ["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id" + ), + schema="fin", + ) + op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin") + op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin") + op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin") + op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin") + # Una sola configuración fiscal vigente por empresa. + op.create_index( + "uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.invoice_item_taxes ---------- + op.create_table( + "invoice_item_taxes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("invoice_item_id", sa.Integer(), nullable=False), + sa.Column("tax_id", sa.Integer(), nullable=False), + sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True), + sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")), + *_timestamp_columns(with_soft_delete=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"), + sa.ForeignKeyConstraint( + ["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id" + ), + sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"), + schema="fin", + ) + op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin") + op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin") + op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin") + op.create_index( + "ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin" + ) + # Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida. + op.create_index( + "uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"], + unique=True, schema="fin", postgresql_where=sa.text(_ALIVE), + ) + + # ---------- fin.invoices: claves fiscales del comprobante ---------- + # Todas nullable: las facturas ya emitidas no tienen estos datos. + op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin") + op.create_foreign_key( + "fk_fin_invoices_voucher_type_id", "invoices", "voucher_types", + ["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoices_payment_form_id", "invoices", "payment_forms", + ["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoices_payment_method_id", "invoices", "payment_methods", + ["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + + # ---------- fin.invoice_items: claves fiscales de la partida ---------- + # La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el + # PDF actual de la factura. + op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin") + op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin") + op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin") + op.create_foreign_key( + "fk_fin_invoice_items_concept_id", "invoice_items", "concepts", + ["concept_id"], ["id"], source_schema="fin", referent_schema="fin", + ) + op.create_foreign_key( + "fk_fin_invoice_items_product_service_id", "invoice_items", "products_services", + ["product_service_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure", + ["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + op.create_foreign_key( + "fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects", + ["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat", + ) + + +def downgrade() -> None: + # fin.invoice_items + for constraint in ( + "fk_fin_invoice_items_tax_object_id", + "fk_fin_invoice_items_unit_of_measure_id", + "fk_fin_invoice_items_product_service_id", + "fk_fin_invoice_items_concept_id", + ): + op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey") + op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin") + for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"): + op.drop_column("invoice_items", column, schema="fin") + + # fin.invoices + for constraint in ( + "fk_fin_invoices_payment_method_id", + "fk_fin_invoices_payment_form_id", + "fk_fin_invoices_voucher_type_id", + ): + op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey") + for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"): + op.drop_column("invoices", column, schema="fin") + + # Tablas nuevas (los índices caen con la tabla). + op.drop_table("invoice_item_taxes", schema="fin") + op.drop_table("issuer_settings", schema="fin") + op.drop_table("concepts", schema="fin") + + # Catálogos del SAT: se va el schema completo. + op.execute("DROP SCHEMA IF EXISTS sat CASCADE") diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 5987717..1d20c52 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -10,7 +10,16 @@ class InvoiceClientReviewInput(BaseModel): notes: str | None = None -class InvoiceItemBase(BaseModel): +class InvoiceItemSatFields(BaseModel): + """Claves fiscales de la partida. Opcionales: las facturas previas no las tienen.""" + + concept_id: int | None = None + product_service_id: int | None = None + unit_of_measure_id: int | None = None + tax_object_id: int | None = None + + +class InvoiceItemBase(InvoiceItemSatFields): concept: str = Field(..., max_length=60) description: str | None = Field(None, max_length=255) quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2) @@ -19,9 +28,11 @@ class InvoiceItemBase(BaseModel): class InvoiceItemCreate(InvoiceItemBase): invoice_id: int + # Opcional solo si viene concept_id: el service copia la descripción del concepto. + concept: str | None = Field(None, max_length=60) -class InvoiceItemUpdate(BaseModel): +class InvoiceItemUpdate(InvoiceItemSatFields): concept: str | None = Field(None, max_length=60) description: str | None = Field(None, max_length=255) quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2) @@ -76,6 +87,11 @@ class InvoiceBase(BaseModel): bank_info: str | None = None notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) + # ----- Claves fiscales del CFDI (opcionales mientras no se timbre) ----- + voucher_type_id: int | None = None + payment_form_id: int | None = None + payment_method_id: int | None = None + expedition_zip_code: str | None = Field(None, max_length=5) class InvoiceCreate(InvoiceBase): @@ -94,6 +110,10 @@ class InvoiceUpdate(BaseModel): bank_info: str | None = None notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) + voucher_type_id: int | None = None + payment_form_id: int | None = None + payment_method_id: int | None = None + expedition_zip_code: str | None = Field(None, max_length=5) class InvoiceResponse(InvoiceBase): diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index 1ebdac4..cf6fc41 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -1,11 +1,22 @@ from datetime import date, datetime -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base +from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK) + PaymentForm, + PaymentMethod, + ProductService, + Tax, + TaxObject, + UnitOfMeasure, + VoucherType, +) +from ..concepts.models import Concept # noqa: F401 + class Invoice(Base, TenantScopedMixin, TimestampMixin): """Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente.""" @@ -50,6 +61,18 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin): owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + # ----- Datos fiscales del CFDI (catálogos SAT) ----- + # Nullables: las facturas emitidas antes de existir los catálogos no los tienen. + voucher_type_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.voucher_types.id"), nullable=True + ) + payment_form_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.payment_forms.id"), nullable=True + ) + payment_method_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.payment_methods.id"), nullable=True + ) + expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True) class InvoiceItem(Base, TenantScopedMixin, TimestampMixin): @@ -62,10 +85,54 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin): invoice_id: Mapped[int] = mapped_column( Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True ) + # Texto libre histórico: lo consume el PDF actual y se conserva obligatorio. concept: Mapped[str] = mapped_column(String(60), nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1")) unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) + # ----- Datos fiscales de la partida (catálogos SAT) ----- + concept_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True + ) + product_service_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.products_services.id"), nullable=True + ) + unit_of_measure_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.units_of_measure.id"), nullable=True + ) + tax_object_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_objects.id"), nullable=True + ) + + +class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin): + """Impuesto trasladado o retenido de una partida de la factura. + + Es captura de detalle fiscal para el futuro CFDI: **no** interviene en el cálculo + de subtotal/IVA/total de la factura, que sigue saliendo de ``invoices.tax_rate``. + """ + + __tablename__ = "invoice_item_taxes" + __table_args__ = ( + Index( + "uq_fin_invoice_item_taxes", + "invoice_item_id", "tax_id", "is_withholding", + unique=True, + postgresql_where=text("deleted_at IS NULL"), + sqlite_where=text("deleted_at IS NULL"), + ), + {"schema": "fin"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + invoice_item_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True + ) + tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False) + # false = trasladado (se cobra al cliente); true = retenido + is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000 + amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0")) class Payment(Base, TenantScopedMixin, TimestampMixin): diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 6820515..bbd94c0 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -9,6 +9,7 @@ from api.v1.modules.crm.accounts.models import Account from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment +from ..concepts.models import Concept from .dto import ( InvoiceClientReviewInput, InvoiceCreate, @@ -331,9 +332,38 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem: return obj +def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: + """Completa ``concept`` a partir del concepto del catálogo cuando no se envió. + + El PDF de la factura sigue leyendo la columna de texto libre ``concept``, así que + al capturar por catálogo se hereda ahí la descripción del concepto (recortada al + largo de la columna). + """ + concept_id = data.get("concept_id") + if concept_id is not None: + catalog_concept = db.query(Concept).filter( + Concept.id == concept_id, Concept.tenant_id == tenant_id, + Concept.company_id == company_id, Concept.deleted_at.is_(None), + ).first() + if not catalog_concept: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="El concepto del catálogo no existe en esta empresa", + ) + if not data.get("concept"): + data["concept"] = catalog_concept.description[:60] + if not data.get("concept"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="La partida requiere un concepto o una referencia al catálogo de conceptos", + ) + + def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem: invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id) - item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) + data = payload.model_dump() + _resolve_item_concept(db, data, tenant_id, company_id) + item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id) db.add(item) db.flush() _recompute(db, invoice) diff --git a/backend/api/v1/modules/fin/permissions.py b/backend/api/v1/modules/fin/permissions.py index 068fb98..366da64 100644 --- a/backend/api/v1/modules/fin/permissions.py +++ b/backend/api/v1/modules/fin/permissions.py @@ -3,7 +3,7 @@ from api.v1.modules.core.permissions.registry import registry MODULE = "fin" -_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")] +_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")] _ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")] @@ -12,6 +12,11 @@ def register_permissions() -> None: for entity, label in _ENTITIES: for action, verb in _ACTIONS: registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action) + # Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD, + # así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio: + # son globales y de solo lectura, basta con fin.access. + registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view") + registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit") register_permissions() diff --git a/backend/api/v1/modules/fin/router.py b/backend/api/v1/modules/fin/router.py index 9cc824c..ee82017 100644 --- a/backend/api/v1/modules/fin/router.py +++ b/backend/api/v1/modules/fin/router.py @@ -5,8 +5,14 @@ from fastapi import APIRouter, Depends from api.v1.modules.core.permissions.dependencies import PermissionChecker from . import permissions # noqa: F401 (side-effect: registra permisos) +from .catalogs.routes import router as catalogs_router +from .concepts.routes import router as concepts_router from .invoices.routes import router as invoices_router +from .issuer.routes import router as issuer_router # Enforcement por área/carril (R-T-07): se exige fin.access para el módulo. router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))]) +router.include_router(catalogs_router) +router.include_router(concepts_router) +router.include_router(issuer_router) router.include_router(invoices_router) From 5a112a017126e5db7cd16bcf0338f9befeb03f3a Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:58:09 -0500 Subject: [PATCH 05/10] feat(fin): pantallas de conceptos y datos fiscales del emisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clientes API por dominio para los catálogos del SAT, conceptos y emisor. Los catálogos se cachean en un Map del módulo tras la primera carga: son fijos y no cambian durante la sesión. Pantalla de conceptos (/dashboard/fin/conceptos) con tabla, buscador, filtro de activos y alta/edición en diálogo. La clave de producto/servicio se elige con un combobox que consulta el catálogo a partir de 2 caracteres, y el 409 del backend por clave ya asignada se muestra junto al campo. Sección de configuración fiscal (/dashboard/settings/facturacion) con razón social, RFC (misma validación que el backend), régimen fiscal y CP. Si el GET responde 404 se abre en modo alta, no como error; el guardar se deshabilita sin fin.settings.edit. Todo en Svelte 5 con runes. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/api/fin/catalogs.ts | 94 ++++ frontend/src/lib/api/fin/concepts.ts | 81 ++++ frontend/src/lib/api/fin/index.ts | 4 + frontend/src/lib/api/fin/issuer.ts | 51 ++ .../src/lib/components/sidebar/modules.ts | 5 + .../dashboard/fin/conceptos/+page.svelte | 458 ++++++++++++++++++ .../settings/facturacion/+page.svelte | 200 ++++++++ .../dashboard/settings/facturacion/+page.ts | 1 + .../dashboard/settings/general/+page.svelte | 25 +- 9 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/api/fin/catalogs.ts create mode 100644 frontend/src/lib/api/fin/concepts.ts create mode 100644 frontend/src/lib/api/fin/issuer.ts create mode 100644 frontend/src/routes/dashboard/fin/conceptos/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/facturacion/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/facturacion/+page.ts diff --git a/frontend/src/lib/api/fin/catalogs.ts b/frontend/src/lib/api/fin/catalogs.ts new file mode 100644 index 0000000..a4cfb21 --- /dev/null +++ b/frontend/src/lib/api/fin/catalogs.ts @@ -0,0 +1,94 @@ +/** + * Cliente API — Catálogos del SAT (solo lectura). + * + * Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la + * sesión, así que se guardan en un `Map` del módulo para no repetir la petición en + * cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone. + */ +import { api } from '$lib/api'; + +export interface SatCatalogItem { + id: number; + code: string; + description: string; + is_active: boolean; +} + +export interface SatTaxRegime extends SatCatalogItem { + applies_to_individual: boolean; // persona física + applies_to_legal_entity: boolean; // persona moral +} + +export interface SatTax extends SatCatalogItem { + is_withholding: boolean; + is_transferred: boolean; + is_local: boolean; +} + +/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */ +export interface SatUnitOfMeasure extends Omit { + description: string | null; + name: string; + symbol: string | null; +} + +export type PersonType = 'fisica' | 'moral'; + +type CatalogParams = Record; + +/** Cache en memoria del módulo, con la query string completa como llave. */ +const cache = new Map(); + +function buildQuery(companyId: number, params?: CatalogParams): string { + const qs = new URLSearchParams({ company_id: String(companyId) }); + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== '') qs.set(key, String(value)); + } + qs.sort(); // llave de cache estable sin importar el orden de los parámetros + return qs.toString(); +} + +async function fetchCatalog( + path: string, + companyId: number, + params?: CatalogParams +): Promise { + const query = buildQuery(companyId, params); + const key = `${path}?${query}`; + const cached = cache.get(key); + if (cached) return cached as T[]; + + const res = await api.get(`/v1/fin/catalogs/${path}?${query}`); + if (res.error) throw new Error(res.error); + const rows = res.data ?? []; + cache.set(key, rows); + return rows; +} + +/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */ +export function clearCatalogCache(): void { + cache.clear(); +} + +export const satCatalogsAPI = { + taxRegimes: ( + companyId: number, + params?: { search?: string; person_type?: PersonType; active_only?: boolean } + ) => fetchCatalog('tax-regimes', companyId, params), + taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('taxes', companyId, params), + paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('payment-forms', companyId, params), + unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('units-of-measure', companyId, params), + productsServices: ( + companyId: number, + params?: { search?: string; limit?: number; active_only?: boolean } + ) => fetchCatalog('products-services', companyId, params), + voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('voucher-types', companyId, params), + paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('payment-methods', companyId, params), + taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('tax-objects', companyId, params) +}; diff --git a/frontend/src/lib/api/fin/concepts.ts b/frontend/src/lib/api/fin/concepts.ts new file mode 100644 index 0000000..975aba2 --- /dev/null +++ b/frontend/src/lib/api/fin/concepts.ts @@ -0,0 +1,81 @@ +/** + * Cliente API — Catálogo de conceptos de facturación. + * + * Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la + * empresa; el backend responde 409 si la clave ya está tomada. + */ +import { api } from '$lib/api'; +import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs'; + +export interface Concept { + id: number; + code: string; + description: string; + product_service_id: number; + unit_of_measure_id: number | null; + tax_object_id: number | null; + unit_price: number | null; + currency: string; + is_active: boolean; + notes: string | null; + product_service: SatCatalogItem | null; + unit_of_measure: SatUnitOfMeasure | null; + tax_object: SatCatalogItem | null; + tenant_id: number; + company_id: number; + created_by: string | null; + updated_by: string | null; + created_at: string; + updated_at: string; +} + +export interface ConceptInput { + code: string; + description: string; + product_service_id: number; + unit_of_measure_id?: number | null; + tax_object_id?: number | null; + unit_price?: number | null; + currency?: string; + is_active?: boolean; + notes?: string | null; +} + +export const conceptsAPI = { + async list( + companyId: number, + params?: { search?: string; active_only?: boolean; product_service_id?: number } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.search) qs.set('search', params.search); + if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only)); + if (params?.product_service_id !== undefined) + qs.set('product_service_id', String(params.product_service_id)); + const res = await api.get(`/v1/fin/concepts?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/v1/fin/concepts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: ConceptInput, companyId: number): Promise { + const res = await api.post(`/v1/fin/concepts?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/v1/fin/concepts/${id}?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async remove(id: number, companyId: number): Promise { + const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index e04e708..052329e 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -3,6 +3,10 @@ */ import { api } from '$lib/api'; +export * from './catalogs'; +export * from './concepts'; +export * from './issuer'; + export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada'; export interface Invoice { diff --git a/frontend/src/lib/api/fin/issuer.ts b/frontend/src/lib/api/fin/issuer.ts new file mode 100644 index 0000000..69cfc9c --- /dev/null +++ b/frontend/src/lib/api/fin/issuer.ts @@ -0,0 +1,51 @@ +/** + * Cliente API — Datos fiscales del emisor (una configuración por empresa). + */ +import { api } from '$lib/api'; +import type { SatTaxRegime } from './catalogs'; + +/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */ +export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/; + +export interface IssuerSettings { + id: number; + tenant_id: number; + company_id: number; + legal_name: string; + rfc: string; + tax_regime_id: number; + tax_regime: SatTaxRegime | null; + zip_code: string | null; + updated_by: string | null; + created_at: string; + updated_at: string; +} + +export interface IssuerSettingsInput { + legal_name: string; + rfc: string; + tax_regime_id: number; + zip_code?: string | null; +} + +export const issuerAPI = { + /** + * Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el + * backend responde 404 y la pantalla debe abrirse en modo alta, no en error. + */ + async get(companyId: number): Promise { + const res = await api.get(`/v1/fin/settings/issuer?company_id=${companyId}`); + if (res.status === 404) return null; + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async save(data: IssuerSettingsInput, companyId: number): Promise { + const res = await api.put( + `/v1/fin/settings/issuer?company_id=${companyId}`, + data + ); + if (res.error) throw new Error(res.error); + return res.data!; + } +}; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index dbd39e0..bd901a3 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] { icon: Receipt, items: [ { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, + { title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' }, ], }, { @@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] { title: 'Configuración', url: '/dashboard/settings/general', icon: Settings2, + items: [ + { title: 'General', url: '/dashboard/settings/general' }, + { title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' }, + ], }, ]; } diff --git a/frontend/src/routes/dashboard/fin/conceptos/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte new file mode 100644 index 0000000..ed87f66 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte @@ -0,0 +1,458 @@ + + + + Conceptos de facturación + + +
+
+
+

+ + Conceptos de facturación +

+

+ Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la + empresa. +

+
+ +
+ + + +
+
+ + companyId && load(companyId)} + /> +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if items.length === 0} +

Sin conceptos registrados.

+ {:else} +
+ + + + Clave + Descripción + Clave ProdServ + Unidad + Objeto de impuesto + Precio unitario + Estado + Acciones + + + + {#each items as concept (concept.id)} + + {concept.code} + {concept.description} + + {concept.product_service?.code ?? '—'} + {#if concept.product_service} + {concept.product_service.description} + {/if} + + {concept.unit_of_measure?.name ?? '—'} + {concept.tax_object?.code ?? '—'} + {money(concept.unit_price)} {concept.currency} + + + {concept.is_active ? 'Activo' : 'Inactivo'} + + + + + + + + {/each} + + +
+ {/if} +
+
+
+ + { + if (modalOpen && e.key === 'Escape') modalOpen = false; + }} +/> + +{#if modalOpen} + +{/if} diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.svelte b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte new file mode 100644 index 0000000..1113d26 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte @@ -0,0 +1,200 @@ + + + + Configuración de Facturación + + +
+
+

+ + Datos fiscales del emisor +

+

+ Identidad fiscal con la que la empresa emite sus comprobantes. +

+
+ + {#if !canView} + + +

+ No tienes permiso para consultar los datos fiscales del emisor. +

+
+
+ {:else} + + + {isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'} + + {isNew + ? 'Esta empresa aún no tiene datos fiscales configurados.' + : 'Actualiza la información con la que se emiten los comprobantes.'} + + + + {#if loading} +

Cargando…

+ {:else} +
+ + + + + + + + +
+ +
+ {#if !canEdit} +

+ Solo puedes consultar: se requiere el permiso de edición de datos fiscales. +

+ {/if} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.ts b/frontend/src/routes/dashboard/settings/facturacion/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/facturacion/+page.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/dashboard/settings/general/+page.svelte b/frontend/src/routes/dashboard/settings/general/+page.svelte index 0f1206d..d01c340 100644 --- a/frontend/src/routes/dashboard/settings/general/+page.svelte +++ b/frontend/src/routes/dashboard/settings/general/+page.svelte @@ -1,6 +1,10 @@ @@ -18,6 +22,25 @@

+ {#if canViewIssuerSettings} + + + + + Facturación + + + Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición. + + + + + + + {/if} + Configuración del sistema From cb2acb11fc14368155c14246fc87726ba7dfaac9 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:58:09 -0500 Subject: [PATCH 06/10] =?UTF-8?q?test(fin):=20cobertura=20de=20cat=C3=A1lo?= =?UTF-8?q?gos,=20conceptos=20y=20emisor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: los 8 endpoints de catálogo responden 200 con las semillas exactas y filtran por búsqueda; tax-regimes acota por tipo de persona; ninguna ruta de catálogo acepta escritura (405); sync_catalogs es idempotente. CRUD de conceptos, conflicto 409 por clave ProdServ repetida en la misma empresa, la misma clave permitida en otra empresa, la baja lógica liberándola, aislamiento multi-tenant, upsert del emisor sin duplicar filas y RFC inválido rechazado. También que una partida con concept_id hereda la descripción y que las facturas sin claves del SAT siguen listándose y generando PDF. El fixture de pruebas siembra los catálogos con la misma función que usa la migración, sobre el schema sat mapeado a SQLite. Frontend: prueba del cacheo del cliente de catálogos. RFC dummy XAXX010101000 en todas las pruebas: sin datos reales. Co-Authored-By: Claude Opus 5 (1M context) --- backend/tests/conftest.py | 10 +- backend/tests/test_fin_sat_catalogs.py | 331 ++++++++++++++++++++++ frontend/src/lib/api/fin/catalogs.test.ts | 62 ++++ 3 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_fin_sat_catalogs.py create mode 100644 frontend/src/lib/api/fin/catalogs.test.ts diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 70d2eaa..7881476 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -37,9 +37,13 @@ import api.v1.modules.crm.quotes.models # noqa: E402,F401 import api.v1.modules.crm.service_requests.models # noqa: E402,F401 import api.v1.modules.crm.suppliers.models # noqa: E402,F401 import api.v1.modules.ops.shipments.models # noqa: E402,F401 +import api.v1.modules.fin.catalogs.models # noqa: E402,F401 +import api.v1.modules.fin.concepts.models # noqa: E402,F401 +import api.v1.modules.fin.issuer.models # noqa: E402,F401 import api.v1.modules.fin.invoices.models # noqa: E402,F401 +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402 -_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None} +_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None} # Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm. # En CI (PostgreSQL) la tabla real la crea la migración inicial del core. @@ -75,6 +79,10 @@ def db(): Base.metadata.create_all(engine) session_factory = sessionmaker(bind=engine, future=True) session = session_factory() + # Los catálogos del SAT los siembra la migración en PostgreSQL; aquí se replica + # con la misma función para que conceptos y emisor tengan claves que referenciar. + sync_catalogs(session.connection()) + session.commit() try: yield session finally: diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py new file mode 100644 index 0000000..8c3e9d9 --- /dev/null +++ b/backend/tests/test_fin_sat_catalogs.py @@ -0,0 +1,331 @@ +"""Pruebas de los catálogos del SAT, el catálogo de conceptos y los datos fiscales +del emisor (módulo fin). + +Cubren: lectura de los 8 catálogos y su filtrado, que no acepten escritura, el CRUD de +conceptos con la relación 1:1 contra c_ClaveProdServ, el aislamiento multi-tenant, el +upsert del emisor y el amarre de las partidas de factura al catálogo de conceptos. + +Los RFC de las pruebas son dummies (XAXX010101000): nunca datos reales. +""" +from decimal import Decimal + +import pytest +import sqlalchemy as sa +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from api.v1.modules.crm.accounts import service as accounts_service +from api.v1.modules.crm.accounts.dto import AccountCreate +from api.v1.modules.fin.catalogs.models import ProductService, TaxRegime +from api.v1.modules.fin.catalogs.routes import router as catalogs_router +from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs +from api.v1.modules.fin.concepts import service as concepts_service +from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate +from api.v1.modules.fin.invoices import service as invoices_service +from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate +from api.v1.modules.fin.issuer import service as issuer_service +from api.v1.modules.fin.issuer.dto import IssuerSettingsInput +from api.v1.modules.fin.issuer.models import IssuerSettings +from core.database import get_core_db +from core.security import get_current_user + +T, C = 1, 1 +OTHER_TENANT, OTHER_COMPANY = 2, 2 +RFC_DUMMY = "XAXX010101000" + + +@pytest.fixture() +def client(db): + """App mínima con solo el router de catálogos: evita levantar auth y permisos.""" + app = FastAPI() + app.include_router(catalogs_router, prefix="/fin") + app.dependency_overrides[get_core_db] = lambda: db + app.dependency_overrides[get_current_user] = lambda: {"sub": "tester", "tenant_id": T} + return TestClient(app) + + +def _product_service(db, code: str = "78101600") -> ProductService: + return db.query(ProductService).filter(ProductService.code == code).one() + + +def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> ConceptCreate: + return ConceptCreate( + code=code, + description="Flete marítimo internacional", + product_service_id=_product_service(db, ps_code).id, + unit_price=Decimal("1500.00"), + ) + + +# ---------- Catálogos del SAT: lectura ---------- + +CATALOG_EXPECTATIONS = [ + ("tax-regimes", 19, "601"), + ("taxes", 3, "002"), + ("payment-forms", 22, "03"), + ("units-of-measure", 21, "H87"), + ("products-services", 11, "78101500"), + ("voucher-types", 5, "I"), + ("payment-methods", 2, "PUE"), + ("tax-objects", 4, "02"), +] + + +@pytest.mark.parametrize("path,expected_count,sample_code", CATALOG_EXPECTATIONS) +def test_catalog_endpoints_return_seeded_rows(client, path, expected_count, sample_code): + res = client.get(f"/fin/catalogs/{path}") + assert res.status_code == 200 + rows = res.json() + assert len(rows) == expected_count + assert sample_code in [r["code"] for r in rows] + + +def test_catalog_search_filters_by_code_or_description(client): + by_code = client.get("/fin/catalogs/payment-forms", params={"search": "03"}).json() + assert [r["code"] for r in by_code] == ["03"] + + by_description = client.get("/fin/catalogs/payment-forms", params={"search": "transferencia"}).json() + assert [r["code"] for r in by_description] == ["03"] + + prodserv = client.get("/fin/catalogs/products-services", params={"search": "marítimo"}).json() + assert [r["code"] for r in prodserv] == ["78101600"] + + +def test_tax_regimes_person_type_excludes_individual_only(client): + moral = client.get("/fin/catalogs/tax-regimes", params={"person_type": "moral"}).json() + codes = [r["code"] for r in moral] + assert "601" in codes # General de Ley Personas Morales + assert "605" not in codes # Sueldos y Salarios: solo persona física + assert all(r["applies_to_legal_entity"] for r in moral) + + fisica = client.get("/fin/catalogs/tax-regimes", params={"person_type": "fisica"}).json() + fisica_codes = [r["code"] for r in fisica] + assert "605" in fisica_codes and "601" not in fisica_codes + + +def test_products_services_limit_caps_results(client): + assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3 + assert client.get("/fin/catalogs/products-services", params={"limit": 500}).status_code == 422 + + +def test_catalogs_are_read_only(client): + """Los catálogos del SAT no exponen métodos de escritura.""" + for method, path in [ + ("post", "/fin/catalogs/payment-forms"), + ("put", "/fin/catalogs/tax-regimes"), + ("patch", "/fin/catalogs/units-of-measure"), + ("delete", "/fin/catalogs/products-services"), + ]: + res = client.request(method.upper(), path, json={"code": "XX", "description": "Inventado"}) + assert res.status_code == 405, f"{method.upper()} {path} no debería aceptarse" + + +def _catalog_counts(db) -> dict[str, int]: + return { + table.name: db.execute(sa.select(sa.func.count()).select_from(table)).scalar() + for table, _ in CATALOGS + } + + +def test_sync_catalogs_is_idempotent(db): + """Volver a correrla no duplica ni borra filas.""" + before = _catalog_counts(db) + inserted = sync_catalogs(db.connection()) # el fixture ya sembró los catálogos + db.commit() + assert sum(inserted.values()) == 0 + assert _catalog_counts(db) == before + + +# ---------- Conceptos ---------- + +def test_concept_crud(db): + created = concepts_service.create_concept(db, _concept_payload(db), T, C, "tester") + assert created.code == "FLETE-MAR" and created.currency == "MXN" and created.is_active + + fetched = concepts_service.get_concept(db, created.id, T, C) + assert fetched.product_service.code == "78101600" # catálogo resuelto sin N+1 + + updated = concepts_service.update_concept( + db, created.id, ConceptUpdate(description="Flete marítimo FCL", is_active=False), T, C, "tester" + ) + assert updated.description == "Flete marítimo FCL" and updated.is_active is False + + assert concepts_service.get_concepts(db, T, C, active_only=False) == [updated] + assert concepts_service.get_concepts(db, T, C, active_only=True) == [] + + concepts_service.delete_concept(db, created.id, T, C) + assert concepts_service.get_concepts(db, T, C) == [] + with pytest.raises(HTTPException) as exc: + concepts_service.get_concept(db, created.id, T, C) + assert exc.value.status_code == 404 + + +def test_duplicate_product_service_in_same_company_conflicts(db): + concepts_service.create_concept(db, _concept_payload(db), T, C) + with pytest.raises(HTTPException) as exc: + concepts_service.create_concept(db, _concept_payload(db, code="OTRO-CODIGO"), T, C) + assert exc.value.status_code == 409 + assert "producto/servicio" in exc.value.detail + + +def test_duplicate_concept_code_in_same_company_conflicts(db): + concepts_service.create_concept(db, _concept_payload(db), T, C) + with pytest.raises(HTTPException) as exc: + concepts_service.create_concept(db, _concept_payload(db, ps_code="78101500"), T, C) + assert exc.value.status_code == 409 + assert "clave 'FLETE-MAR'" in exc.value.detail + + +def test_same_product_service_allowed_in_another_company(db): + concepts_service.create_concept(db, _concept_payload(db), T, C) + other = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY) + assert other.company_id == OTHER_COMPANY + assert other.product_service_id == _product_service(db).id + + +def test_soft_deleted_concept_frees_its_product_service(db): + first = concepts_service.create_concept(db, _concept_payload(db), T, C) + concepts_service.delete_concept(db, first.id, T, C) + reused = concepts_service.create_concept(db, _concept_payload(db), T, C) + assert reused.id != first.id + assert reused.product_service_id == first.product_service_id + + +def test_concept_is_isolated_by_tenant(db): + other_tenant_concept = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C) + assert concepts_service.get_concepts(db, T, C) == [] + with pytest.raises(HTTPException) as exc: + concepts_service.get_concept(db, other_tenant_concept.id, T, C) + assert exc.value.status_code == 404 + with pytest.raises(HTTPException) as exc: + concepts_service.update_concept( + db, other_tenant_concept.id, ConceptUpdate(description="Ajeno"), T, C + ) + assert exc.value.status_code == 404 + + +def test_concept_rejects_unknown_sat_key(db): + payload = _concept_payload(db) + payload.product_service_id = 999999 + with pytest.raises(HTTPException) as exc: + concepts_service.create_concept(db, payload, T, C) + assert exc.value.status_code == 422 + + +# ---------- Datos fiscales del emisor ---------- + +def _issuer_payload(db, legal_name: str = "Empresa Demo SA de CV") -> IssuerSettingsInput: + regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one() + return IssuerSettingsInput( + legal_name=legal_name, rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="64000" + ) + + +def test_issuer_settings_upsert_keeps_one_row_per_company(db): + created = issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester") + assert created.rfc == RFC_DUMMY + + updated = issuer_service.save_issuer_settings( + db, _issuer_payload(db, legal_name="Empresa Demo Renombrada SA de CV"), T, C, "tester" + ) + assert updated.id == created.id + assert updated.legal_name == "Empresa Demo Renombrada SA de CV" + + rows = db.query(IssuerSettings).filter( + IssuerSettings.tenant_id == T, IssuerSettings.company_id == C, IssuerSettings.deleted_at.is_(None) + ).all() + assert len(rows) == 1 + + +def test_issuer_settings_missing_returns_404(db): + with pytest.raises(HTTPException) as exc: + issuer_service.get_issuer_settings(db, T, C) + assert exc.value.status_code == 404 + + +def test_issuer_rfc_is_validated_and_normalized(db): + regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one() + with pytest.raises(ValidationError): + IssuerSettingsInput(legal_name="Demo", rfc="RFC-INVALIDO", tax_regime_id=regime.id) + with pytest.raises(ValidationError): + IssuerSettingsInput(legal_name="Demo", rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="123") + + normalized = IssuerSettingsInput( + legal_name="Demo", rfc=" xaxx010101000 ", tax_regime_id=regime.id + ) + assert normalized.rfc == RFC_DUMMY + + +def test_issuer_rejects_unknown_tax_regime(db): + payload = _issuer_payload(db) + payload.tax_regime_id = 999999 + with pytest.raises(HTTPException) as exc: + issuer_service.save_issuer_settings(db, payload, T, C) + assert exc.value.status_code == 422 + + +# ---------- Amarre con las facturas ---------- + +def test_invoice_item_inherits_concept_description(db): + concept = concepts_service.create_concept(db, _concept_payload(db), T, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-1"), T, C) + item = invoices_service.create_item( + db, + InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, quantity=1, unit_amount=1500), + T, + C, + ) + assert item.concept == concept.description # copiada del catálogo para el PDF + assert item.concept_id == concept.id + + # Si el cliente sí manda el texto, se respeta tal cual. + explicit = invoices_service.create_item( + db, + InvoiceItemCreate( + invoice_id=invoice.id, concept_id=concept.id, concept="Flete a la medida", unit_amount=100 + ), + T, + C, + ) + assert explicit.concept == "Flete a la medida" + + +def test_invoice_item_without_concept_or_catalog_is_rejected(db): + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-2"), T, C) + with pytest.raises(HTTPException) as exc: + invoices_service.create_item(db, InvoiceItemCreate(invoice_id=invoice.id, unit_amount=10), T, C) + assert exc.value.status_code == 422 + + +def test_invoice_item_rejects_concept_from_another_company(db): + concept = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-3"), T, C) + with pytest.raises(HTTPException) as exc: + invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=10), T, C + ) + assert exc.value.status_code == 422 + + +def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch): + """Las facturas previas, sin claves del SAT, siguen listándose y generando PDF.""" + stored = {} + monkeypatch.setattr( + "core.storage_s3.put_object_bytes", + lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}), + ) + account = accounts_service.create_account(db, AccountCreate(name="Cliente heredado"), T, C) + invoice = invoices_service.create_invoice( + db, InvoiceCreate(reference="F-LEGACY", account_id=account.id, tax_rate=Decimal("16")), T, C + ) + invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional", unit_amount=1000), T, C + ) + assert invoice.voucher_type_id is None and invoice.payment_form_id is None + + listed = invoices_service.get_invoices(db, T, C) + assert invoice.id in [i.id for i in listed] + + sent = invoices_service.send_invoice(db, invoice.id, T, C) + assert sent.status == "enviada" and stored["len"] > 0 diff --git a/frontend/src/lib/api/fin/catalogs.test.ts b/frontend/src/lib/api/fin/catalogs.test.ts new file mode 100644 index 0000000..29ab94f --- /dev/null +++ b/frontend/src/lib/api/fin/catalogs.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const get = vi.fn(); + +// El cliente de catálogos solo usa `api.get`; se sustituye para contar peticiones. +vi.mock('$lib/api', () => ({ api: { get } })); + +const { satCatalogsAPI, clearCatalogCache } = await import('./catalogs'); + +const COMPANY_ID = 1; +const PAYMENT_FORMS = [ + { id: 1, code: '01', description: 'Efectivo', is_active: true }, + { id: 2, code: '03', description: 'Transferencia electrónica de fondos', is_active: true } +]; + +describe('satCatalogsAPI — cacheo en memoria', () => { + beforeEach(() => { + clearCatalogCache(); + get.mockReset(); + get.mockResolvedValue({ data: PAYMENT_FORMS, status: 200 }); + }); + + it('consulta el backend la primera vez y reusa el cache después', async () => { + const first = await satCatalogsAPI.paymentForms(COMPANY_ID); + const second = await satCatalogsAPI.paymentForms(COMPANY_ID); + + expect(first).toEqual(PAYMENT_FORMS); + expect(second).toBe(first); // misma referencia: vino del cache + expect(get).toHaveBeenCalledTimes(1); + }); + + it('cachea por separado cada combinación de parámetros', async () => { + await satCatalogsAPI.paymentForms(COMPANY_ID); + await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' }); + await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' }); + + expect(get).toHaveBeenCalledTimes(2); + }); + + it('no comparte cache entre compañías', async () => { + await satCatalogsAPI.paymentForms(COMPANY_ID); + await satCatalogsAPI.paymentForms(2); + + expect(get).toHaveBeenCalledTimes(2); + }); + + it('clearCatalogCache obliga a volver a consultar', async () => { + await satCatalogsAPI.paymentForms(COMPANY_ID); + clearCatalogCache(); + await satCatalogsAPI.paymentForms(COMPANY_ID); + + expect(get).toHaveBeenCalledTimes(2); + }); + + it('propaga el error del backend y no lo cachea', async () => { + get.mockResolvedValueOnce({ error: 'Falla del servidor', status: 500 }); + await expect(satCatalogsAPI.taxRegimes(COMPANY_ID)).rejects.toThrow('Falla del servidor'); + + await satCatalogsAPI.taxRegimes(COMPANY_ID); + expect(get).toHaveBeenCalledTimes(2); + }); +}); From ae0664e987f6315853ebe65dbbc8cd60976a82c2 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:10:57 -0500 Subject: [PATCH 07/10] =?UTF-8?q?refactor(fin):=20conceptos=20con=20p?= =?UTF-8?q?=C3=A1ginas=20dedicadas=20en=20vez=20de=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye el diálogo de alta/edición por el patrón que ya usa el CRM para proveedores y cuentas: la lista solo lista, y el alta y la edición viven en /dashboard/fin/conceptos/nuevo y /dashboard/fin/conceptos/[id]. Los campos del formulario se extraen a $lib/components/fin/ConceptFields.svelte para que ambas pantallas compartan el combobox de clave ProdServ y los selects de unidad y objeto de impuesto. El 409 del backend por clave ya asignada se sigue mostrando junto al campo. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/fin/ConceptFields.svelte | 185 +++++++++++ .../dashboard/fin/conceptos/+page.svelte | 313 +----------------- .../dashboard/fin/conceptos/[id]/+page.svelte | 155 +++++++++ .../fin/conceptos/nuevo/+page.svelte | 99 ++++++ 4 files changed, 457 insertions(+), 295 deletions(-) create mode 100644 frontend/src/lib/components/fin/ConceptFields.svelte create mode 100644 frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte create mode 100644 frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte diff --git a/frontend/src/lib/components/fin/ConceptFields.svelte b/frontend/src/lib/components/fin/ConceptFields.svelte new file mode 100644 index 0000000..b6e7523 --- /dev/null +++ b/frontend/src/lib/components/fin/ConceptFields.svelte @@ -0,0 +1,185 @@ + + +
+ + + + + +
+ Clave de producto/servicio del SAT * +

+ Una clave del SAT solo puede estar asignada a un concepto de la empresa. +

+ {#if productService} +
+ + {productService.code} + — {productService.description} + + +
+ {:else} + + {#if searchingProductService} +

Buscando…

+ {:else if productServiceOptions.length > 0} +
    + {#each productServiceOptions as option (option.id)} +
  • + +
  • + {/each} +
+ {:else if productServiceQuery.trim().length >= 2} +

Sin coincidencias en el catálogo.

+ {/if} + {/if} + {#if productServiceError} +

{productServiceError}

+ {/if} +
+ + + + + + + + +
diff --git a/frontend/src/routes/dashboard/fin/conceptos/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte index ed87f66..11c1304 100644 --- a/frontend/src/routes/dashboard/fin/conceptos/+page.svelte +++ b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte @@ -1,60 +1,23 @@ + + + {concept ? `Concepto ${concept.code}` : 'Concepto de facturación'} + + +
+ + + {#if loading && !concept} +

Cargando…

+ {:else if concept} +
+
+

+ + {concept.code} +

+

+ {concept.description} + {#if concept.product_service} + · {concept.product_service.code} + {/if} + · {concept.is_active ? 'Activo' : 'Inactivo'} +

+
+ +
+ + + +
+ + +
+ +
+ +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte new file mode 100644 index 0000000..a6f0e12 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/conceptos/nuevo/+page.svelte @@ -0,0 +1,99 @@ + + + + Nuevo concepto de facturación + + +
+ + +
+

+ Nuevo concepto +

+

+ Cada concepto se liga a una clave de producto/servicio del SAT. +

+
+ + + +
+ + +
+ + +
+ +
+
+
From ce09e0d30a296888689e9cf34708329e6b0ae258 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:24:50 -0500 Subject: [PATCH 08/10] =?UTF-8?q?feat(fin):=20partidas=20de=20factura=20ca?= =?UTF-8?q?pturadas=20desde=20el=20cat=C3=A1logo=20de=20conceptos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El selector de concepto de la partida deja de ser una lista fija en el código y se alimenta del catálogo de conceptos de la empresa: al elegir uno se manda concept_id y el backend copia la descripción a la columna de texto libre que consume el PDF. Si el concepto trae precio unitario, se precarga en la partida. Las claves genéricas anteriores quedan en un segundo grupo del mismo selector, marcadas como "sin clave del SAT", para no bloquear a las empresas que aún no tienen catálogo; si está vacío se enlaza al alta de conceptos. El listado de partidas etiqueta con la clave y descripción del catálogo cuando la partida lo referencia, y cae al texto libre para las facturas anteriores. Los tipos de Invoice e InvoiceItem se completan con las claves fiscales que el backend ya devuelve. Co-Authored-By: Claude Opus 5 (1M context) --- backend/tests/test_fin_sat_catalogs.py | 8 ++- frontend/src/lib/api/fin/index.ts | 16 ++++- .../dashboard/fin/facturas/[id]/+page.svelte | 68 +++++++++++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index 8c3e9d9..33201cf 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -23,7 +23,7 @@ from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs from api.v1.modules.fin.concepts import service as concepts_service from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate from api.v1.modules.fin.invoices import service as invoices_service -from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate +from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, InvoiceItemResponse from api.v1.modules.fin.issuer import service as issuer_service from api.v1.modules.fin.issuer.dto import IssuerSettingsInput from api.v1.modules.fin.issuer.models import IssuerSettings @@ -279,6 +279,12 @@ def test_invoice_item_inherits_concept_description(db): assert item.concept == concept.description # copiada del catálogo para el PDF assert item.concept_id == concept.id + # La respuesta expone las claves fiscales: el frontend etiqueta la partida con ellas. + payload = InvoiceItemResponse.model_validate(item).model_dump() + assert payload["concept_id"] == concept.id + assert payload["concept"] == concept.description + assert {"product_service_id", "unit_of_measure_id", "tax_object_id"} <= payload.keys() + # Si el cliente sí manda el texto, se respeta tal cual. explicit = invoices_service.create_item( db, diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index 052329e..0d2bbde 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -37,6 +37,11 @@ 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. + voucher_type_id: number | null; + payment_form_id: number | null; + payment_method_id: number | null; + expedition_zip_code: string | null; tenant_id: number; company_id: number; created_at: string; @@ -47,17 +52,26 @@ export type InvoiceInput = Partial> & { invoice_id: number; - concept: string; }; export interface Payment { diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte index 6d4bdbc..8e085ad 100644 --- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -6,8 +6,8 @@ import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; import { - invoicesAPI, invoiceItemsAPI, paymentsAPI, - type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput + invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI, + type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput } from '$lib/api/fin'; import { accountsAPI, type Account } from '$lib/api/crm'; import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format'; @@ -20,6 +20,9 @@ let items = $state([]); let payments = $state([]); let accounts = $state([]); + /** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar + * partidas que apunten a un concepto ya inactivo. */ + let concepts = $state([]); let form = $state({}); let tab = $state('conceptos'); let loading = $state(false); @@ -29,6 +32,10 @@ let addingPay = $state(false); let newItem = $state({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }); let newPay = $state({ invoice_id: 0, amount: 0, method: 'transferencia' }); + /** Opción elegida en el selector de concepto: `cat:` del catálogo o `txt:` genérica. */ + let conceptChoice = $state('txt:flete_internacional'); + + const activeConcepts = $derived(concepts.filter((c) => c.is_active)); $effect(() => { const cid = companyId; @@ -40,8 +47,9 @@ async function load(cid: number, id: number) { loading = true; try { - [invoice, items, payments, accounts] = await Promise.all([ - invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid) + [invoice, items, payments, accounts, concepts] = await Promise.all([ + invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), + accountsAPI.list(cid), conceptsAPI.list(cid) ]); form = { ...invoice }; } catch (e) { @@ -127,7 +135,35 @@ } } - function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; } + function startItem() { + newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; + // Si la empresa ya tiene catálogo, se arranca con su primer concepto. + conceptChoice = activeConcepts.length ? `cat:${activeConcepts[0].id}` : 'txt:flete_internacional'; + applyConceptChoice(); + addingItem = true; + } + + /** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */ + function applyConceptChoice() { + if (conceptChoice.startsWith('cat:')) { + const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4))); + if (!c) return; + // Solo se manda concept_id: el backend copia ahí la descripción del concepto. + newItem.concept_id = c.id; + newItem.concept = undefined; + if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price); + } else { + newItem.concept_id = null; + newItem.concept = conceptChoice.slice(4); + } + } + + /** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */ + function itemConceptLabel(it: InvoiceItem): string { + const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined; + return c ? `${c.code} — ${c.description}` : labelOf(QUOTE_CONCEPTS, it.concept); + } + async function saveItem() { if (!companyId) return; try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); } @@ -207,7 +243,25 @@
{#if addingItem}
- + @@ -222,7 +276,7 @@ {#each items as it (it.id)} - {labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}{it.description}{/if} + {itemConceptLabel(it)}{#if it.description}{it.description}{/if} {it.quantity} {formatMoney(it.unit_amount, invoice.currency)} {formatMoney(it.line_total, invoice.currency)} From 15717314fd8013a2b16329a7ad7a1192d8a78070 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:50:12 -0500 Subject: [PATCH 09/10] feat(fin): la partida hereda las claves del SAT de su concepto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra la decisión pendiente 7. create_item ya no copia solo la descripción del concepto: también hereda product_service_id, unit_of_measure_id y tax_object_id cuando el cliente no los envía, para que la partida capturada por catálogo quede completa para el CFDI. Lo que el cliente sí manda gana sobre el catálogo, para poder facturar con una unidad distinta a la del concepto. update_item pasa por la misma resolución cuando cambia concept_id: revalida que el concepto sea de la empresa (antes el PATCH no lo validaba y admitía apuntar a un concepto de otro tenant) y vuelve a heredar del concepto nuevo. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/v1/modules/fin/invoices/service.py | 29 ++++- backend/tests/test_fin_sat_catalogs.py | 113 +++++++++++++++++- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index bbd94c0..3d10574 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -332,12 +332,21 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem: return obj -def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: - """Completa ``concept`` a partir del concepto del catálogo cuando no se envió. +# 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") - El PDF de la factura sigue leyendo la columna de texto libre ``concept``, así que - al capturar por catálogo se hereda ahí la descripción del concepto (recortada al - largo de la columna). + +def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: + """Completa la partida a partir del concepto del catálogo. + + Hereda dos cosas cuando el cliente no las manda: + + - ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así + que ahí va la descripción del concepto (recortada al largo de la columna). + - Las claves fiscales (``product_service_id``, ``unit_of_measure_id``, + ``tax_object_id``): sin ellas la partida capturada por catálogo quedaría + incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo, + para poder facturar una partida con una unidad distinta a la del concepto. """ concept_id = data.get("concept_id") if concept_id is not None: @@ -352,6 +361,9 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: ) if not data.get("concept"): data["concept"] = catalog_concept.description[:60] + for field in _CONCEPT_INHERITED_FIELDS: + if data.get(field) is None: + data[field] = getattr(catalog_concept, field) if not data.get("concept"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -374,7 +386,12 @@ 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) - for f, v in payload.model_dump(exclude_unset=True).items(): + 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. + if data.get("concept_id") is not None: + _resolve_item_concept(db, data, tenant_id, company_id) + for f, v in data.items(): setattr(item, f, v) db.flush() _recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id)) diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index 33201cf..b0a432a 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -16,14 +16,19 @@ from fastapi.testclient import TestClient from pydantic import ValidationError from api.v1.modules.crm.accounts import service as accounts_service -from api.v1.modules.crm.accounts.dto import AccountCreate -from api.v1.modules.fin.catalogs.models import ProductService, TaxRegime +from api.v1.modules.crm.accounts.dto import AccountCreate, AccountUpdate +from api.v1.modules.fin.catalogs.models import CfdiUse, ProductService, TaxObject, TaxRegime, UnitOfMeasure from api.v1.modules.fin.catalogs.routes import router as catalogs_router from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs from api.v1.modules.fin.concepts import service as concepts_service from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate from api.v1.modules.fin.invoices import service as invoices_service -from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, InvoiceItemResponse +from api.v1.modules.fin.invoices.dto import ( + InvoiceCreate, + InvoiceItemCreate, + InvoiceItemResponse, + InvoiceItemUpdate, +) from api.v1.modules.fin.issuer import service as issuer_service from api.v1.modules.fin.issuer.dto import IssuerSettingsInput from api.v1.modules.fin.issuer.models import IssuerSettings @@ -54,6 +59,8 @@ def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> code=code, description="Flete marítimo internacional", product_service_id=_product_service(db, ps_code).id, + unit_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id, + tax_object_id=db.query(TaxObject).filter(TaxObject.code == "02").one().id, unit_price=Decimal("1500.00"), ) @@ -69,6 +76,7 @@ CATALOG_EXPECTATIONS = [ ("voucher-types", 5, "I"), ("payment-methods", 2, "PUE"), ("tax-objects", 4, "02"), + ("cfdi-uses", 24, "G03"), ] @@ -314,6 +322,105 @@ def test_invoice_item_rejects_concept_from_another_company(db): assert exc.value.status_code == 422 +def test_invoice_item_inherits_sat_keys_from_concept(db): + """La partida hereda las claves fiscales del concepto para quedar completa (CFDI).""" + concept = concepts_service.create_concept(db, _concept_payload(db), T, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-4"), T, C) + + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=1500), T, C + ) + assert item.product_service_id == concept.product_service_id + assert item.unit_of_measure_id == concept.unit_of_measure_id + assert item.tax_object_id == concept.tax_object_id + + +def test_invoice_item_sat_keys_sent_by_client_win_over_concept(db): + """Lo que el cliente envía manda: permite facturar con otra unidad de medida.""" + concept = concepts_service.create_concept(db, _concept_payload(db), T, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-5"), T, C) + other_unit = db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "KGM").one() + + item = invoices_service.create_item( + db, + InvoiceItemCreate( + invoice_id=invoice.id, concept_id=concept.id, unit_of_measure_id=other_unit.id, unit_amount=10 + ), + T, + C, + ) + assert item.unit_of_measure_id == other_unit.id + assert item.product_service_id == concept.product_service_id # el resto sí se hereda + + +def test_changing_item_concept_reinherits_keys(db): + """Cambiar el concepto de una partida revalida y vuelve a heredar del nuevo.""" + first = concepts_service.create_concept(db, _concept_payload(db), T, C) + second = concepts_service.create_concept( + db, _concept_payload(db, code="DESPACHO", ps_code="78141600"), T, C + ) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-6"), T, C) + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=first.id, unit_amount=100), T, C + ) + + updated = invoices_service.update_item( + db, item.id, InvoiceItemUpdate(concept_id=second.id), T, C + ) + assert updated.concept_id == second.id + assert updated.product_service_id == second.product_service_id + assert updated.concept == second.description + + +def test_updating_item_rejects_concept_from_another_tenant(db): + """El PATCH valida la referencia igual que el alta: no cruza tenants.""" + mine = concepts_service.create_concept(db, _concept_payload(db), T, C) + alien = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-7"), T, C) + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=mine.id, unit_amount=100), T, C + ) + + with pytest.raises(HTTPException) as exc: + invoices_service.update_item(db, item.id, InvoiceItemUpdate(concept_id=alien.id), T, C) + assert exc.value.status_code == 422 + + +# ---------- Claves fiscales del receptor (crm.accounts) ---------- + +def test_account_accepts_sat_fiscal_keys(db): + regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one() + cfdi_use = db.query(CfdiUse).filter(CfdiUse.code == "G03").one() + + account = accounts_service.create_account( + db, + AccountCreate(name="Cliente fiscal", tax_regime_id=regime.id, cfdi_use_id=cfdi_use.id), + T, + C, + ) + assert account.tax_regime_id == regime.id and account.cfdi_use_id == cfdi_use.id + + +def test_account_rejects_unknown_sat_fiscal_keys(db): + with pytest.raises(HTTPException) as exc: + accounts_service.create_account(db, AccountCreate(name="Cliente malo", cfdi_use_id=999999), T, C) + assert exc.value.status_code == 422 + + account = accounts_service.create_account(db, AccountCreate(name="Cliente ok"), T, C) + with pytest.raises(HTTPException) as exc: + accounts_service.update_account(db, account.id, AccountUpdate(tax_regime_id=999999), T, C) + assert exc.value.status_code == 422 + + +def test_account_free_text_fiscal_fields_are_preserved(db): + """El texto libre previo se conserva: las FK lo complementan, no lo sustituyen.""" + account = accounts_service.create_account( + db, AccountCreate(name="Cliente heredado", tax_regime="601", cfdi_use="G03"), T, C + ) + assert account.tax_regime == "601" and account.cfdi_use == "G03" + assert account.tax_regime_id is None and account.cfdi_use_id is None + + def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch): """Las facturas previas, sin claves del SAT, siguen listándose y generando PDF.""" stored = {} From f4ef6a037d07a1645f06e5d5caa4664906a4f406 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:50:26 -0500 Subject: [PATCH 10/10] =?UTF-8?q?feat(fin,crm):=20cat=C3=A1logo=20c=5FUsoC?= =?UTF-8?q?FDI=20y=20claves=20fiscales=20del=20receptor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra las decisiones pendientes 1 y 5. Agrega sat.cfdi_uses con su endpoint de solo lectura y amarra la ficha del cliente a los catálogos del SAT con crm.accounts.tax_regime_id y cfdi_use_id. Las columnas de texto libre tax_regime y cfdi_use se conservan intactas: la migración hace un backfill conservador que solo resuelve lo inequívoco (la clave del catálogo, o la descripción exacta sin distinguir mayúsculas ni espacios) y deja en NULL lo que no case, porque deducir el régimen de un receptor a partir de texto libre provoca CFDI rechazados. La UI muestra el texto anterior junto al selector para que el usuario elija la clave que corresponde. El selector de régimen se acota al tipo de persona de la cuenta, y el service valida ambas claves contra el catálogo. sync_catalogs ahora omite los catálogos cuya tabla todavía no existe: al correr el historial desde cero, la migración anterior la invoca antes de que se creen los catálogos agregados después. Las claves de c_UsoCFDI quedan pendientes de validación con el área Fiscal antes de producción, igual que el subset de c_ClaveProdServ; no se cargaron las banderas de persona física/moral ni la compatibilidad por régimen. Co-Authored-By: Claude Opus 5 (1M context) --- ...e2_sat_cfdi_uses_and_account_fiscal_fks.py | 84 +++++++++++++++++++ backend/api/v1/modules/crm/accounts/dto.py | 5 ++ backend/api/v1/modules/crm/accounts/models.py | 12 ++- .../api/v1/modules/crm/accounts/service.py | 22 ++++- backend/api/v1/modules/fin/catalogs/dto.py | 4 + backend/api/v1/modules/fin/catalogs/models.py | 13 +++ backend/api/v1/modules/fin/catalogs/routes.py | 12 +++ .../api/v1/modules/fin/catalogs/seed_data.py | 57 +++++++++++++ .../api/v1/modules/fin/catalogs/service.py | 5 ++ frontend/src/lib/api/crm/types.ts | 3 + frontend/src/lib/api/fin/catalogs.ts | 4 +- .../lib/components/crm/AccountFields.svelte | 57 ++++++++++++- .../dashboard/crm/cuentas/[id]/+page.svelte | 2 +- .../dashboard/crm/cuentas/nuevo/+page.svelte | 2 +- 14 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py diff --git a/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py new file mode 100644 index 0000000..14a832f --- /dev/null +++ b/backend/alembic/versions/f7a8b9c0d1e2_sat_cfdi_uses_and_account_fiscal_fks.py @@ -0,0 +1,84 @@ +"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts. + +Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega +``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los +catálogos, conservando las columnas de texto libre que ya existían. + +Revision ID: f7a8b9c0d1e2 +Revises: e6f7a8b9c0d1 +Create Date: 2026-08-07 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +from api.v1.modules.fin.catalogs.seed_data import sync_catalogs + +revision: str = "f7a8b9c0d1e2" +down_revision: Union[str, None] = "e6f7a8b9c0d1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ---------- sat.cfdi_uses ---------- + op.create_table( + "cfdi_uses", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=4), nullable=False), + sa.Column("description", sa.String(length=500), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + schema="sat", + ) + op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat") + op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat") + + # sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos + # que ya sembró la migración anterior. + sync_catalogs(op.get_bind()) + + # ---------- crm.accounts: claves fiscales del receptor ---------- + # Nullables: las cuentas existentes solo tienen el texto libre. + op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm") + op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm") + op.create_foreign_key( + "fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes", + ["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat", + ) + op.create_foreign_key( + "fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses", + ["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat", + ) + + # Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre + # contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción + # exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se + # queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor + # a partir de texto libre provoca CFDI rechazados. + for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]: + op.execute( + f""" + UPDATE crm.accounts AS a + SET {column}_id = c.id + FROM sat.{catalog} AS c + WHERE a.{column}_id IS NULL + AND a.{column} IS NOT NULL + AND ( + upper(btrim(a.{column})) = upper(c.code) + OR upper(btrim(a.{column})) = upper(c.description) + ) + """ + ) + + +def downgrade() -> None: + op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey") + op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey") + op.drop_column("accounts", "cfdi_use_id", schema="crm") + op.drop_column("accounts", "tax_regime_id", schema="crm") + op.drop_table("cfdi_uses", schema="sat") diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index b7aa564..95f6708 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -25,6 +25,9 @@ class AccountBase(BaseModel): # Fiscal tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) + # Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar. + tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor") + cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor") payment_method: str | None = Field(None, max_length=60) payment_form: str | None = Field(None, max_length=60) currency: str | None = Field(None, max_length=3) @@ -65,6 +68,8 @@ class AccountUpdate(BaseModel): website: str | None = Field(None, max_length=255) tax_regime: str | None = Field(None, max_length=120) cfdi_use: str | None = Field(None, max_length=60) + tax_regime_id: int | None = None + cfdi_use_id: int | None = None payment_method: str | None = Field(None, max_length=60) payment_form: str | None = Field(None, max_length=60) currency: str | None = Field(None, max_length=3) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 9f23d46..d91fdda 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -1,9 +1,10 @@ from decimal import Decimal -from sqlalchemy import Integer, Numeric, String, Text, text +from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK) from core.database import Base @@ -47,8 +48,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin): website: Mapped[str | None] = mapped_column(String(255), nullable=True) # ----- Información fiscal ----- + # Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario + # para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo. tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI + # Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI). + tax_regime_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.tax_regimes.id"), nullable=True + ) + cfdi_use_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True + ) payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda diff --git a/backend/api/v1/modules/crm/accounts/service.py b/backend/api/v1/modules/crm/accounts/service.py index 617edc6..2284a17 100644 --- a/backend/api/v1/modules/crm/accounts/service.py +++ b/backend/api/v1/modules/crm/accounts/service.py @@ -3,10 +3,24 @@ from datetime import datetime, timezone from fastapi import HTTPException, status from sqlalchemy.orm import Session +from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime + from .dto import AccountCreate, AccountUpdate from .models import Account +def _validate_sat_refs(db: Session, data: dict) -> None: + """Verifica las claves del SAT del receptor antes de guardar la cuenta.""" + for field, model, msg in [ + ("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"), + ("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"), + ]: + value = data.get(field) + if field in data and value is not None: + if db.query(model.id).filter(model.id == value).first() is None: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) + + def get_accounts( db: Session, tenant_id: int, @@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) - def create_account( db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Account: + data = payload.model_dump() + _validate_sat_refs(db, data) account = Account( - **payload.model_dump(), + **data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, @@ -75,7 +91,9 @@ def update_account( user_id: str | None = None, ) -> Account: account = get_account(db, account_id, tenant_id, company_id) - for field, value in payload.model_dump(exclude_unset=True).items(): + data = payload.model_dump(exclude_unset=True) + _validate_sat_refs(db, data) + for field, value in data.items(): setattr(account, field, value) account.updated_by = user_id db.commit() diff --git a/backend/api/v1/modules/fin/catalogs/dto.py b/backend/api/v1/modules/fin/catalogs/dto.py index 0a80605..4fc2848 100644 --- a/backend/api/v1/modules/fin/catalogs/dto.py +++ b/backend/api/v1/modules/fin/catalogs/dto.py @@ -55,3 +55,7 @@ class PaymentMethodResponse(SatCatalogItem): class TaxObjectResponse(SatCatalogItem): """``c_ObjetoImp``.""" + + +class CfdiUseResponse(SatCatalogItem): + """``c_UsoCFDI``.""" diff --git a/backend/api/v1/modules/fin/catalogs/models.py b/backend/api/v1/modules/fin/catalogs/models.py index 3c19338..4e31c9a 100644 --- a/backend/api/v1/modules/fin/catalogs/models.py +++ b/backend/api/v1/modules/fin/catalogs/models.py @@ -128,3 +128,16 @@ class TaxObject(Base, SatCatalogMixin): __table_args__ = {"schema": "sat"} code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True) + + +class CfdiUse(Base, SatCatalogMixin): + """``c_UsoCFDI`` — uso que el receptor le dará al comprobante. + + Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen + fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``). + """ + + __tablename__ = "cfdi_uses" + __table_args__ = {"schema": "sat"} + + code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True) diff --git a/backend/api/v1/modules/fin/catalogs/routes.py b/backend/api/v1/modules/fin/catalogs/routes.py index 8396ab8..5a385b6 100644 --- a/backend/api/v1/modules/fin/catalogs/routes.py +++ b/backend/api/v1/modules/fin/catalogs/routes.py @@ -18,6 +18,7 @@ from core.security import get_current_user from . import service from .dto import ( + CfdiUseResponse, PaymentFormResponse, PaymentMethodResponse, ProductServiceResponse, @@ -124,3 +125,14 @@ def list_tax_objects( ): """``c_ObjetoImp`` — si la partida es objeto de impuesto.""" return service.get_tax_objects(db, search, active_only) + + +@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse]) +def list_cfdi_uses( + search: str | None = _SEARCH, + active_only: bool = _ACTIVE_ONLY, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """``c_UsoCFDI`` — uso que el receptor le dará al comprobante.""" + return service.get_cfdi_uses(db, search, active_only) diff --git a/backend/api/v1/modules/fin/catalogs/seed_data.py b/backend/api/v1/modules/fin/catalogs/seed_data.py index 60ebe35..71beb47 100644 --- a/backend/api/v1/modules/fin/catalogs/seed_data.py +++ b/backend/api/v1/modules/fin/catalogs/seed_data.py @@ -49,6 +49,7 @@ products_services_table = _catalog_table("products_services") voucher_types_table = _catalog_table("voucher_types") payment_methods_table = _catalog_table("payment_methods") tax_objects_table = _catalog_table("tax_objects") +cfdi_uses_table = _catalog_table("cfdi_uses") # --------------------------------------------------------------------------- @@ -234,6 +235,48 @@ PRODUCTS_SERVICES: list[dict] = [ ] +# --------------------------------------------------------------------------- +# c_UsoCFDI +# --------------------------------------------------------------------------- +# Catálogo del uso que el receptor da al comprobante. Se siembran clave y +# descripción; **no** se cargan las banderas de persona física/moral ni la +# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del +# catálogo y equivocarla provoca rechazos al timbrar. +# +# Pendiente validación con área Fiscal antes de producción, igual que el subset de +# c_ClaveProdServ. + +CFDI_USES: list[dict] = [ + {"code": code, "description": description, "is_active": True} + for code, description in [ + ("G01", "Adquisición de mercancías"), + ("G02", "Devoluciones, descuentos o bonificaciones"), + ("G03", "Gastos en general"), + ("I01", "Construcciones"), + ("I02", "Mobiliario y equipo de oficina por inversiones"), + ("I03", "Equipo de transporte"), + ("I04", "Equipo de cómputo y accesorios"), + ("I05", "Dados, troqueles, moldes, matrices y herramental"), + ("I06", "Comunicaciones telefónicas"), + ("I07", "Comunicaciones satelitales"), + ("I08", "Otra maquinaria y equipo"), + ("D01", "Honorarios médicos, dentales y gastos hospitalarios"), + ("D02", "Gastos médicos por incapacidad o discapacidad"), + ("D03", "Gastos funerales"), + ("D04", "Donativos"), + ("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"), + ("D06", "Aportaciones voluntarias al SAR"), + ("D07", "Primas por seguros de gastos médicos"), + ("D08", "Gastos de transportación escolar obligatoria"), + ("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"), + ("D10", "Pagos por servicios educativos (colegiaturas)"), + ("S01", "Sin efectos fiscales"), + ("CP01", "Pagos"), + ("CN01", "Nómina"), + ] +] + + # Orden estable de sincronización: (tabla, filas). CATALOGS: list[tuple[sa.Table, list[dict]]] = [ (tax_regimes_table, TAX_REGIMES), @@ -244,6 +287,7 @@ CATALOGS: list[tuple[sa.Table, list[dict]]] = [ (voucher_types_table, VOUCHER_TYPES), (payment_methods_table, PAYMENT_METHODS), (tax_objects_table, TAX_OBJECTS), + (cfdi_uses_table, CFDI_USES), ] @@ -257,11 +301,24 @@ def sync_catalogs(connection) -> dict[str, int]: Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de la migración. + Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de + migraciones desde cero, una migración antigua invoca esta misma función cuando los + catálogos agregados después aún no se han creado. Cada uno se siembra en la + migración que lo crea. + Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la conexión de una sesión en pruebas. """ + inspector = sa.inspect(connection) + # La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None + # sobre SQLite), así que se resuelve el schema efectivo a mano. + schema_map = connection.get_execution_options().get("schema_translate_map") or {} + inserted: dict[str, int] = {} for table, rows in CATALOGS: + effective_schema = schema_map.get(table.schema, table.schema) + if not inspector.has_table(table.name, schema=effective_schema): + continue key = f"sat.{table.name}" inserted[key] = 0 for row in rows: diff --git a/backend/api/v1/modules/fin/catalogs/service.py b/backend/api/v1/modules/fin/catalogs/service.py index 6393fe0..7ea97b4 100644 --- a/backend/api/v1/modules/fin/catalogs/service.py +++ b/backend/api/v1/modules/fin/catalogs/service.py @@ -8,6 +8,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from .models import ( + CfdiUse, PaymentForm, PaymentMethod, ProductService, @@ -99,3 +100,7 @@ def get_payment_methods(db: Session, search=None, active_only=True) -> list[Paym def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]: return search_catalog(db, TaxObject, search, active_only) + + +def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]: + return search_catalog(db, CfdiUse, search, active_only) diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index f8082c4..3264f72 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -28,8 +28,11 @@ export interface Account { email: string | null; phone: string | null; website: string | null; + /** Texto libre histórico; lo que vale al timbrar son las claves del SAT de abajo. */ tax_regime: string | null; cfdi_use: string | null; + tax_regime_id: number | null; + cfdi_use_id: number | null; payment_method: string | null; payment_form: string | null; currency: string | null; diff --git a/frontend/src/lib/api/fin/catalogs.ts b/frontend/src/lib/api/fin/catalogs.ts index a4cfb21..66a52a6 100644 --- a/frontend/src/lib/api/fin/catalogs.ts +++ b/frontend/src/lib/api/fin/catalogs.ts @@ -90,5 +90,7 @@ export const satCatalogsAPI = { paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) => fetchCatalog('payment-methods', companyId, params), taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) => - fetchCatalog('tax-objects', companyId, params) + fetchCatalog('tax-objects', companyId, params), + cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) => + fetchCatalog('cfdi-uses', companyId, params) }; diff --git a/frontend/src/lib/components/crm/AccountFields.svelte b/frontend/src/lib/components/crm/AccountFields.svelte index 81d3757..f6f55d6 100644 --- a/frontend/src/lib/components/crm/AccountFields.svelte +++ b/frontend/src/lib/components/crm/AccountFields.svelte @@ -1,12 +1,45 @@