feat(fin,crm): catálogo c_UsoCFDI y claves fiscales del receptor

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:50:26 -05:00
parent 15717314fd
commit f4ef6a037d
14 changed files with 273 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,3 +55,7 @@ class PaymentMethodResponse(SatCatalogItem):
class TaxObjectResponse(SatCatalogItem):
"""``c_ObjetoImp``."""
class CfdiUseResponse(SatCatalogItem):
"""``c_UsoCFDI``."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,5 +90,7 @@ export const satCatalogsAPI = {
paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('payment-methods', companyId, params),
taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params)
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params),
cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('cfdi-uses', companyId, params)
};

View File

@@ -1,12 +1,45 @@
<script lang="ts">
import type { AccountInput } from '$lib/api/crm';
import { satCatalogsAPI, type SatCatalogItem, type SatTaxRegime } from '$lib/api/fin';
import {
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
let { form = $bindable(), tab }: { form: AccountInput; tab: string } = $props();
// `companyId` solo se usa para consultar los catálogos del SAT del receptor.
let { form = $bindable(), tab, companyId = null }: { form: AccountInput; tab: string; companyId?: number | null } = $props();
let taxRegimes = $state<SatTaxRegime[]>([]);
let cfdiUses = $state<SatCatalogItem[]>([]);
// El régimen se acota al tipo de persona de la cuenta: una persona física no puede
// declararse en el 601 y viceversa. Sin tipo de persona se ofrecen todos.
const regimesForPersonType = $derived(
form.person_type === 'fisica'
? taxRegimes.filter((r) => r.applies_to_individual)
: form.person_type === 'moral'
? taxRegimes.filter((r) => r.applies_to_legal_entity)
: taxRegimes
);
$effect(() => {
const cid = companyId;
if (!cid || tab !== 'fiscal') return;
void loadCatalogs(cid);
});
async function loadCatalogs(cid: number) {
try {
[taxRegimes, cfdiUses] = await Promise.all([
satCatalogsAPI.taxRegimes(cid),
satCatalogsAPI.cfdiUses(cid)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
}
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
@@ -35,8 +68,26 @@
</div>
{:else if tab === 'fiscal'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Régimen fiscal</span>
<select class={inputCls} bind:value={form.tax_regime_id}>
<option value={null}>Sin especificar</option>
{#each regimesForPersonType as r (r.id)}<option value={r.id}>{r.code} {r.description}</option>{/each}
</select>
{#if !form.tax_regime_id && form.tax_regime}
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.tax_regime}». Elige la clave del SAT que corresponde.</span>
{/if}
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Uso de CFDI</span>
<select class={inputCls} bind:value={form.cfdi_use_id}>
<option value={null}>Sin especificar</option>
{#each cfdiUses as u (u.id)}<option value={u.id}>{u.code} {u.description}</option>{/each}
</select>
{#if !form.cfdi_use_id && form.cfdi_use}
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.cfdi_use}». Elige la clave del SAT que corresponde.</span>
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>

View File

@@ -104,7 +104,7 @@
</div>
{#if activeTab.kind === 'info'}
<AccountFields bind:form tab={tab} />
<AccountFields bind:form tab={tab} {companyId} />
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
</div>

View File

@@ -65,7 +65,7 @@
{/each}
</div>
<AccountFields bind:form {tab} />
<AccountFields bind:form {tab} {companyId} />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>