+{/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}
+
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 @@