From f4ef6a037d07a1645f06e5d5caa4664906a4f406 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:50:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(fin,crm):=20cat=C3=A1logo=20c=5FUsoCFDI=20?= =?UTF-8?q?y=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 @@