10 Commits

Author SHA1 Message Date
f4ef6a037d 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>
2026-08-07 17:50:26 -05:00
15717314fd feat(fin): la partida hereda las claves del SAT de su concepto
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) <noreply@anthropic.com>
2026-08-07 17:50:12 -05:00
ce09e0d30a feat(fin): partidas de factura capturadas desde el catálogo de conceptos
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) <noreply@anthropic.com>
2026-08-07 17:24:50 -05:00
ae0664e987 refactor(fin): conceptos con páginas dedicadas en vez de modal
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) <noreply@anthropic.com>
2026-08-07 17:10:57 -05:00
cb2acb11fc test(fin): cobertura de catálogos, conceptos y emisor
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) <noreply@anthropic.com>
2026-08-07 16:58:09 -05:00
5a112a0171 feat(fin): pantallas de conceptos y datos fiscales del emisor
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) <noreply@anthropic.com>
2026-08-07 16:58:09 -05:00
8d9db3505d feat(fin): amarre de facturas y partidas a catálogos SAT
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) <noreply@anthropic.com>
2026-08-07 16:57:51 -05:00
b8b8311ece feat(fin): datos fiscales del emisor por empresa
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) <noreply@anthropic.com>
2026-08-07 16:57:39 -05:00
9cf142add6 feat(fin): CRUD de conceptos con relación 1:1 a clave ProdServ
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) <noreply@anthropic.com>
2026-08-07 16:57:39 -05:00
e24435c74b feat(fin): catálogos SAT en schema sat con seeds idempotentes
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) <noreply@anthropic.com>
2026-08-07 16:57:26 -05:00
97 changed files with 4075 additions and 5445 deletions

View File

@@ -1,90 +0,0 @@
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-07-22 00:00:00.000000
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
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
SCHEMA = "crm"
def upgrade() -> None:
# ----- crm.catalog_items -----
op.create_table(
"catalog_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("catalog", sa.String(length=60), nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
sa.Column("parent_code", sa.String(length=64), nullable=True),
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("extra", sa.JSON(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=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=SCHEMA,
)
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
op.create_index(
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
)
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
op.create_index(
"uq_crm_catalog_items_global",
"catalog_items",
["catalog", "code"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NULL"),
)
op.create_index(
"uq_crm_catalog_items_tenant",
"catalog_items",
["catalog", "code", "tenant_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NOT NULL"),
)
# ----- columnas nuevas -----
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Proveedores: "otro" de la clasificación múltiple.
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
op.drop_table("catalog_items", schema=SCHEMA)

View File

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

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

@@ -36,12 +36,6 @@ class TokenResponseDTO(BaseModel):
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
# Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED.
# Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y
# que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token
# de arriba sigue siendo el de Keycloak (para llamadas al Hub).
session_token: Optional[str] = None
session_id: Optional[str] = None
class Config:
json_schema_extra = {
@@ -58,12 +52,6 @@ class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
# Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el
# inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el
# refresh del token KC contra el Hub falla ("Token is not active" del relay).
session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)")
# session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser).
session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)")
class UserInfoResponseDTO(BaseModel):

View File

@@ -24,12 +24,6 @@ from .dto import (
)
from .service import AuthService
import logging
from typing import Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@@ -408,16 +402,10 @@ async def dev_login():
@router.get("/my-companies")
async def get_my_companies(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Retorna las compañías accesibles para el usuario actual.
Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con
el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se
garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se
resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario).
STUB: implementa con tu modelo de compañías.
En dev-local retorna una compañía ficticia para que el dashboard funcione.
"""
from core.config import settings
@@ -427,239 +415,8 @@ async def get_my_companies(
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"name": "Empresa Dev Local",
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"rfc": None,
"logo": None,
"is_active": True,
}]
from core.security import (
resolve_effective_tenant_id_from_user,
_ensure_user_tenant_for_company,
)
from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
# defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de
# su tenant. Así cualquier usuario del mismo tenant (misma organización del
# Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se
# asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin
# asigna el rol; el primer usuario recibe super_admin vía /permissions/me).
if tenant_id:
tenant_id = int(tenant_id)
company_ids = [
int(r[0])
for r in db.execute(
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
{"tid": tenant_id},
).fetchall()
]
if not company_ids:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
created = db.execute(
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
{"tid": tenant_id, "name": default_name},
).fetchone()
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
company_ids = [int(created[0])]
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
# Auto-ligado por tenant (solo membresía, sin rol).
if user_id:
for cid in company_ids:
try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
except Exception as exc:
logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc)
# 2) Compañías por MEMBRESÍA (user_tenants user_company_roles) → funciona
# también para hub_admin sin tenant en el token: verá las compañías que creó
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
if not user_id:
return []
rows = db.execute(
text(
"""
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
FROM a76.company c
LEFT JOIN core.tenants t ON t.id = c.tenant_id
WHERE c.id IN (
SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
UNION
SELECT company_id FROM core.user_company_roles
WHERE user_id = :uid AND is_active
)
ORDER BY c.id
"""
),
{"uid": str(user_id)},
).fetchall()
return [
{
"id": int(r[0]),
"name": r[1] or "Empresa",
"tenant_id": int(r[4]),
"tenant_name": r[5],
"tenant_slug": r[6],
"rfc": r[2],
"logo": r[3],
"is_active": True,
}
for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
rfc: Optional[str] = None
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
"""
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
responde, no bloquea (se devuelven los tenants ya sincronizados).
"""
import httpx
from sqlalchemy import text as _text
from core.config import settings
from core import session_store
from api.v1.modules.core.tenants.models import Tenant, TenantType
sid = request.cookies.get("crm_sid") if request else None
kc_token = None
if sid:
sess = session_store.get_session(sid)
kc_token = (sess or {}).get("access_token")
if not kc_token:
return
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(
f"{settings.HUB_URL}api/v1/hub/tenants",
headers={"Authorization": f"Bearer {kc_token}"},
)
if r.status_code != 200:
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
return
payload = r.json()
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
for t in items:
tid = t.get("id")
if tid is None:
continue
name = t.get("name") or t.get("display_name") or t.get("slug")
slug = t.get("slug") or f"tenant-{tid}"
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
if existing:
if name and existing.name != name:
existing.name = name
else:
db.add(Tenant(
id=int(tid), name=name or slug, slug=slug,
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
))
db.commit()
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
db.commit()
except Exception as exc:
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
try:
db.rollback()
except Exception:
pass
@router.get("/assignable-tenants")
async def assignable_tenants(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
con tenant ve el suyo.
"""
from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
if is_hub_admin(current_user):
# Sincroniza automáticamente los tenants del Workspace antes de listar.
await _sync_tenants_from_hub(request, db)
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
tid = resolve_effective_tenant_id_from_user(current_user)
if tid:
t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
# Implementa aquí la consulta real a tu tabla de compañías.
return []
@router.post("/companies", status_code=201)
async def create_company(
data: _CreateCompanyDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
"""
from sqlalchemy import text as _text
from api.v1.modules.core.tenants.models import Tenant
from core.security import (
resolve_effective_tenant_id_from_user,
is_hub_admin,
_ensure_user_tenant_for_company,
)
name = (data.name or "").strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
tid = int(data.tenant_id)
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
# Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant;
# un usuario ligado a un tenant, solo en el suyo.
if not is_hub_admin(current_user):
own = resolve_effective_tenant_id_from_user(current_user)
if own is None or int(own) != tid:
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
created = db.execute(
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
{"t": tid, "n": name, "r": (data.rfc or None)},
).fetchone()
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
cid = int(created[0])
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
except Exception as exc:
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}

View File

@@ -213,160 +213,55 @@ class AuthService:
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Decodifica una sesión local del CRM (HS256) verificando la firma pero
SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
o None si la firma no valida o no es una sesión local del CRM.
"""
if not session_token:
return None
try:
claims = jwt.decode(
session_token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"verify_exp": False},
)
except JWTError:
return None
if not claims.get("crm_session") or claims.get("source") != "local":
return None
return claims
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Construye los claims de la sesión local a partir del token KC (decode)."""
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
claims: Dict[str, Any] = dict(kc_claims)
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
if data.get("tenant_id") is not None:
claims["tenant_id"] = data.get("tenant_id")
if data.get("tenant_slug") is not None:
claims["tenant_slug"] = data.get("tenant_slug")
return claims
async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
responde, se cae al decode del token KC. Así la sesión local sabe si el
usuario es hub_admin sin volver a consultar al Hub en cada request.
"""
from core.security import verify_token
claims: Dict[str, Any] = {}
try:
info = await verify_token(data.get("access_token", ""))
if isinstance(info, dict):
claims = dict(info)
except Exception as exc:
logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)
if not claims:
return self._session_claims_from_kc(data)
# tenant_id/tenant_slug explícitos del Hub tienen precedencia.
if data.get("tenant_id") is not None:
claims["tenant_id"] = data.get("tenant_id")
if data.get("tenant_slug") is not None:
claims["tenant_slug"] = data.get("tenant_slug")
return claims
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca la sesión.
- Intenta el refresh del token KC contra el Hub (comportamiento histórico).
- Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
(patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
- Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
Refresca el access token usando el Hub
"""
from datetime import datetime, timezone
session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
prev_sst = prev_claims.get("sst") if prev_claims else None
prev_session_id = refresh_data.session_id if session_enabled else None
# Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
# mande el cliente (puede estar desactualizado). Fail-silent.
kc_refresh = refresh_data.refresh_token
if session_enabled and prev_session_id:
from core import session_store
sess = session_store.get_session(prev_session_id)
if sess and sess.get("refresh_token"):
kc_refresh = sess["refresh_token"]
# ── Intento de refresh del token KC contra el Hub ────────────────────────
kc_ok = False
data: Optional[Dict[str, Any]] = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": kc_refresh},
json=refresh_data.model_dump()
)
kc_ok = response.status_code == 200
if kc_ok:
if response.status_code == 200:
data = response.json()
else:
logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
except Exception as exc:
logger.warning("Hub inalcanzable en refresh: %s", exc)
kc_ok = False
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
# ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
if kc_ok and data is not None:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
)
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
resp = TokenResponseDTO(**data)
if session_enabled:
from core import local_session, session_store
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
claims = await self._session_claims(data)
new_access = data.get("access_token", "")
new_refresh = data.get("refresh_token", "")
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
if prev_session_id and session_store.get_session(prev_session_id):
session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
resp.session_id = prev_session_id
else:
resp.session_id = session_store.create_session(new_access, new_refresh, start)
resp.session_token = local_session.mint_session_token(claims, session_start=start)
return resp
# El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
# central de Keycloak (no hay re-emisión local de fallback). El usuario
# re-entra por el App Launcher. La sesión local de larga duración evita el
# bucle: el refresh solo se intenta al expirar la sesión local por
# inactividad (idle), no cada ~60s como con el token KC crudo.
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""

View File

@@ -37,33 +37,13 @@ async def create_invite(
required_permissions=["user.create"],
)
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
tenant_slug: str = current_user.get("tenant_slug") or ""
if not tenant_slug:
from sqlalchemy import text as _text
row = db.execute(
_text(
"SELECT t.slug FROM a76.company c "
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
),
{"c": data.company_id},
).first()
if row and row[0]:
tenant_slug = row[0]
created_by: str = current_user.get("sub") or ""
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
from core.hub_token import get_hub_access_token
kc_token = await get_hub_access_token(request)
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
user_access_token=kc_token or credentials.credentials,
user_access_token=credentials.credentials,
)

View File

@@ -53,17 +53,12 @@ async def get_user_statistics(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
@@ -89,19 +84,12 @@ async def list_users(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)

View File

@@ -18,15 +18,16 @@ class AccountBase(BaseModel):
# Comercial
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None # observaciones generales
# 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)
@@ -61,14 +62,14 @@ class AccountUpdate(BaseModel):
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None
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
@@ -39,19 +40,25 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# ----- Información comercial -----
# Clasificación: importador | exportador | ambos
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Texto libre cuando el medio de contacto es "otro"
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
# ----- 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

@@ -1,45 +0,0 @@
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
from pydantic import BaseModel, ConfigDict, Field
class CatalogItemBase(BaseModel):
code: str = Field(..., max_length=64)
label: str = Field(..., max_length=255)
parent_catalog: str | None = Field(None, max_length=60)
parent_code: str | None = Field(None, max_length=64)
sort_order: int = 0
is_active: bool = True
class CatalogItemCreate(CatalogItemBase):
pass
class CatalogItemUpdate(BaseModel):
"""PATCH: todos los campos opcionales."""
code: str | None = Field(None, max_length=64)
label: str | None = Field(None, max_length=255)
parent_code: str | None = Field(None, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
class CatalogItemResponse(CatalogItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
catalog: str
tenant_id: int | None
is_system: bool
class CatalogMeta(BaseModel):
"""Metadata de un catálogo para la pantalla de administración."""
catalog: str
label: str
scope: str # 'global' | 'tenant'
is_system: bool
count: int

View File

@@ -1,54 +0,0 @@
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
(descripción que se visualiza).
Alcance:
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
Los catálogos dependientes (p. ej. Estado depende de País) usan
``parent_catalog`` + ``parent_code`` para filtrarse.
"""
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from core.database import Base
class CatalogItem(Base):
__tablename__ = "catalog_items"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Dependencia (Estado→País, Municipio→Estado, …)
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@@ -1,10 +1,6 @@
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
(Aduanasoft) y catálogos propios de cada cliente (tenant).
"""
from fastapi import APIRouter, Depends, Query, status
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -12,9 +8,7 @@ from core.security import get_current_user
from ..accounts.models import Account
from ..suppliers.models import Supplier
from . import service as catalog_service
from .data import INCOTERMS, PARTICIPANT_ROLES
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
router = APIRouter()
@@ -37,76 +31,6 @@ def list_participant_roles(
return PARTICIPANT_ROLES
# ----------------------------------------------------------------------------
# Catálogos de referencia (CRUD) — T2026-07-081/082
# ----------------------------------------------------------------------------
@router.get("/catalogs", response_model=list[CatalogMeta])
def list_catalog_meta(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
return catalog_service.list_meta(db, current_user["tenant_id"])
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
def list_catalog_items(
catalog: str,
company_id: int = Query(..., description="Company ID"),
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
include_inactive: bool = Query(False),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
return catalog_service.list_items(
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
)
@router.post(
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
)
def create_catalog_item(
catalog: str,
data: CatalogItemCreate,
company_id: int = Query(..., description="Company ID"),
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Inserta una opción en un catálogo."""
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
def update_catalog_item(
catalog: str,
item_id: int,
data: CatalogItemUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Edita una opción de catálogo."""
return catalog_service.update_item(db, catalog, item_id, data, current_user)
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_catalog_item(
catalog: str,
item_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
catalog_service.delete_item(db, catalog, item_id, current_user)
@router.get("/participants")
def list_participants(
company_id: int = Query(..., description="Company ID"),

View File

@@ -1,68 +0,0 @@
"""Siembra de catálogos globales (Aduanasoft) del CRM.
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
"""
import logging
from sqlalchemy.orm import Session
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS
logger = logging.getLogger(__name__)
def seed_global_catalogs(db: Session) -> dict:
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
summary: dict[str, int] = {}
for catalog, meta in GLOBAL_CATALOGS.items():
is_system = bool(meta.get("is_system", False))
existing = {
row.code
for row in db.query(CatalogItem.code).filter(
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
)
}
added = 0
for order, item in enumerate(meta["items"]):
if item["code"] in existing:
continue
db.add(
CatalogItem(
catalog=catalog,
code=item["code"],
label=item["label"],
parent_catalog=item.get("parent_catalog"),
parent_code=item.get("parent_code"),
tenant_id=None,
sort_order=order,
is_active=True,
is_system=is_system,
)
)
added += 1
if added:
summary[catalog] = added
db.commit()
total = sum(summary.values())
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
return summary
def _run() -> None:
from core.database import CoreSessionLocal
db = CoreSessionLocal()
try:
result = seed_global_catalogs(db)
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
finally:
db.close()
if __name__ == "__main__":
_run()

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
"""Lógica de negocio de los catálogos de referencia del CRM."""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from core.security import is_hub_admin
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
# Metadata de catálogos (labels y si el cliente puede llenarlos).
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
def _require_known(catalog: str) -> None:
if catalog not in KNOWN_CATALOGS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
out: list[CatalogMeta] = []
for key, label in CATALOG_LABELS.items():
is_tenant = key in TENANT_CATALOG_KEYS
count = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == key,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
.count()
)
out.append(
CatalogMeta(
catalog=key,
label=label,
scope="tenant" if is_tenant else "global",
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
count=count,
)
)
return out
def list_items(
db: Session,
catalog: str,
tenant_id: int,
parent_code: str | None = None,
include_inactive: bool = False,
) -> list[CatalogItem]:
_require_known(catalog)
q = db.query(CatalogItem).filter(
CatalogItem.catalog == catalog,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
if not include_inactive:
q = q.filter(CatalogItem.is_active.is_(True))
if parent_code:
q = q.filter(CatalogItem.parent_code == parent_code)
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
- scope 'tenant' (default) → se guarda en el tenant del usuario.
"""
wants_global = scope == "global"
if wants_global:
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
)
return None
return int(current_user["tenant_id"])
def create_item(
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
) -> CatalogItem:
_require_known(catalog)
target_tenant = _resolve_write_scope(catalog, scope, current_user)
# No duplicar por (catalog, code, tenant_id)
exists = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == catalog,
CatalogItem.code == data.code,
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
)
.first()
)
if exists:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
)
item = CatalogItem(
catalog=catalog,
code=data.code,
label=data.label,
parent_catalog=data.parent_catalog,
parent_code=data.parent_code,
tenant_id=target_tenant,
sort_order=data.sort_order,
is_active=data.is_active,
is_system=False,
created_by=current_user.get("sub"),
updated_by=current_user.get("sub"),
)
db.add(item)
db.commit()
db.refresh(item)
return item
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
_require_known(catalog)
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
if item.tenant_id is None:
# Global (Aduanasoft): solo hub_admin.
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
)
elif item.tenant_id != int(current_user["tenant_id"]):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
return item
def update_item(
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
) -> CatalogItem:
item = _get_writable(db, catalog, item_id, current_user)
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
for field, value in payload.items():
setattr(item, field, value)
item.updated_by = current_user.get("sub")
db.commit()
db.refresh(item)
return item
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
item = _get_writable(db, catalog, item_id, current_user)
if item.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
)
db.delete(item)
db.commit()

View File

@@ -13,7 +13,6 @@ class SupplierBase(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str = Field("active", max_length=20)
classifications: list[str] = Field(default_factory=list)
classification_other: str | None = Field(None, max_length=120)
# Comercial
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
@@ -53,7 +52,6 @@ class SupplierUpdate(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str | None = Field(None, max_length=20)
classifications: list[str] | None = None
classification_other: str | None = Field(None, max_length=120)
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
countries: list[str] | None = None

View File

@@ -30,8 +30,6 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
# Texto libre cuando la clasificación incluye "otro"
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
# ----- Información comercial -----
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1 @@
"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura."""

View File

@@ -0,0 +1,61 @@
"""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``."""
class CfdiUseResponse(SatCatalogItem):
"""``c_UsoCFDI``."""

View File

@@ -0,0 +1,143 @@
"""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)
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

@@ -0,0 +1,138 @@
"""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 (
CfdiUseResponse,
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)
@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

@@ -0,0 +1,336 @@
"""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")
cfdi_uses_table = _catalog_table("cfdi_uses")
# ---------------------------------------------------------------------------
# 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 0507; 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", ""),
("MTQ", "Metro cúbico", ""),
("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"),
]
]
# ---------------------------------------------------------------------------
# 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),
(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),
(cfdi_uses_table, CFDI_USES),
]
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.
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:
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

View File

@@ -0,0 +1,106 @@
"""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 (
CfdiUse,
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)
def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]:
return search_catalog(db, CfdiUse, search, active_only)

View File

@@ -0,0 +1 @@
"""Catálogo de conceptos de facturación por empresa."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,50 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
return obj
# 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")
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:
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]
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,
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)
@@ -344,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))

View File

@@ -0,0 +1 @@
"""Datos fiscales del emisor por empresa."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,19 +42,6 @@ class Settings(BaseSettings):
PERMISSION_CACHE_ENABLED: bool = True
PERMISSION_CACHE_TTL_SECONDS: int = 300
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
#
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
SESSION_STORE_ENABLED: bool = False
SESSION_IDLE_MINUTES: int = 30
SESSION_MAX_HOURS: int = 10
# Synchronization
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"

View File

@@ -1,67 +0,0 @@
"""
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
"""
import logging
import time
from typing import Optional
import httpx
from jose import jwt
from core.config import settings
from core import session_store
logger = logging.getLogger(__name__)
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
"""True si el token KC no está expirado (con margen)."""
try:
claims = jwt.get_unverified_claims(token)
exp = claims.get("exp")
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
except Exception:
return False
async def get_hub_access_token(request) -> Optional[str]:
"""
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
"""
sid = request.cookies.get("crm_sid") if request is not None else None
if not sid:
return None
sess = session_store.get_session(sid)
if not sess:
return None
access = sess.get("access_token")
refresh = sess.get("refresh_token")
if access and _kc_exp_ok(access):
return access
if refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": refresh},
)
if r.status_code == 200:
data = r.json()
new_access = data.get("access_token") or access
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
return new_access
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
except Exception as exc:
logger.warning("get_hub_access_token: refresh falló: %s", exc)
return access

View File

@@ -1,94 +0,0 @@
"""
Sesión local del CRM (patrón SIWEB).
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
(no hay re-emisión de fallback).
Marcadores del token:
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
token dev-local (dev_local: True).
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
- exp → sliding por inactividad (idle); se
re-emite en cada refresh mientras no se supere el cap.
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
"""
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
# Claims de identidad que se propagan del token KC a la sesión local.
_IDENTITY_CLAIMS = (
"sub", "email", "preferred_username", "username", "name",
"given_name", "family_name", "first_name", "last_name",
"tenant_id", "tenant_slug", "roles", "permissions",
"is_hub_admin", "avatar_url",
)
def _now_epoch() -> int:
return int(datetime.now(timezone.utc).timestamp())
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
"""
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
no se provee, se usa el momento actual (sesión nueva).
"""
now = _now_epoch()
sst = int(session_start) if session_start else now
payload: Dict[str, Any] = {
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
}
payload.update({
"source": "local",
"crm_session": True,
"sst": sst,
"iat": now,
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
})
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
"""
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
Nunca lanza (para poder encadenar con la validación contra el Hub).
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except JWTError:
return None
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
if not payload.get("crm_session") or payload.get("source") != "local":
return None
# Cap absoluto de vida de sesión (independiente del sliding por idle).
sst = payload.get("sst")
if isinstance(sst, (int, float)):
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
return None
return payload
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
sst = payload.get("sst")
return int(sst) if isinstance(sst, (int, float)) else None

View File

@@ -3,7 +3,6 @@ import time
import httpx
from datetime import datetime, timezone
from typing import Callable, Optional
from cachetools import TTLCache
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
@@ -13,11 +12,6 @@ from .security import get_tenant_from_token, verify_token, get_active_system
logger = logging.getLogger(__name__)
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
# los cambios de licencia se propaguen en minutos.
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
def _normalize_text(value: str | None) -> str:
if not value:
@@ -151,18 +145,6 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
token = auth_header.split(" ")[1]
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
# guardado en valkey y se cachea por tenant.
if getattr(settings, "SESSION_STORE_ENABLED", False):
try:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
except Exception:
local_claims = None
if local_claims is not None:
return await self._handle_local_session_license(request, call_next, local_claims)
tenant_override = request.headers.get("X-Tenant-Override")
if not tenant_override:
# Fallback para flujos SSO cuando el override no viaja en header.
@@ -325,136 +307,6 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
}
)
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
"""
Valida licencia para una sesión local del CRM (patrón SIWEB).
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
valkey (refrescándolo si está vencido) para consultar verify-license, con
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
permite el paso: la sesión local se emitió tras un login válido (el App
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
"""
from core import session_store
tenant_key = str(local_claims.get("tenant_id") or "")
cached = _license_cache.get(tenant_key) if tenant_key else None
if cached == "valid":
return await call_next(request)
if isinstance(cached, str) and cached.startswith("invalid:"):
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
)
tenant_override = (
tenant_key
or request.cookies.get("sso_tenant_id")
or request.cookies.get("sso_tenant_pub")
or ""
)
sid = request.cookies.get("crm_sid")
sess = session_store.get_session(sid) if sid else None
kc_token = (sess or {}).get("access_token") or ""
kc_refresh = (sess or {}).get("refresh_token") or ""
async def _verify(tok: str):
if not tok:
return None
headers = {"Authorization": f"Bearer {tok}"}
if tenant_override:
headers["X-Tenant-Override"] = str(tenant_override)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
return await client.get(
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
)
except Exception as exc:
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
return None
resp = await _verify(kc_token)
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
needs_refresh = resp is None or resp.status_code == 401
if not needs_refresh and resp.status_code == 200:
try:
_d = resp.json()
except Exception:
_d = {}
if not _d.get("valid", False) and _is_token_issue_message(
_d.get("message"), _d.get("detail"), _d.get("reason")
):
needs_refresh = True
if needs_refresh and kc_refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
rr = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": kc_refresh},
)
if rr.status_code == 200:
nt = rr.json()
kc_token = nt.get("access_token") or kc_token
if sid:
session_store.update_session_tokens(
sid, kc_token, nt.get("refresh_token") or kc_refresh
)
resp = await _verify(kc_token)
else:
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
except Exception as exc:
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
if resp is not None and resp.status_code == 200:
try:
data = resp.json()
except Exception:
data = {}
if data.get("valid", False):
expires_at_str = data.get("expires_at")
if expires_at_str:
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
if tenant_key:
_license_cache[tenant_key] = f"invalid:{msg}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
)
except (ValueError, TypeError):
pass
if tenant_key:
_license_cache[tenant_key] = "valid"
request.state.license_info = data
return await call_next(request)
message = data.get("message", "Sin licencia asignada para este tenant")
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
if tenant_key:
_license_cache[tenant_key] = f"invalid:{message}"
return JSONResponse(
status_code=402,
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
)
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
# bucle de 401 por el bug de refresh del Hub.
logger.warning(
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
tenant_key,
)
return await call_next(request)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""

View File

@@ -47,19 +47,6 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
if cache_key in token_cache:
return token_cache[cache_key]
# Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada
# (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh
# del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que
# elimina el bucle de login. verify_session_token retorna None para tokens KC
# (RS256), así que no interfiere con el flujo normal.
if settings.SESSION_STORE_ENABLED:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
if local_claims is not None:
token_cache[cache_key] = local_claims
return local_claims
# Shortcut para tokens de desarrollo local
if settings.DEV_LOCAL_AUTH:
try:
@@ -666,20 +653,6 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user)
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
# por compañía seleccionada cuando el token no está ligado a un tenant.
if tenant_id is None and company_id:
try:
from sqlalchemy import text as _text
row = db.execute(
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row and row[0] is not None:
tenant_id = int(row[0])
except Exception as exc:
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
# Se reemplazó el antiguo "admin" in realm_access.roles para que la

View File

@@ -1,111 +0,0 @@
"""
Store de sesión en Valkey/Redis (patrón SIWEB).
Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por
un session_id opaco. La app usa la sesión local firmada (ver core.local_session)
para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub
(provisioning, my-apps, my-tenants), refrescándolos best-effort.
Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op
y la sesión local firmada sigue sosteniendo la app.
"""
import json
import logging
import uuid
from typing import Optional
from core.config import settings
try:
import redis # type: ignore
except Exception: # pragma: no cover - redis es opcional en algunos entornos
redis = None # type: ignore
logger = logging.getLogger(__name__)
_KEY_PREFIX = "crm:session:"
_client = None
def _get_client():
"""Cliente Redis/Valkey compartido (perezoso). None si no está disponible."""
global _client
if redis is None:
return None
if _client is None:
try:
_client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True)
except Exception as exc:
logger.warning("session_store_init_failed: %s", exc)
return None
return _client
def _ttl_seconds() -> int:
# La sesión en valkey vive como máximo lo que la vida absoluta de la sesión.
return settings.SESSION_MAX_HOURS * 3600
def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]:
"""Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está)."""
client = _get_client()
if client is None:
return None
session_id = str(uuid.uuid4())
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": int(session_start),
})
try:
client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data)
return session_id
except Exception as exc:
logger.warning("session_store_create_failed: %s", exc)
return None
def get_session(session_id: str) -> Optional[dict]:
"""Devuelve {access_token, refresh_token, sst} de la sesión, o None."""
client = _get_client()
if client is None or not session_id:
return None
try:
raw = client.get(f"{_KEY_PREFIX}{session_id}")
return json.loads(raw) if raw else None
except Exception as exc:
logger.warning("session_store_get_failed: %s", exc)
return None
def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None:
"""Actualiza los tokens KC de una sesión existente conservando su TTL y su sst."""
client = _get_client()
if client is None or not session_id:
return
try:
key = f"{_KEY_PREFIX}{session_id}"
ttl = client.ttl(key)
if ttl and ttl > 0:
existing = client.get(key)
sst = json.loads(existing).get("sst") if existing else None
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": sst,
})
client.setex(key, ttl, data)
except Exception as exc:
logger.warning("session_store_update_failed: %s", exc)
def delete_session(session_id: str) -> None:
"""Elimina la sesión (logout). Fail-silent."""
client = _get_client()
if client is None or not session_id:
return
try:
client.delete(f"{_KEY_PREFIX}{session_id}")
except Exception as exc:
logger.warning("session_store_delete_failed: %s", exc)

View File

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

View File

@@ -0,0 +1,444 @@
"""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, 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,
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
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_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"),
)
# ---------- 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"),
("cfdi-uses", 24, "G03"),
]
@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
# 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,
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_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 = {}
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

View File

@@ -1,74 +0,0 @@
"""
Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session.
Lógica pura (firma HS256 + claims); no requiere BD ni valkey.
"""
from datetime import datetime, timezone
from jose import jwt
from core.config import settings
from core.local_session import mint_session_token, verify_session_token, session_start_of
def _now() -> int:
return int(datetime.now(timezone.utc).timestamp())
def test_round_trip_conserva_identidad():
claims = {
"sub": "kc-user-123",
"email": "user@example.com",
"tenant_id": 11,
"tenant_slug": "aduanasoft",
"is_hub_admin": True,
}
token = mint_session_token(claims)
out = verify_session_token(token)
assert out is not None
assert out["sub"] == "kc-user-123"
assert out["tenant_id"] == 11
assert out["tenant_slug"] == "aduanasoft"
assert out["is_hub_admin"] is True
assert out["source"] == "local"
assert out["crm_session"] is True
assert isinstance(out["sst"], int)
def test_cap_absoluto_rechaza_sesion_vieja():
# session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle.
old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120)
token = mint_session_token({"sub": "x"}, session_start=old_start)
assert verify_session_token(token) is None
def test_preserva_session_start():
start = _now() - 60
token = mint_session_token({"sub": "x"}, session_start=start)
out = verify_session_token(token)
assert out is not None
assert session_start_of(out) == start
def test_firma_alterada_se_rechaza():
token = mint_session_token({"sub": "x"})
# Alterar el último carácter de la firma invalida el token.
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert verify_session_token(tampered) is None
def test_token_no_crm_se_rechaza():
# Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse.
other = jwt.encode(
{"sub": "x", "exp": _now() + 600},
settings.SECRET_KEY,
algorithm="HS256",
)
assert verify_session_token(other) is None
def test_token_basura_se_rechaza():
assert verify_session_token("no-es-un-jwt") is None
assert verify_session_token("") is None

View File

@@ -1,162 +0,0 @@
# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga
> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de
> datos de prod. **Este runbook no lo ejecuta ningún agente automático.**
> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario`
> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar.
>
> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod
> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes.
Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local
(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo
completo antes de tocar prod.
---
## 0. Alcance del cambio
- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub
(App Launcher → `/auth/sso?relay=<uuid>``POST {HUB_URL}/api/v1/auth/sso-exchange`).
- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey**
por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`.
- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril
(Ventas, Operaciones, Facturación, Consulta); permisos por módulo.
**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`,
`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes
de desplegar (paso 5). No corras migraciones a ciegas.
---
## 1. Prerrequisitos de infraestructura
- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod.
- [ ] Docker + Docker Compose en el server.
- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`,
`celery_worker`, `celery_beat`.
- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`).
- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**.
- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos.
---
## 2. Variables de entorno (`.env` en el server de prod)
Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de
producción**. Nunca subas el `.env` con secretos al repo.
```env
# --- Seguridad ---
ENVIRONMENT=production
DEV_LOCAL_AUTH=false
SECRET_KEY=<openssl rand -hex 32>
# --- Sesión local (patrón SIWEB) ---
SESSION_STORE_ENABLED=true
SESSION_IDLE_MINUTES=30
SESSION_MAX_HOURS=10
VALKEY_URL=redis://valkey:6379/0
# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) ---
WORKSPACE_URL=https://<hub-produccion>
HUB_URL=https://<hub-produccion>
INTERNAL_HUB_URL=https://<hub-produccion>
VITE_HUB_URL=https://<hub-produccion>
# --- Keycloak (single-realm / single-client) ---
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=aduanasoft
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado en prod>
# --- Dominio del CRM (mismo origen app + API vía nginx) ---
ORIGIN=https://<dominio-crm-produccion>
APP_PUBLIC_URL=https://<dominio-crm-produccion>
VITE_API_URL=https://<dominio-crm-produccion>/api/
INTERNAL_API_URL=http://backend:8000/api/
CORS_ORIGINS=https://<dominio-crm-produccion>
# --- PostgreSQL ---
CORE_DB_HOST=postgres
CORE_DB_PORT=5432
CORE_DB_NAME=crm_core
CORE_DB_USER=<usuario>
POSTGRES_APP_PASSWORD=<password fuerte>
# --- MinIO / S3 ---
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY=<access>
S3_SECRET_KEY=<secret>
S3_BUCKET=crm
S3_REGION=us-east-1
S3_USE_SSL=false
```
> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé
> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod.
---
## 3. Build del frontend — **en CI, no en el server**
La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen
en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
# push al registry interno si aplica
```
El backend usa la imagen/código directamente (uvicorn sin --reload).
---
## 4. Despliegue
```bash
# En el server de prod, en el directorio del proyecto:
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose ps # verifica que todos queden healthy
```
---
## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho**
1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro):
revisar y aplicar **solo** las que correspondan, con backup previo.
2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py`
(`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta).
3. **Sync de permisos** (registra el catálogo por módulo):
```bash
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
```
---
## 6. Verificación / smoke test
```bash
curl -fsS https://<dominio-crm-produccion>/api/health && echo OK
```
- [ ] Login vía **App Launcher del Workspace** entra sin bucle.
- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto).
- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace.
- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda
(toast) y persiste al refrescar.
- [ ] Licencia válida (sin bucle 401 / "sesión expirada").
---
## 7. Rollback
1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend.
2. Restaurar `.env` anterior si se cambió.
3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles.
4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin
redeploy de código (feature-flag), como mitigación rápida.

View File

@@ -5,26 +5,11 @@
# docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build
services:
backend:
# DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores;
# el backend debe resolver workspace.aduanasoft.com (Hub) en runtime.
dns:
- "8.8.8.8"
- "1.1.1.1"
ports: !override
- "127.0.0.1:8000:8000"
# Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista
# (no lee el .env dentro del contenedor), así que los flags van aquí. Valores
# desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo.
environment:
- SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true}
- SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30}
- SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10}
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
frontend:
dns:
- "8.8.8.8"
- "1.1.1.1"
build:
context: ./frontend
dockerfile: Dockerfile.prod
@@ -36,11 +21,8 @@ services:
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft}
environment:
- NODE_ENV=production
# El callback OIDC (/auth/callback) intercambia el código por tokens con el
# secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend.
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
volumes: !override []
command: !override ["node", "build/index.js"]
command: !override ["pnpm", "start"]
ports: !override
- "127.0.0.1:5173:5173"
@@ -49,10 +31,3 @@ services:
minio:
ports: !override []
# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local.
# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red.
networks:
app-hub:
external: false
driver: bridge

View File

@@ -49,8 +49,7 @@ FROM node:22-alpine AS runtime
WORKDIR /app
# wget de busybox ya viene en alpine; el apk es best-effort (redes restringidas sin CDN de Alpine)
RUN apk add --no-cache wget || true
RUN apk add --no-cache wget
RUN npm config set strict-ssl false && \
npm install -g pnpm

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

@@ -57,9 +57,7 @@ export const permissionsAPI = {
if (params?.action) queryParams.set('action', params.action);
if (params?.search) queryParams.set('search', params.search);
const query = queryParams.toString();
// Sin slash final: la ruta backend es @router.get("") = /v1/core/permissions.
// Con slash FastAPI responde 307 y el cliente server-side no lo sigue.
const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`);
const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`);
return response.data;
},
@@ -67,7 +65,7 @@ export const permissionsAPI = {
* Obtener un permiso por ID
*/
async getById(id: number): Promise<Permission> {
const response = await api.get(`/v1/core/permissions/${id}`);
const response = await api.get(`/v1/core/permissions/${id}/`);
return response.data;
},
@@ -75,7 +73,7 @@ export const permissionsAPI = {
* Crear un nuevo permiso
*/
async create(data: CreatePermissionData): Promise<Permission> {
const response = await api.post('/v1/core/permissions', data);
const response = await api.post('/v1/core/permissions/', data);
return response.data;
},
@@ -83,7 +81,7 @@ export const permissionsAPI = {
* Actualizar un permiso
*/
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
const response = await api.put(`/v1/core/permissions/${id}`, data);
const response = await api.put(`/v1/core/permissions/${id}/`, data);
return response.data;
},
@@ -91,14 +89,14 @@ export const permissionsAPI = {
* Eliminar un permiso
*/
async delete(id: number): Promise<void> {
await api.delete(`/v1/core/permissions/${id}`);
await api.delete(`/v1/core/permissions/${id}/`);
},
/**
* Obtener módulos únicos
*/
async getModules(): Promise<string[]> {
const response = await api.get('/v1/core/permissions/modules');
const response = await api.get('/v1/core/permissions/modules/');
return response.data;
},
@@ -106,7 +104,7 @@ export const permissionsAPI = {
* Obtener acciones únicas
*/
async getActions(): Promise<string[]> {
const response = await api.get('/v1/core/permissions/actions');
const response = await api.get('/v1/core/permissions/actions/');
return response.data;
}
};

View File

@@ -48,11 +48,7 @@ export const rolePermissionsAPI = {
companyId: number,
data: AssignPermissionData
): Promise<RolePermission> {
// El backend recibe permission_id como query param (no en el body).
const response = await api.post(
`/v1/core/permissions/roles/${roleId}/permissions?permission_id=${data.permission_id}&company_id=${companyId}`,
{}
);
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
return response.data;
},

View File

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

View File

@@ -0,0 +1,96 @@
/**
* 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<SatCatalogItem, 'description'> {
description: string | null;
name: string;
symbol: string | null;
}
export type PersonType = 'fisica' | 'moral';
type CatalogParams = Record<string, string | number | boolean | undefined>;
/** Cache en memoria del módulo, con la query string completa como llave. */
const cache = new Map<string, unknown>();
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<T>(
path: string,
companyId: number,
params?: CatalogParams
): Promise<T[]> {
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<T[]>(`/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<SatTaxRegime>('tax-regimes', companyId, params),
taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatTax>('taxes', companyId, params),
paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('payment-forms', companyId, params),
unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatUnitOfMeasure>('units-of-measure', companyId, params),
productsServices: (
companyId: number,
params?: { search?: string; limit?: number; active_only?: boolean }
) => fetchCatalog<SatCatalogItem>('products-services', companyId, params),
voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('voucher-types', companyId, params),
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),
cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
fetchCatalog<SatCatalogItem>('cfdi-uses', companyId, params)
};

View File

@@ -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<Concept[]> {
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<Concept[]>(`/v1/fin/concepts?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Concept> {
const res = await api.get<Concept>(`/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<Concept> {
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
const res = await api.patch<Concept>(`/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<void> {
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -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 {
@@ -33,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;
@@ -43,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
export interface InvoiceItem {
id: number;
invoice_id: number;
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
concept: string;
description: string | null;
quantity: number;
unit_amount: number;
line_total: number;
// Claves fiscales de la partida (catálogo de conceptos y catálogos SAT).
concept_id: number | null;
product_service_id: number | null;
unit_of_measure_id: number | null;
tax_object_id: number | null;
tenant_id: number;
company_id: number;
}
/**
* `concept` es opcional cuando se envía `concept_id`: el backend copia ahí la
* descripción del concepto del catálogo. Sin ninguno de los dos responde 422.
*/
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
invoice_id: number;
concept: string;
};
export interface Payment {

View File

@@ -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<IssuerSettings | null> {
const res = await api.get<IssuerSettings>(`/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<IssuerSettings> {
const res = await api.put<IssuerSettings>(
`/v1/fin/settings/issuer?company_id=${companyId}`,
data
);
if (res.error) throw new Error(res.error);
return res.data!;
}
};

View File

@@ -407,12 +407,10 @@ export const initAuth = async (): Promise<boolean> => {
return true;
}
// Sin token local: no hay sesión en el cliente. El login entra SIEMPRE por
// el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange);
// el CRM NO inicializa Keycloak en el browser. Si no hay token, el layout
// del servidor reenvía al Workspace.
// Sin token local, intentar Keycloak JS (flujo SSO)
const authenticated = await initKeycloak();
authStore.setLoading(false);
return false;
return authenticated;
} catch (err) {
console.error('[auth] Error en initAuth:', err);
authStore.setLoading(false);

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

@@ -0,0 +1,185 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import {
satCatalogsAPI,
type ConceptInput,
type SatCatalogItem,
type SatUnitOfMeasure
} from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let {
form = $bindable(),
companyId,
/** Clave ProdServ ya elegida; se muestra resuelta en vez del buscador. */
productService = $bindable(),
/** Error del 409 del backend, mostrado junto al campo de clave ProdServ. */
productServiceError = $bindable()
}: {
form: ConceptInput;
companyId: number | null;
productService: SatCatalogItem | null;
productServiceError: string;
} = $props();
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
let taxObjects = $state<SatCatalogItem[]>([]);
let productServiceQuery = $state('');
let productServiceOptions = $state<SatCatalogItem[]>([]);
let searchingProductService = $state(false);
$effect(() => {
const cid = companyId;
if (!cid) return;
void loadCatalogs(cid);
});
async function loadCatalogs(cid: number) {
try {
[unitsOfMeasure, taxObjects] = await Promise.all([
satCatalogsAPI.unitsOfMeasure(cid),
satCatalogsAPI.taxObjects(cid)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
}
}
/** Busca claves ProdServ; a partir de 2 caracteres para no traer el catálogo completo. */
async function searchProductServices() {
const cid = companyId;
const term = productServiceQuery.trim();
if (!cid || term.length < 2) {
productServiceOptions = [];
return;
}
searchingProductService = true;
try {
productServiceOptions = await satCatalogsAPI.productsServices(cid, {
search: term,
limit: 20
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : 'No se pudo buscar la clave de producto/servicio'
);
} finally {
searchingProductService = false;
}
}
function pick(option: SatCatalogItem) {
productService = option;
form.product_service_id = option.id;
productServiceQuery = '';
productServiceOptions = [];
productServiceError = '';
}
function clearProductService() {
productService = null;
form.product_service_id = 0;
productServiceOptions = [];
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Clave *</span>
<input class="font-mono {inputCls}" bind:value={form.code} maxlength="40" required />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Precio unitario</span>
<input type="number" step="0.01" min="0" class={inputCls} bind:value={form.unit_price} />
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Descripción *</span>
<input class={inputCls} bind:value={form.description} maxlength="500" required />
</label>
<div class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Clave de producto/servicio del SAT *</span>
<p class="text-xs text-muted-foreground">
Una clave del SAT solo puede estar asignada a un concepto de la empresa.
</p>
{#if productService}
<div class="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
<span class="text-sm">
<span class="font-mono">{productService.code}</span>
<span class="text-muted-foreground">{productService.description}</span>
</span>
<Button type="button" variant="ghost" size="sm" onclick={clearProductService}
>Cambiar</Button
>
</div>
{:else}
<input
class={inputCls}
placeholder="Escribe al menos 2 caracteres (clave o descripción)…"
bind:value={productServiceQuery}
oninput={searchProductServices}
/>
{#if searchingProductService}
<p class="text-xs text-muted-foreground">Buscando…</p>
{:else if productServiceOptions.length > 0}
<ul class="max-h-48 overflow-y-auto rounded-md border">
{#each productServiceOptions as option (option.id)}
<li>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-muted"
onclick={() => pick(option)}
>
<span class="font-mono">{option.code}</span>
<span class="text-muted-foreground">{option.description}</span>
</button>
</li>
{/each}
</ul>
{:else if productServiceQuery.trim().length >= 2}
<p class="text-xs text-muted-foreground">Sin coincidencias en el catálogo.</p>
{/if}
{/if}
{#if productServiceError}
<p class="text-xs text-destructive">{productServiceError}</p>
{/if}
</div>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Unidad de medida</span>
<select class={inputCls} bind:value={form.unit_of_measure_id}>
<option value={null}>Sin especificar</option>
{#each unitsOfMeasure as unit (unit.id)}
<option value={unit.id}>{unit.code} {unit.name}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Objeto de impuesto</span>
<select class={inputCls} bind:value={form.tax_object_id}>
<option value={null}>Sin especificar</option>
{#each taxObjects as taxObject (taxObject.id)}
<option value={taxObject.id}>{taxObject.code} {taxObject.description}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Moneda</span>
<input class={inputCls} bind:value={form.currency} maxlength="3" />
</label>
<label class="flex items-center gap-2 self-end text-sm">
<input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.is_active} />
<span class="font-medium">Activo</span>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Notas</span>
<textarea rows="3" class={inputCls} bind:value={form.notes}></textarea>
</label>
</div>

View File

@@ -6,7 +6,6 @@ import {
Briefcase,
Ship,
Receipt,
Building,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -68,13 +67,9 @@ 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' },
],
},
{
title: 'Compañías',
url: '/dashboard/companias',
icon: Building,
},
{
title: 'Usuarios',
url: '/dashboard/users',
@@ -89,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' },
],
},
];
}

View File

@@ -145,18 +145,9 @@
>
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
{#if userTenants.length === 0}
{#if companyStore.activeCompany?.tenant_name}
<DropdownMenu.Item disabled class="gap-2 p-2">
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
<BuildingIcon class="size-3.5" />
</div>
<span class="truncate font-medium">{companyStore.activeCompany.tenant_name}</span>
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Sin tenant asignado</span>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Sin tenant asignado</span>
</DropdownMenu.Item>
{:else}
{#each userTenants as tenant (tenant.id)}
<DropdownMenu.Item

View File

@@ -82,60 +82,6 @@ export function clearAuthTokens(cookies: Cookies) {
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
// Sesión local del CRM (patrón SIWEB)
cookies.delete('kc_access_token', { path: '/' });
cookies.delete('crm_sid', { path: '/' });
}
/**
* Token de Keycloak para llamadas DIRECTAS al Hub (my-apps, my-tenants,
* provisioning). Con el patrón de sesión local (SIWEB) el `access_token` guarda
* la sesión local del CRM, así que el token KC vive en su propia cookie
* `kc_access_token`. Fallback a `access_token` cuando el patrón está apagado
* (kc_access_token ausente) → comportamiento histórico intacto.
*/
export function getKcAccessToken(cookies: Cookies): string | null {
return cookies.get('kc_access_token') ?? getAccessTokenFromCookies(cookies);
}
/**
* Aplica a las cookies la respuesta de /v1/auth/refresh considerando la sesión
* local del CRM (patrón SIWEB):
* - Con `session_token`: `access_token` = sesión local (bearer de la app),
* `kc_access_token` = token KC (para el Hub), `crm_sid` = id de sesión valkey.
* - Sin él: comportamiento histórico (`access_token` = token KC).
* Devuelve el token que la app debe usar para reintentar (la sesión local si aplica).
*/
export function applyRefreshedTokens(
cookies: Cookies,
data: { access_token?: string; refresh_token?: string; session_token?: string; session_id?: string }
): string | null {
const secure = isSecureContext();
if (data.session_token) {
setAccessTokenCookies(cookies, data.session_token, { secure, maxAge: 60 * 60 * 24 * 7 });
// access_token KC vacío = fallback sin token fresco → conservar el actual.
if (data.access_token) {
cookies.set('kc_access_token', data.access_token, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7
});
}
if (data.session_id) {
cookies.set('crm_sid', data.session_id, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
});
}
if (data.refresh_token) {
cookies.set('refresh_token', data.refresh_token, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
});
}
return data.session_token;
}
if (data.access_token) {
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
}
return null;
}
/**
@@ -167,21 +113,12 @@ export async function refreshAccessToken(
try {
const baseUrl = getServerApiUrl();
// Sesión local actual del CRM (patrón SIWEB): se envía para preservar el
// inicio de sesión (cap absoluto) y permitir el re-emitido de fallback
// cuando el refresh del token KC contra el Hub falla.
const currentSession = getAccessTokenFromCookies(cookies);
const sessionId = cookies.get('crm_sid');
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refresh_token: refreshToken,
...(currentSession ? { session_token: currentSession } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
@@ -190,8 +127,10 @@ export async function refreshAccessToken(
const data = await response.json();
// Actualiza las cookies teniendo en cuenta la sesión local (o histórico si no aplica).
return applyRefreshedTokens(cookies, data);
// Actualizar las cookies con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
} catch (error) {
console.error('🔄 [API] Error al refrescar token:', error);
return null;

View File

@@ -5,14 +5,9 @@ const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
const RETURN_PATH_COOKIE = 'workspace_return_path';
/**
* Autenticación 100% vía el Hub/Workspace (patrón SIWEB). El CRM NUNCA habla
* directo a Keycloak: el login entra por el App Launcher del workspace (relay
* → /auth/sso → Hub /sso-exchange) y el resto de auth va por la API del Hub.
*/
/**
* True solo cuando la URL pública usa HTTPS. Se usa para el flag `secure` de las
* cookies (en vez de NODE_ENV) para que funcionen en dev HTTP LAN (192.168.x.x).
* Returns true only when the public-facing URL uses HTTPS.
* Use this for cookie `secure` flag instead of NODE_ENV so that
* cookies work on HTTP LAN dev environments (e.g. 192.168.x.x).
*/
export function isSecureContext(): boolean {
const origin = (env.ORIGIN || process.env.ORIGIN || '').trim();
@@ -25,8 +20,9 @@ function stripTrailingSlashes(value: string): string {
}
/**
* Detecta URLs solo accesibles localmente (localhost, IPs LAN/privadas, hosts
* internos de Docker). No son válidas como URL pública del Workspace.
* Detecta si una URL apunta a un host que solo es accesible localmente:
* localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker.
* Estas URLs no son válidas como redirect_uri ni como KC public URL en producción.
*/
function isDevOnlyUrl(rawUrl: string): boolean {
try {
@@ -64,14 +60,27 @@ export function getWorkspaceBaseUrl(): string {
}
/**
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri.
* Corrige el caso donde `ORIGIN` env var apunta a localhost en producción.
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros.
*
* Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno
* `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env
* de dev), todos los redirect_uri generados por el servidor apuntan a localhost.
*
* Esta función:
* 1. Usa `requestOrigin` si ya es una URL pública (no dev-only).
* 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback.
* 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino).
*
* Var de entorno recomendada en producción:
* SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN)
*/
export function resolveSystemBaseUrl(requestOrigin: string): string {
if (!isDevOnlyUrl(requestOrigin)) {
return stripTrailingSlashes(requestOrigin);
}
// requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción.
// Buscar URL pública en env vars adicionales.
const candidates = [
(env.SITE_URL || '').trim(),
(env.APP_URL || '').trim(),
@@ -84,17 +93,31 @@ export function resolveSystemBaseUrl(requestOrigin: string): string {
}
}
// Entorno dev genuino: devolver requestOrigin tal cual
return stripTrailingSlashes(requestOrigin);
}
/**
* URL de login del Workspace. NO lleva `return_to` a Mi Aplicación: el Hub
* muestra el App Launcher y el usuario re-entra al CRM por relay
* (→ /auth/sso?relay=). Así se evita el rebote a /login sin sesión (bucle) y no
* se usa ningún flujo OIDC directo contra Keycloak.
*/
export function getWorkspaceLoginUrl(): string {
return `${getWorkspaceBaseUrl()}/login`;
export type WorkspaceLoginUrlOptions = {
/**
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
* Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más.
*/
forPostLogout?: boolean;
};
export function getWorkspaceLoginUrl(
systemBaseUrl: string,
options?: WorkspaceLoginUrlOptions
): string {
const workspaceBaseUrl = getWorkspaceBaseUrl();
if (options?.forPostLogout) {
return `${workspaceBaseUrl}/login`;
}
// return_to includes sso_verified=1 so the workspace preserves it when redirecting
// back, regardless of what additional params the workspace appends.
const loginUrl = `${systemBaseUrl}/login?sso_verified=1`;
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
}
export function storeReturnPath(cookies: Cookies, path: string): void {
@@ -108,6 +131,26 @@ export function storeReturnPath(cookies: Cookies, path: string): void {
});
}
export function getPublicKeycloakBaseUrl(): string {
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
// Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service),
// ignorarlo y derivar la URL del hostname público del Workspace.
// Esto protege contra builds donde el .env de dev llega a producción por error.
if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) {
return stripTrailingSlashes(configuredKeycloakUrl);
}
return `${getWorkspaceBaseUrl()}/kcauth`;
}
export function getKeycloakRealm(): string {
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
}
export function getKeycloakClientId(): string {
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim();
}
export function getCleanReturnPath(url: URL): string {
const cleanParams = new URLSearchParams(url.searchParams);
cleanParams.delete('sso_verified');
@@ -143,9 +186,68 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void {
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
}
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
const redirectUri = `${publicBase}/auth/callback`;
const state = JSON.stringify({ redirect_url: redirectPath });
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid',
prompt: 'none',
state
});
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
}
/**
* Construye URL de login directo en KC sin prompt=none.
* Usa la sesión KC existente si la hay; si no, muestra el form de login.
* Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login).
*/
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
const redirectUri = `${publicBase}/auth/callback`;
const state = JSON.stringify({ redirect_url: redirectPath });
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid',
state
});
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
}
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
// Modo local: nunca salir al workspace, mostrar el login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
storeWorkspaceReturnPath(cookies, url);
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
}
export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never {
throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath));
}
/**
* URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange).
* No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí.
* Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose)
* → fallback a URL pública del workspace (vía proxy SvelteKit del Hub).
*/
export function getHubBackendUrl(): string {
const direct =
@@ -155,15 +257,19 @@ export function getHubBackendUrl(): string {
return getWorkspaceBaseUrl();
}
/**
* Redirige al login del Workspace (App Launcher). Único punto de entrada de
* login: el CRM no inicia ningún flujo contra Keycloak.
*/
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
// Modo local: nunca salir al workspace, mostrar el login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string {
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`;
const params = new URLSearchParams({
client_id: getKeycloakClientId(),
post_logout_redirect_uri: postLogoutRedirectUri
});
// Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad
// de que esté registrado explícitamente en el cliente.
if (idTokenHint) {
params.set('id_token_hint', idTokenHint);
}
storeWorkspaceReturnPath(cookies, url);
throw redirect(303, getWorkspaceLoginUrl());
}
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
}

View File

@@ -1,80 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
slugifyTenantName,
validateTenantForm,
hubErrorMessage,
isForbiddenStatus,
TENANT_SLUG_RE
} from './workspace-provision.shared';
describe('slugifyTenantName', () => {
it('convierte nombre con acentos y espacios a slug válido', () => {
const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.');
expect(slug).toBe('logistica-pena-cia-s-a-de-c-v');
expect(TENANT_SLUG_RE.test(slug)).toBe(true);
});
it('quita guiones al inicio y al final', () => {
expect(slugifyTenantName(' --Hola-- ')).toBe('hola');
});
it('cadena sin caracteres válidos da string vacío', () => {
expect(slugifyTenantName('!!!')).toBe('');
});
});
describe('validateTenantForm', () => {
it('acepta datos válidos', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' })
).toBeNull();
});
it('rechaza nombre demasiado corto', () => {
expect(
validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' })
).toMatch(/al menos 2/);
});
it('rechaza slug con mayúsculas o espacios', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' })
).toMatch(/slug/i);
});
it('rechaza email inválido', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' })
).toMatch(/email/i);
});
});
describe('hubErrorMessage', () => {
it('devuelve el detail string tal cual', () => {
expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe');
});
it('formatea el primer error de validación de Pydantic', () => {
const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] };
expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email');
});
it('mensaje de permisos ante 403 sin detail', () => {
expect(hubErrorMessage(null, 403)).toMatch(/permisos/i);
});
it('mensaje genérico con status ante cuerpo desconocido', () => {
expect(hubErrorMessage(null, 500)).toMatch(/500/);
});
});
describe('isForbiddenStatus', () => {
it('true para 401 y 403', () => {
expect(isForbiddenStatus(401)).toBe(true);
expect(isForbiddenStatus(403)).toBe(true);
});
it('false para otros', () => {
expect(isForbiddenStatus(422)).toBe(false);
expect(isForbiddenStatus(200)).toBe(false);
});
});

View File

@@ -1,85 +0,0 @@
/**
* Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub).
* Sin dependencias de entorno para poder testearse en aislamiento (Vitest).
*/
// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$).
export const TENANT_SLUG_RE = /^[a-z0-9-]+$/;
// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su
// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros.
export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const;
export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number];
/**
* Deriva un slug candidato a partir del nombre de la organización:
* minúsculas, sin acentos, espacios y símbolos → guiones.
*/
export function slugifyTenantName(name: string): string {
return name
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100);
}
export type TenantFormValues = {
name: string;
slug: string;
contact_email: string;
};
/**
* Valida los campos obligatorios del alta de organización antes de llamar al Hub,
* para dar feedback inmediato sin gastar un round-trip.
* Devuelve un mensaje de error o null si es válido.
*/
export function validateTenantForm(values: TenantFormValues): string | null {
if (!values.name || values.name.trim().length < 2) {
return 'El nombre de la organización debe tener al menos 2 caracteres.';
}
if (!TENANT_SLUG_RE.test(values.slug)) {
return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).';
}
if (!values.contact_email || !values.contact_email.includes('@')) {
return 'El email de contacto es obligatorio y debe ser válido.';
}
return null;
}
/**
* Extrae un mensaje legible del cuerpo de error de FastAPI (Hub).
* - 401/403 → mensaje de permisos.
* - detail string → tal cual.
* - detail array (422 Pydantic) → "campo: msg" del primer error.
* - cualquier otro → mensaje genérico con el status.
*/
export function hubErrorMessage(body: unknown, status: number): string {
const detail =
body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null;
if (typeof detail === 'string' && detail.trim()) {
return detail;
}
if (Array.isArray(detail) && detail.length > 0) {
const first = detail[0] as { loc?: unknown[]; msg?: string };
const loc = Array.isArray(first.loc) ? first.loc : [];
const field = loc.length ? String(loc[loc.length - 1]) : '';
const msg = first.msg ?? 'dato inválido';
return field ? `${field}: ${msg}` : msg;
}
if (status === 401 || status === 403) {
return 'No tienes permisos de administrador en el workspace para esta acción.';
}
return `El workspace respondió con un error (${status}).`;
}
/** True si el status del Hub indica falta de permisos/sesión. */
export function isForbiddenStatus(status: number): boolean {
return status === 401 || status === 403;
}

View File

@@ -1,126 +0,0 @@
/**
* Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y
* USUARIOS (invitaciones) desde el CRM.
*
* Principio de seguridad: SIEMPRE se llama server-side reenviando el token del
* usuario autenticado (Bearer). Es el Hub quien valida el permiso —
* `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para
* invitaciones. El CRM NO guarda secretos ni hace bypass de autorización.
*
* Endpoints del Hub (base = getHubBackendUrl()):
* GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin)
* POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin)
* POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant)
*/
import { getHubBackendUrl } from '$lib/server/workspace-auth';
import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared';
const HUB_API_PREFIX = '/api/v1/hub';
function hubUrl(path: string): string {
return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`;
}
export type WorkspaceTenant = {
id: number;
name: string;
slug: string;
display_name?: string | null;
contact_name?: string | null;
contact_email: string;
status: string;
is_self_hosted: boolean;
has_license: boolean;
created_at: string;
};
export type WorkspaceInvite = {
id: number;
email: string;
tenant_slug: string;
role: string;
invite_url: string;
expires_at: string;
created_at: string;
};
export type HubResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; forbidden: boolean; error: string };
/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */
async function toErrorResult<T>(res: Response): Promise<HubResult<T>> {
const body = await res.json().catch(() => null);
return {
ok: false,
status: res.status,
forbidden: isForbiddenStatus(res.status),
error: hubErrorMessage(body, res.status)
};
}
const jsonHeaders = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
});
/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */
export async function listWorkspaceTenants(
accessToken: string,
fetch: typeof globalThis.fetch
): Promise<HubResult<WorkspaceTenant[]>> {
const res = await fetch(hubUrl('/tenants'), {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) return toErrorResult<WorkspaceTenant[]>(res);
const data = (await res.json()) as { tenants?: WorkspaceTenant[] };
return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] };
}
export type CreateTenantInput = {
name: string;
slug: string;
contact_email: string;
contact_name?: string;
contact_phone?: string;
display_name?: string;
is_self_hosted: boolean;
app_url?: string;
};
/** Crea una organización (tenant) y provisiona su realm en Keycloak. */
export async function createWorkspaceTenant(
accessToken: string,
fetch: typeof globalThis.fetch,
input: CreateTenantInput
): Promise<HubResult<WorkspaceTenant>> {
const res = await fetch(hubUrl('/tenants'), {
method: 'POST',
headers: jsonHeaders(accessToken),
body: JSON.stringify(input)
});
if (!res.ok) return toErrorResult<WorkspaceTenant>(res);
return { ok: true, data: (await res.json()) as WorkspaceTenant };
}
export type CreateInviteInput = {
email: string;
tenant_slug: string;
role: string;
};
/** Genera una invitación de un solo uso para un usuario en un tenant. */
export async function createWorkspaceInvite(
accessToken: string,
fetch: typeof globalThis.fetch,
input: CreateInviteInput
): Promise<HubResult<WorkspaceInvite>> {
const res = await fetch(hubUrl('/invites'), {
method: 'POST',
headers: jsonHeaders(accessToken),
body: JSON.stringify(input)
});
if (!res.ok) return toErrorResult<WorkspaceInvite>(res);
return { ok: true, data: (await res.json()) as WorkspaceInvite };
}

View File

@@ -11,8 +11,6 @@ interface Company {
rfc?: string;
logo?: string;
tenant_id: number;
tenant_name?: string;
tenant_slug?: string;
}
class CompanyStore {
@@ -160,9 +158,7 @@ class CompanyStore {
headers: {
'Content-Type': 'application/json'
},
// tenant_id de la compañía → override para que el backend escale por
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
body: JSON.stringify({ companyId: company.id }),
credentials: 'include'
});
} catch (error) {

View File

@@ -14,8 +14,8 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
export const POST = async ({ cookies, fetch }: RequestEvent) => {
const refreshToken = cookies.get('refresh_token');
@@ -27,43 +27,34 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => {
try {
const baseUrl = getServerApiUrl();
// Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el
// inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla.
const currentSession = getAccessTokenFromCookies(cookies);
const sessionId = cookies.get('crm_sid');
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refresh_token: refreshToken,
...(currentSession ? { session_token: currentSession } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!response.ok) {
// El refresh falló y no hubo sesión local que re-emitir (cap superado o
// patrón apagado). Limpiar cookies para que el server redirija al login.
clearAuthTokens(cookies);
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
cookies.delete('refresh_token', { path: '/' });
clearAccessTokenCookies(cookies);
cookies.delete('active_company_id', { path: '/' });
const status = response.status === 401 ? 401 : 400;
return json({ error: 'Refresh token expired or invalid' }, { status });
}
const data = (await response.json()) as {
access_token?: string;
access_token: string;
refresh_token?: string;
session_token?: string;
session_id?: string;
expires_in?: number;
};
// Actualiza cookies considerando la sesión local; devuelve el bearer de la app.
const appToken = applyRefreshedTokens(cookies, data);
// Actualizar las cookies HttpOnly con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Devolver solo el token de app al cliente (sesión local si aplica, o KC).
return json({ access_token: appToken ?? data.access_token ?? '' });
// Devolver solo el access_token al cliente
return json({ access_token: data.access_token });
} catch (error) {
console.error('[silent-refresh] Error inesperado:', error);
return json({ error: 'Internal server error' }, { status: 500 });

View File

@@ -9,7 +9,7 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const body = await request.json();
@@ -28,11 +28,9 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
// Validación contra el Hub → token KC (el access_token puede ser la sesión local).
const kcToken = getKcAccessToken(cookies) ?? accessToken;
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${kcToken}` },
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });

View File

@@ -6,28 +6,22 @@ import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const body = await request.json();
const companyId = body?.companyId;
const tenantId = body?.tenantId;
const { companyId } = await request.json();
if (!companyId || typeof companyId !== 'number') {
return json({ error: 'Invalid company ID' }, { status: 400 });
}
const isProd = process.env.NODE_ENV === 'production';
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
// Establecer la cookie desde el servidor
cookies.set('active_company_id', companyId.toString(), {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false, // Permitir acceso desde JavaScript
secure: process.env.NODE_ENV === 'production'
});
// Compañía activa (legible desde JS)
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
// Fijar el tenant de la compañía como override → el backend escala por ese
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
}
return json({ success: true, companyId, tenantId: tenantId ?? null });
return json({ success: true, companyId });
} catch (error) {
console.error('Error setting active company:', error);
return json({ error: 'Internal server error' }, { status: 500 });

View File

@@ -1,15 +1,132 @@
import { redirect } from '@sveltejs/kit';
import { redirect, isRedirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
clearWorkspaceReturnPath,
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
storeReturnPath,
} from '$lib/server/workspace-auth';
/**
* Callback OIDC — OBSOLETO.
*
* El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por
* el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta
* ruta se conserva solo para no romper enlaces viejos; cualquier acceso se
* redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al
* Workspace). No se intercambia ningún `code` con Keycloak.
*/
export const load: PageServerLoad = async () => {
throw redirect(303, '/dashboard');
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
// Obtener el código y state de los query params
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (errorParam) {
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
// login_required means no KC session exists yet → send to Workspace login.
// Preserve the intended destination through the detour so /login can pick it up.
if (state) {
try {
const stateObj = JSON.parse(state);
const returnPath = stateObj.redirect_url;
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
storeReturnPath(cookies, returnPath);
}
} catch { /* ignore malformed state */ }
}
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
if (!code) {
console.error('❌ [Callback Server] No se recibió código de autorización');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
try {
// Intercambiar código por tokens usando el backend de Keycloak
// En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080
// En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master';
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend';
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: redirectUri,
client_id: KEYCLOAK_CLIENT_ID,
...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET })
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.text();
console.error('❌ [Callback Server] Error al intercambiar código:', errorData);
throw new Error('Error al obtener tokens');
}
const tokens = await tokenResponse.json();
// Establecer las cookies en el servidor
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
const { isSecureContext } = await import('$lib/server/workspace-auth');
const isProduction = isSecureContext();
setAccessTokenCookies(cookies, tokens.access_token, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7 // 7 días
});
if (tokens.refresh_token) {
cookies.set('refresh_token', tokens.refresh_token, {
path: '/',
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 días
});
}
if (tokens.id_token) {
cookies.set('id_token', tokens.id_token, {
path: '/',
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
}
// Obtener la URL de redirección del state o ir al dashboard
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
if (state) {
try {
const stateObj = JSON.parse(state);
redirectTo = stateObj.redirect_url || '/dashboard';
} catch (e) {
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
}
}
clearWorkspaceReturnPath(cookies);
// Redirigir a la página de destino
throw redirect(303, redirectTo);
} catch (err: any) {
if (isRedirect(err)) throw err;
console.error('❌ [Callback Server] Error procesando autenticación:', err);
throw redirect(303, getWorkspaceLoginUrl(url.origin));
}
};

View File

@@ -3,10 +3,11 @@ import type { RequestHandler } from './$types';
import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
/**
* Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak
* (el cierre completo se hace desde el Workspace); se conserva por compatibilidad
* y redirige al App Launcher del Workspace.
* KC redirects here after completing the logout flow.
* This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*).
* We then send the user to workspace login so it can apply myApps() launcher logic.
*/
export const GET: RequestHandler = async () => {
throw redirect(303, getWorkspaceLoginUrl());
export const GET: RequestHandler = async ({ request, url }) => {
const systemBaseUrl = url.origin;
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
};

View File

@@ -134,32 +134,12 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
const isProduction = isSecureContext();
console.log('[SSO] ORIGIN-based secure context:', isProduction);
// Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una
// sesión local firmada, el access_token guarda ESA sesión (bearer de la app,
// sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia
// cookie kc_access_token (solo para llamadas directas al Hub). Si no vino
// (flag apagado), comportamiento histórico: access_token = token KC.
const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null;
const kcAccessToken = tokens.access_token as string;
// access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB
setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, {
setAccessTokenCookies(cookies, tokens.access_token as string, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7,
});
if (sessionToken) {
// kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub.
cookies.set('kc_access_token', kcAccessToken, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
});
if (typeof tokens.session_id === 'string') {
cookies.set('crm_sid', tokens.session_id, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
});
}
}
// refresh_token — HttpOnly (never exposed to JS)
if (typeof tokens.refresh_token === 'string') {
cookies.set('refresh_token', tokens.refresh_token, {

View File

@@ -3,7 +3,6 @@ import type { LayoutServerLoad } from './$types';
import {
validateAuth,
getAuthTokens,
getKcAccessToken,
getUserCompanies,
clearAuthTokens
} from '$lib/server/api';
@@ -31,15 +30,12 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
if (!DEV_LOCAL_AUTH) {
// Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el
// access_token guarda la sesión local del CRM, no el token de Keycloak).
const kcToken = getKcAccessToken(cookies) ?? accessToken;
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${kcToken}`,
'Authorization': `Bearer ${accessToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
}
});
@@ -50,7 +46,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// No bloquear el dashboard si falla la carga de tenants
}
myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id'));
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
}
return {

View File

@@ -1,157 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Building, Plus } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
import { companyStore } from '$lib/stores/company.svelte';
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
type Tenant = { id: number; name: string; slug: string };
let tenants = $state<Tenant[]>([]);
let loading = $state(true);
let submitting = $state(false);
let name = $state('');
let rfc = $state('');
let tenantId = $state<number | null>(null);
const companies = $derived(companyStore.companies);
onMount(async () => {
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
if (res.data) {
tenants = res.data;
if (tenants.length === 1) tenantId = tenants[0].id;
}
await companyStore.loadCompanies();
loading = false;
});
async function createCompany() {
if (name.trim().length < 2) {
toast.error('El nombre de la compañía es obligatorio');
return;
}
if (!tenantId) {
toast.error('Selecciona el tenant al que pertenece');
return;
}
submitting = true;
try {
const res = await api.post<{ id: number }>('/v1/auth/companies', {
name: name.trim(),
tenant_id: tenantId,
rfc: rfc.trim() || null
});
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Compañía creada');
name = '';
rfc = '';
await companyStore.loadCompanies();
// Seleccionarla como activa para poder trabajar de inmediato.
const created = res.data?.id
? companyStore.companies.find((c) => c.id === res.data!.id)
: null;
if (created) await companyStore.setActiveCompany(created);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
} finally {
submitting = false;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building class="h-6 w-6" /> Compañías
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Nombre / Razón social *</span>
<input class={inputCls} bind:value={name} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC</span>
<input class="font-mono {inputCls}" maxlength="13" bind:value={rfc} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tenant (Workspace) *</span>
<select class={inputCls} bind:value={tenantId}>
<option value={null} disabled>Selecciona…</option>
{#each tenants as t (t.id)}
<option value={t.id}>{t.name} ({t.slug})</option>
{/each}
</select>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={createCompany} disabled={submitting}>
{submitting ? 'Creando…' : 'Crear compañía'}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Compañías ({companies.length})</Card.Title>
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if companies.length === 0}
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Nombre</th>
<th class="py-2 pr-4 font-medium">RFC</th>
<th class="py-2 pr-4 font-medium">Tenant</th>
<th class="py-2 pr-4 font-medium">Activa</th>
</tr>
</thead>
<tbody>
{#each companies as c (c.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{c.name}</td>
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
<td class="py-2 pr-4">{c.tenant_id}</td>
<td class="py-2 pr-4">
{#if companyStore.activeCompany?.id === c.id}
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
{:else}
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

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>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { Tags, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { conceptsAPI, type Concept } from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let items = $state<Concept[]>([]);
let loading = $state(false);
let search = $state('');
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await conceptsAPI.list(cid, {
search: search.trim() || undefined,
active_only: activeFilter === 'todos' ? undefined : activeFilter === 'activos'
});
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los conceptos');
} finally {
loading = false;
}
}
async function remove(concept: Concept) {
const cid = companyId;
if (!cid) return;
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
try {
await conceptsAPI.remove(concept.id, cid);
toast.success('Concepto dado de baja');
await load(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
}
}
function money(value: number | null): string {
if (value === null || value === undefined) return '—';
return new Intl.NumberFormat('es-MX', { minimumFractionDigits: 2 }).format(Number(value));
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<svelte:head>
<title>Conceptos de facturación</title>
</svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Tags class="h-6 w-6" />
Conceptos de facturación
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
empresa.
</p>
</div>
<Button href="/dashboard/fin/conceptos/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<input
class="w-full py-2 pr-3 pl-8 {inputCls}"
placeholder="Buscar por clave o descripción…"
bind:value={search}
onchange={() => companyId && load(companyId)}
/>
</div>
<select
class="{inputCls} max-w-xs"
bind:value={activeFilter}
onchange={() => companyId && load(companyId)}
>
<option value="todos">Todos</option>
<option value="activos">Solo activos</option>
<option value="inactivos">Solo inactivos</option>
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if items.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin conceptos registrados.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Clave</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head>Clave ProdServ</Table.Head>
<Table.Head>Unidad</Table.Head>
<Table.Head>Objeto de impuesto</Table.Head>
<Table.Head class="text-right">Precio unitario</Table.Head>
<Table.Head>Estado</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items as concept (concept.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs font-medium">
<a class="hover:underline" href={`/dashboard/fin/conceptos/${concept.id}`}>
{concept.code}
</a>
</Table.Cell>
<Table.Cell>{concept.description}</Table.Cell>
<Table.Cell class="text-xs">
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
{#if concept.product_service}
<span class="block text-muted-foreground">
{concept.product_service.description}
</span>
{/if}
</Table.Cell>
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</Table.Cell>
<Table.Cell class="text-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
<Table.Cell class="text-right">
{money(concept.unit_price)}
{concept.currency}
</Table.Cell>
<Table.Cell>
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}"
>
{concept.is_active ? 'Activo' : 'Inactivo'}
</span>
</Table.Cell>
<Table.Cell class="text-right">
<Button
variant="ghost"
size="sm"
href={`/dashboard/fin/conceptos/${concept.id}`}
aria-label="Abrir"
>
<ChevronRight class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => remove(concept)}
aria-label="Dar de baja"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { ArrowLeft, Tags, Trash2 } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { conceptsAPI, type Concept, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
import { toast } from 'svelte-sonner';
const conceptId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let concept = $state<Concept | null>(null);
let form = $state<ConceptInput>({ code: '', description: '', product_service_id: 0 });
let productService = $state<SatCatalogItem | null>(null);
let productServiceError = $state('');
let loading = $state(false);
let saving = $state(false);
$effect(() => {
const cid = companyId;
const id = conceptId;
if (!cid || !id) return;
void load(cid, id);
});
function hydrate(c: Concept) {
form = {
code: c.code,
description: c.description,
product_service_id: c.product_service_id,
unit_of_measure_id: c.unit_of_measure_id,
tax_object_id: c.tax_object_id,
unit_price: c.unit_price,
currency: c.currency,
is_active: c.is_active,
notes: c.notes ?? ''
};
productService = c.product_service;
productServiceError = '';
}
async function load(cid: number, id: number) {
loading = true;
try {
concept = await conceptsAPI.get(id, cid);
hydrate(concept);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el concepto');
} finally {
loading = false;
}
}
async function save(event: SubmitEvent) {
event.preventDefault();
const cid = companyId;
if (!cid || !concept) return;
if (!form.code.trim() || !form.description.trim()) {
toast.error('La clave y la descripción son obligatorias');
return;
}
if (!form.product_service_id) {
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
return;
}
saving = true;
productServiceError = '';
try {
concept = await conceptsAPI.update(
concept.id,
{
...form,
unit_price:
form.unit_price === null || form.unit_price === undefined
? null
: Number(form.unit_price),
notes: form.notes?.trim() ? form.notes : null
},
cid
);
hydrate(concept);
toast.success('Cambios guardados');
} catch (e) {
const message = e instanceof Error ? e.message : 'No se pudieron guardar los cambios';
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
else toast.error(message);
} finally {
saving = false;
}
}
async function remove() {
const cid = companyId;
if (!cid || !concept) return;
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
try {
await conceptsAPI.remove(concept.id, cid);
toast.success('Concepto dado de baja');
await goto('/dashboard/fin/conceptos');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
}
}
</script>
<svelte:head>
<title>{concept ? `Concepto ${concept.code}` : 'Concepto de facturación'}</title>
</svelte:head>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
</Button>
{#if loading && !concept}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if concept}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Tags class="h-6 w-6" />
{concept.code}
</h1>
<p class="mt-1 text-sm text-muted-foreground">
{concept.description}
{#if concept.product_service}
· <span class="font-mono">{concept.product_service.code}</span>
{/if}
· {concept.is_active ? 'Activo' : 'Inactivo'}
</p>
</div>
<Button variant="outline" onclick={remove}>
<Trash2 class="mr-1 h-4 w-4 text-destructive" /> Dar de baja
</Button>
</div>
<Card.Root>
<Card.Content class="pt-6">
<form onsubmit={save}>
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={saving}
>{saving ? 'Guardando…' : 'Guardar cambios'}</Button
>
</div>
</form>
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { ArrowLeft, Tags } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { conceptsAPI, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let form = $state<ConceptInput>({
code: '',
description: '',
product_service_id: 0,
unit_of_measure_id: null,
tax_object_id: null,
unit_price: null,
currency: 'MXN',
is_active: true,
notes: ''
});
let productService = $state<SatCatalogItem | null>(null);
let productServiceError = $state('');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
async function save(event: SubmitEvent) {
event.preventDefault();
const cid = companyId;
if (!cid) return;
if (!form.code.trim() || !form.description.trim()) {
toast.error('La clave y la descripción son obligatorias');
return;
}
if (!form.product_service_id) {
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
return;
}
saving = true;
productServiceError = '';
try {
const created = await conceptsAPI.create(
{
...form,
unit_price:
form.unit_price === null || form.unit_price === undefined
? null
: Number(form.unit_price),
notes: form.notes?.trim() ? form.notes : null
},
cid
);
toast.success('Concepto creado');
await goto(`/dashboard/fin/conceptos/${created.id}`);
} catch (e) {
const message = e instanceof Error ? e.message : 'No se pudo crear el concepto';
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
else toast.error(message);
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Nuevo concepto de facturación</title>
</svelte:head>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Tags class="h-6 w-6" /> Nuevo concepto
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Cada concepto se liga a una clave de producto/servicio del SAT.
</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<form onsubmit={save}>
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button type="button" variant="outline" href="/dashboard/fin/conceptos">Cancelar</Button>
<Button type="submit" disabled={saving || !companyId}
>{saving ? 'Guardando…' : 'Crear'}</Button
>
</div>
</form>
</Card.Content>
</Card.Root>
</div>

View File

@@ -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<InvoiceItem[]>([]);
let payments = $state<Payment[]>([]);
let accounts = $state<Account[]>([]);
/** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar
* partidas que apunten a un concepto ya inactivo. */
let concepts = $state<Concept[]>([]);
let form = $state<InvoiceInput>({});
let tab = $state('conceptos');
let loading = $state(false);
@@ -29,6 +32,10 @@
let addingPay = $state(false);
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
/** Opción elegida en el selector de concepto: `cat:<id>` del catálogo o `txt:<clave>` 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 @@
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
{#if addingItem}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Concepto</span>
<select class={inputCls} bind:value={conceptChoice} onchange={applyConceptChoice}>
{#if activeConcepts.length > 0}
<optgroup label="Catálogo de conceptos">
{#each activeConcepts as c (c.id)}<option value={`cat:${c.id}`}>{c.code} — {c.description}</option>{/each}
</optgroup>
{/if}
<optgroup label="Conceptos genéricos (sin clave del SAT)">
{#each QUOTE_CONCEPTS as c (c.value)}<option value={`txt:${c.value}`}>{c.label}</option>{/each}
</optgroup>
</select>
{#if activeConcepts.length === 0}
<span class="text-xs text-muted-foreground">
El catálogo de conceptos está vacío.
<a class="underline" href="/dashboard/fin/conceptos">Darlos de alta</a> permite facturar con clave del SAT.
</span>
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
@@ -222,7 +276,7 @@
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="font-medium">{itemConceptLabel(it)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>

View File

@@ -1,231 +1,26 @@
<script lang="ts">
import { Shield, Plus, Trash2, ChevronRight, ChevronDown } from '@lucide/svelte';
import { Shield } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
import { rolePermissionsAPI } from '$lib/api/dashboard/admin/role-permissions';
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let roles = $state<CompanyRole[]>([]);
let catalog = $state<Permission[]>([]);
let loading = $state(true);
let creating = $state(false);
let name = $state('');
let code = $state('');
let description = $state('');
let expandedRoleId = $state<number | null>(null);
// roleId → Set de permission_id asignados
let rolePerms = $state<Record<number, Set<number>>>({});
let savingPerm = $state<string | null>(null);
// Catálogo agrupado por módulo
const byModule = $derived.by(() => {
const m: Record<string, Permission[]> = {};
for (const p of catalog) (m[p.module] ??= []).push(p);
return m;
});
function slugify(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
async function load() {
if (!companyId) {
loading = false;
return;
}
loading = true;
try {
const [rRes, pRes] = await Promise.all([
rolesAPI.list(companyId, { page_size: 100 }),
permissionsAPI.list({ page_size: 1000 })
]);
roles = rRes.data?.items ?? [];
catalog = pRes.items ?? [];
} catch {
toast.error('No se pudieron cargar roles/permisos');
} finally {
loading = false;
}
}
// Recarga los roles cuando cambia la compañía activa —incluida la hidratación
// inicial tras refrescar (en onMount el store aún no tenía compañía, por eso
// antes no aparecían hasta re-seleccionarla).
$effect(() => {
const id = companyId;
expandedRoleId = null;
rolePerms = {};
if (id) {
void load();
} else {
roles = [];
loading = false;
}
});
async function createRole() {
if (!companyId) return;
const n = name.trim();
if (n.length < 2) {
toast.error('El nombre del rol es obligatorio');
return;
}
const c = (code.trim() || slugify(n));
creating = true;
try {
const res = await rolesAPI.create(companyId, { name: n, code: c, description: description.trim() || undefined });
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Rol creado');
name = '';
code = '';
description = '';
await load();
} finally {
creating = false;
}
}
async function delRole(r: CompanyRole) {
if (!companyId) return;
if (!confirm(`¿Eliminar el rol "${r.name}"?`)) return;
const res = await rolesAPI.delete(r.id, companyId);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Rol eliminado');
await load();
}
async function toggleExpand(r: CompanyRole) {
if (expandedRoleId === r.id) {
expandedRoleId = null;
return;
}
expandedRoleId = r.id;
if (!rolePerms[r.id] && companyId) {
try {
const resp = await rolePermissionsAPI.listByRole(r.id, companyId);
rolePerms = { ...rolePerms, [r.id]: new Set((resp.permissions ?? []).map((p) => p.permission_id)) };
} catch {
rolePerms = { ...rolePerms, [r.id]: new Set() };
}
}
}
async function togglePerm(roleId: number, perm: Permission, checked: boolean) {
if (!companyId) return;
savingPerm = `${roleId}:${perm.id}`;
try {
if (checked) {
await rolePermissionsAPI.assign(roleId, companyId, { permission_id: perm.id });
rolePerms[roleId]?.add(perm.id);
} else {
await rolePermissionsAPI.remove(roleId, perm.id, companyId);
rolePerms[roleId]?.delete(perm.id);
}
rolePerms = { ...rolePerms };
toast.success(checked ? 'Permiso agregado' : 'Permiso quitado');
} catch (e) {
toast.error('No se pudo actualizar el permiso');
} finally {
savingPerm = null;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Shield class="h-6 w-6" /> Roles y permisos
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Shield class="h-6 w-6" />
Roles y permisos
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.
<p class="text-muted-foreground text-sm mt-1">
Gestión de roles y control de acceso.
</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa para gestionar sus roles.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nuevo rol</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={name} placeholder="Ventas" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código</span><input class="font-mono {inputCls}" bind:value={code} placeholder="(auto)" title="Se genera del nombre si lo dejas vacío" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={description} /></label>
</div>
<div class="mt-4 flex justify-end"><Button onclick={createRole} disabled={creating}>{creating ? 'Creando…' : 'Crear rol'}</Button></div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Roles ({roles.length})</Card.Title>
<Card.Description>Haz clic en un rol para ver y editar sus permisos.</Card.Description>
</Card.Header>
<Card.Content class="space-y-2">
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if roles.length === 0}
<p class="text-sm text-muted-foreground">No hay roles. Crea uno arriba.</p>
{:else}
{#each roles as r (r.id)}
<div class="rounded-md border">
<div class="flex items-center gap-2 px-3 py-2">
<button type="button" class="flex flex-1 items-center gap-2 text-left" onclick={() => toggleExpand(r)}>
{#if expandedRoleId === r.id}<ChevronDown class="h-4 w-4" />{:else}<ChevronRight class="h-4 w-4" />{/if}
<span class="font-medium">{r.name}</span>
<span class="font-mono text-xs text-muted-foreground">{r.code}</span>
</button>
<Button variant="ghost" size="sm" onclick={() => delRole(r)}><Trash2 class="h-4 w-4 text-destructive" /></Button>
</div>
{#if expandedRoleId === r.id}
<div class="border-t p-3">
{#if !rolePerms[r.id]}
<p class="text-sm text-muted-foreground">Cargando permisos…</p>
{:else}
<div class="space-y-4">
{#each Object.entries(byModule) as [mod, perms] (mod)}
<div>
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{mod}</p>
<div class="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{#each perms as p (p.id)}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="h-4 w-4 rounded border"
checked={rolePerms[r.id].has(p.id)}
disabled={savingPerm === `${r.id}:${p.id}`}
onchange={(e) => togglePerm(r.id, p, (e.currentTarget as HTMLInputElement).checked)}
/>
<span title={p.code}>{p.description || p.code}</span>
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>Roles del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de roles y permisos de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import { Receipt } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { authStore, userHasPermission } from '$lib/auth';
import {
issuerAPI,
satCatalogsAPI,
RFC_REGEX,
type IssuerSettingsInput,
type SatTaxRegime
} from '$lib/api/fin';
import { toast } from 'svelte-sonner';
let form = $state<IssuerSettingsInput>({
legal_name: '',
rfc: '',
tax_regime_id: 0,
zip_code: ''
});
let taxRegimes = $state<SatTaxRegime[]>([]);
let loading = $state(false);
let saving = $state(false);
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
let isNew = $state(true);
let rfcError = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
const canEdit = $derived(userHasPermission($authStore.user, 'fin.settings.edit'));
$effect(() => {
const cid = companyId;
if (!cid || !canView) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
const [settings, regimes] = await Promise.all([
issuerAPI.get(cid),
satCatalogsAPI.taxRegimes(cid)
]);
taxRegimes = regimes;
isNew = settings === null;
if (settings) {
form = {
legal_name: settings.legal_name,
rfc: settings.rfc,
tax_regime_id: settings.tax_regime_id,
zip_code: settings.zip_code ?? ''
};
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos fiscales');
} finally {
loading = false;
}
}
function normalizedRfc(): string {
return (form.rfc ?? '').replace(/[\s-]/g, '').toUpperCase();
}
async function save(event: SubmitEvent) {
event.preventDefault();
const cid = companyId;
if (!cid) return;
const rfc = normalizedRfc();
if (!RFC_REGEX.test(rfc)) {
rfcError = 'El RFC no tiene un formato válido (ej. XAXX010101000)';
return;
}
rfcError = '';
if (!form.tax_regime_id) {
toast.error('Selecciona el régimen fiscal');
return;
}
saving = true;
try {
await issuerAPI.save(
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
cid
);
isNew = false;
toast.success('Datos fiscales guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los datos fiscales');
} finally {
saving = false;
}
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<svelte:head>
<title>Configuración de Facturación</title>
</svelte:head>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Receipt class="h-6 w-6" />
Datos fiscales del emisor
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Identidad fiscal con la que la empresa emite sus comprobantes.
</p>
</div>
{#if !canView}
<Card.Root>
<Card.Content>
<p class="py-6 text-center text-sm text-muted-foreground">
No tienes permiso para consultar los datos fiscales del emisor.
</p>
</Card.Content>
</Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title>{isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}</Card.Title>
<Card.Description>
{isNew
? 'Esta empresa aún no tiene datos fiscales configurados.'
: 'Actualiza la información con la que se emiten los comprobantes.'}
</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else}
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={save}>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Razón social *</span>
<input
class={inputCls}
bind:value={form.legal_name}
maxlength="255"
required
disabled={!canEdit}
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC *</span>
<input
class="{inputCls} font-mono uppercase"
bind:value={form.rfc}
maxlength="13"
required
disabled={!canEdit}
oninput={() => (rfcError = '')}
/>
{#if rfcError}<span class="text-xs text-destructive">{rfcError}</span>{/if}
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Código postal del lugar de expedición</span>
<input
class={inputCls}
bind:value={form.zip_code}
maxlength="5"
inputmode="numeric"
disabled={!canEdit}
/>
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Régimen fiscal *</span>
<select class={inputCls} bind:value={form.tax_regime_id} required disabled={!canEdit}>
<option value={0}>Selecciona un régimen…</option>
{#each taxRegimes as regime (regime.id)}
<option value={regime.id}>{regime.code} {regime.description}</option>
{/each}
</select>
</label>
<div class="flex justify-end sm:col-span-2">
<Button type="submit" disabled={saving || !canEdit || !companyId}>
{saving ? 'Guardando…' : 'Guardar'}
</Button>
</div>
{#if !canEdit}
<p class="text-xs text-muted-foreground sm:col-span-2">
Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
</p>
{/if}
</form>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import { Settings2 } from 'lucide-svelte';
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { authStore, userHasPermission } from '$lib/auth';
const canViewIssuerSettings = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
</script>
<svelte:head>
@@ -18,6 +22,25 @@
</p>
</div>
{#if canViewIssuerSettings}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Receipt class="h-5 w-5" />
Facturación
</Card.Title>
<Card.Description>
Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
</Card.Description>
</Card.Header>
<Card.Content>
<Button variant="outline" href="/dashboard/settings/facturacion">
Abrir datos fiscales <ChevronRight class="ml-1 h-4 w-4" />
</Button>
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>Configuración del sistema</Card.Title>

View File

@@ -1,253 +1,23 @@
<script lang="ts">
import { Users, X, UserPlus, Copy, Check } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Users } from 'lucide-svelte';
import * as Card from '$lib/components/ui/card';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { usersAPI, type User } from '$lib/api/dashboard/users';
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles';
const inputCls =
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let users = $state<User[]>([]);
let roles = $state<CompanyRole[]>([]);
let assignments = $state<UserRole[]>([]);
let loading = $state(true);
let busy = $state<string | null>(null);
// Alta de usuario por invitación
let inviteEmail = $state('');
let inviteRoleId = $state<number | null>(null);
let inviting = $state(false);
let inviteUrl = $state<string | null>(null);
let copied = $state(false);
// user_id → asignaciones de rol
const rolesByUser = $derived.by(() => {
const m: Record<string, UserRole[]> = {};
for (const a of assignments) (m[String(a.user_id)] ??= []).push(a);
return m;
});
function fullName(u: User): string {
const n = `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim();
return n || u.username || u.email || u.id;
}
async function load() {
if (!companyId) {
loading = false;
return;
}
loading = true;
try {
const [uRes, rRes, arRes] = await Promise.all([
usersAPI.list(companyId, { page_size: 100 }).catch(() => ({ users: [] as User[] })),
rolesAPI.list(companyId, { page_size: 100 }),
userRolesAPI.list(companyId, { page_size: 500 }).catch(() => ({ items: [] as UserRole[] } as any))
]);
users = uRes.users ?? [];
roles = rRes.data?.items ?? [];
assignments = arRes.items ?? [];
} catch {
toast.error('No se pudieron cargar los usuarios');
} finally {
loading = false;
}
}
// Recarga al cambiar la compañía activa —incluida la hidratación inicial tras
// refrescar (antes no cargaba hasta re-seleccionar la compañía).
$effect(() => {
const id = companyId;
if (id) {
void load();
} else {
users = [];
loading = false;
}
});
async function assignRole(user: User, roleIdRaw: string) {
if (!companyId) return;
const roleId = Number(roleIdRaw);
if (!roleId) return;
if (rolesByUser[String(user.id)]?.some((a) => a.company_role_id === roleId)) {
toast.info('El usuario ya tiene ese rol');
return;
}
busy = `assign:${user.id}`;
try {
await userRolesAPI.assign(companyId, { user_id: String(user.id), company_role_id: roleId });
toast.success('Rol asignado');
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo asignar el rol');
} finally {
busy = null;
}
}
async function removeRole(a: UserRole) {
if (!companyId) return;
busy = `remove:${a.id}`;
try {
await userRolesAPI.remove(a.id, companyId);
toast.success('Rol removido');
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo remover el rol');
} finally {
busy = null;
}
}
async function inviteUser() {
if (!companyId) return;
const email = inviteEmail.trim();
if (!email || !email.includes('@')) {
toast.error('Ingresa un email válido');
return;
}
if (!inviteRoleId) {
toast.error('Selecciona el rol del usuario');
return;
}
inviting = true;
inviteUrl = null;
try {
const res = await usersAPI.invite({ email, company_id: companyId, role_id: inviteRoleId });
toast.success('Invitación creada');
inviteUrl = res.invite_url ?? null;
inviteEmail = '';
await load();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo dar de alta el usuario');
} finally {
inviting = false;
}
}
async function copyInvite() {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
copied = true;
toast.success('Enlace copiado');
setTimeout(() => (copied = false), 2000);
} catch {
toast.error('No se pudo copiar');
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Users class="h-6 w-6" /> Usuarios
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
<Users class="h-6 w-6" />
Usuarios
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones).
</p>
<p class="text-muted-foreground text-sm mt-1">Gestión de usuarios y accesos.</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><UserPlus class="h-4 w-4" /> Dar de alta usuario</Card.Title>
<Card.Description>Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Email *</span>
<input type="email" class={inputCls} bind:value={inviteEmail} placeholder="persona@empresa.com" />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Rol *</span>
<select class={inputCls} bind:value={inviteRoleId}>
<option value={null} disabled>Selecciona…</option>
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
</select>
</label>
</div>
<div class="mt-4 flex items-center justify-between gap-2">
{#if roles.length === 0}
<span class="text-xs text-muted-foreground">Primero crea roles en "Roles y permisos".</span>
{:else}<span></span>{/if}
<Button onclick={inviteUser} disabled={inviting || roles.length === 0}>{inviting ? 'Enviando…' : 'Invitar / dar de alta'}</Button>
</div>
{#if inviteUrl}
<div class="mt-3 flex items-center gap-2 rounded-md border bg-muted/40 p-2">
<span class="whitespace-nowrap text-xs text-muted-foreground">Si el correo no llega, comparte:</span>
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={inviteUrl} />
<Button variant="outline" size="sm" onclick={copyInvite}>{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Usuarios ({users.length})</Card.Title>
<Card.Description>Asigna o quita roles (los roles se definen en Roles y permisos).</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if users.length === 0}
<p class="text-sm text-muted-foreground">No hay usuarios en esta compañía todavía.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Usuario</th>
<th class="py-2 pr-4 font-medium">Email</th>
<th class="py-2 pr-4 font-medium">Roles</th>
<th class="py-2 pr-4 font-medium">Asignar rol</th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr class="border-b last:border-0 align-top">
<td class="py-2 pr-4">{fullName(u)}</td>
<td class="py-2 pr-4 text-muted-foreground">{u.email ?? '—'}</td>
<td class="py-2 pr-4">
<div class="flex flex-wrap gap-1">
{#each rolesByUser[String(u.id)] ?? [] as a (a.id)}
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`}
<button type="button" class="hover:text-destructive" disabled={busy === `remove:${a.id}`} onclick={() => removeRole(a)} title="Quitar rol"><X class="h-3 w-3" /></button>
</span>
{:else}
<span class="text-xs text-muted-foreground">sin rol</span>
{/each}
</div>
</td>
<td class="py-2 pr-4">
<select
class={inputCls}
disabled={busy === `assign:${u.id}` || roles.length === 0}
onchange={(e) => { const el = e.currentTarget as HTMLSelectElement; assignRole(u, el.value); el.value = ''; }}
>
<option value="">+ rol…</option>
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
</select>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>Usuarios del sistema</Card.Title>
<Card.Description>Implementa aquí la gestión de usuarios de tu proyecto.</Card.Description>
</Card.Header>
<Card.Content>
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,78 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getKcAccessToken } from '$lib/server/api';
import {
listWorkspaceTenants,
createWorkspaceTenant,
type CreateTenantInput
} from '$lib/server/workspace-provision';
import { validateTenantForm } from '$lib/server/workspace-provision.shared';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) {
return { tenants: [], forbidden: true, loadError: null };
}
const res = await listWorkspaceTenants(accessToken, fetch);
if (!res.ok) {
// 401/403 → el usuario no es admin del workspace: se muestra estado informativo,
// no un error. Otros status sí se reportan como error de carga.
return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error };
}
return { tenants: res.data, forbidden: false, loadError: null };
};
export const actions: Actions = {
create: async ({ request, cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
const data = await request.formData();
const name = String(data.get('name') ?? '').trim();
const slug = String(data.get('slug') ?? '')
.trim()
.toLowerCase();
const contact_email = String(data.get('contact_email') ?? '').trim();
const contact_name = String(data.get('contact_name') ?? '').trim();
const contact_phone = String(data.get('contact_phone') ?? '').trim();
const display_name = String(data.get('display_name') ?? '').trim();
const app_url = String(data.get('app_url') ?? '').trim();
const is_self_hosted = data.get('is_self_hosted') === 'on';
// Valores para repoblar el formulario si algo falla.
const values = {
name,
slug,
contact_email,
contact_name,
contact_phone,
display_name,
app_url,
is_self_hosted
};
const validationError = validateTenantForm({ name, slug, contact_email });
if (validationError) return fail(422, { error: validationError, values });
// Solo se envían los campos opcionales con valor, para no mandar strings vacíos.
const payload: CreateTenantInput = {
name,
slug,
contact_email,
is_self_hosted,
...(contact_name ? { contact_name } : {}),
...(contact_phone ? { contact_phone } : {}),
...(display_name ? { display_name } : {}),
...(app_url ? { app_url } : {})
};
const res = await createWorkspaceTenant(accessToken, fetch, payload);
if (!res.ok) {
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
}
return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug };
}
};

View File

@@ -1,180 +0,0 @@
<script lang="ts">
import { Building2, Plus, ShieldAlert, RefreshCw } from '@lucide/svelte';
import { enhance } from '$app/forms';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// Valores repoblados tras un fallo de validación del servidor.
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
function prev(field: string): string {
const val = v?.[field];
return typeof val === 'string' ? val : '';
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" /> Organizaciones del workspace
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base.
</p>
</div>
{#if data.forbidden}
<Card.Root>
<Card.Content class="flex items-start gap-3 pt-6">
<ShieldAlert class="mt-0.5 h-5 w-5 text-amber-500" />
<div class="text-sm">
<p class="font-medium">Requiere permisos de administrador del workspace</p>
<p class="mt-1 text-muted-foreground">
El alta de organizaciones solo está disponible para administradores del Hub
(<code>hub_admin</code>). Tu usuario actual no tiene ese rol.
</p>
</div>
</Card.Content>
</Card.Root>
{:else}
{#if data.loadError}
<div class="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
No se pudo consultar el workspace: {data.loadError}
</div>
{/if}
<!-- Alta de organización -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva organización</Card.Title>
<Card.Description>El slug identifica al tenant; no se puede cambiar después.</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/create"
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'success') {
toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`);
await update({ reset: true });
} else if (result.type === 'failure') {
toast.error(String(result.data?.error ?? 'No se pudo crear la organización'));
await update({ reset: false });
} else {
await update();
}
};
}}
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Nombre / Razón social *</span>
<input class={inputCls} name="name" required minlength="2" value={prev('name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Slug *</span>
<input
class="font-mono {inputCls}"
name="slug"
required
pattern="[a-z0-9-]+"
placeholder="empresa-abc"
title="Solo minúsculas, dígitos y guiones"
value={prev('slug')}
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre para mostrar</span>
<input class={inputCls} name="display_name" value={prev('display_name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Contacto (nombre)</span>
<input class={inputCls} name="contact_name" value={prev('contact_name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Email de contacto *</span>
<input type="email" class={inputCls} name="contact_email" required value={prev('contact_email')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Teléfono de contacto</span>
<input class={inputCls} name="contact_phone" value={prev('contact_phone')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">URL de la app (opcional)</span>
<input class={inputCls} name="app_url" placeholder="https://app.empresa-abc.com" value={prev('app_url')} />
</label>
<label class="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" name="is_self_hosted" class="h-4 w-4 rounded border" />
<span>Cliente self-hosted (instalación propia)</span>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Creando…' : 'Crear organización'}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<!-- Organizaciones existentes -->
<Card.Root>
<Card.Header>
<Card.Title>Organizaciones ({data.tenants.length})</Card.Title>
<Card.Description>Tenants activos en el workspace.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.tenants.length === 0}
<p class="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw class="h-4 w-4" /> Aún no hay organizaciones registradas.
</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Nombre</th>
<th class="py-2 pr-4 font-medium">Slug</th>
<th class="py-2 pr-4 font-medium">Contacto</th>
<th class="py-2 pr-4 font-medium">Estatus</th>
<th class="py-2 pr-4 font-medium">Licencia</th>
</tr>
</thead>
<tbody>
{#each data.tenants as t (t.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{t.display_name || t.name}</td>
<td class="py-2 pr-4 font-mono text-xs">{t.slug}</td>
<td class="py-2 pr-4">{t.contact_email}</td>
<td class="py-2 pr-4">
<span
class="rounded-full px-2 py-0.5 text-xs {t.status === 'active'
? 'bg-emerald-500/15 text-emerald-600'
: 'bg-muted text-muted-foreground'}"
>
{t.status}
</span>
</td>
<td class="py-2 pr-4">{t.has_license ? 'Sí' : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -1,60 +0,0 @@
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getUserCompanies } from '$lib/server/api';
import { createWorkspaceInvite } from '$lib/server/workspace-provision';
import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
// La organización destino sale de las compañías del usuario (mismo origen que
// el switcher, vía /v1/auth/my-companies). Así no se escribe el slug a mano ni
// se depende de listar todos los tenants del Hub con un token KC que caduca.
const companiesRaw = await getUserCompanies(cookies, fetch);
const companies = (companiesRaw ?? [])
.filter((c) => c && c.tenant_slug)
.map((c) => ({
id: c.id as number,
name: c.name as string,
tenant_slug: c.tenant_slug as string,
tenant_name: (c.tenant_name as string) || (c.tenant_slug as string)
}));
return { companies, roles: WORKSPACE_INVITE_ROLES };
};
export const actions: Actions = {
invite: async ({ request, cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
const data = await request.formData();
const email = String(data.get('email') ?? '').trim();
const tenant_slug = String(data.get('tenant_slug') ?? '')
.trim()
.toLowerCase();
const role = String(data.get('role') ?? 'user').trim();
const values = { email, tenant_slug, role };
if (!email || !email.includes('@')) {
return fail(422, { error: 'Ingresa un email válido para invitar.', values });
}
if (!tenant_slug) {
return fail(422, { error: 'Selecciona la compañía destino.', values });
}
if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) {
return fail(422, { error: 'Rol inválido.', values });
}
const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role });
if (!res.ok) {
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
}
return {
success: true,
email: res.data.email,
inviteUrl: res.data.invite_url,
expiresAt: res.data.expires_at
};
}
};

View File

@@ -1,141 +0,0 @@
<script lang="ts">
import { UserPlus, Copy, Check, MailCheck } from '@lucide/svelte';
import { enhance } from '$app/forms';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
let copied = $state(false);
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
function prev(field: string): string {
const val = v?.[field];
return typeof val === 'string' ? val : '';
}
// Invitación recién creada (para copiar/compartir el enlace).
const created = $derived(
form && 'success' in form && form.success
? { email: form.email, inviteUrl: form.inviteUrl }
: null
);
async function copyInvite(url: string) {
try {
await navigator.clipboard.writeText(url);
copied = true;
toast.success('Enlace de invitación copiado');
setTimeout(() => (copied = false), 2000);
} catch {
toast.error('No se pudo copiar el enlace');
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<UserPlus class="h-6 w-6" /> Alta de usuarios (invitaciones)
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para
fijar su contraseña y activarse.
</p>
</div>
{#if created}
<Card.Root class="border-emerald-500/40">
<Card.Content class="pt-6">
<div class="flex items-start gap-3">
<MailCheck class="mt-0.5 h-5 w-5 text-emerald-600" />
<div class="min-w-0 flex-1 text-sm">
<p class="font-medium">Invitación enviada a {created.email}</p>
<p class="mt-1 text-muted-foreground">
Si el correo no llega, comparte este enlace directamente:
</p>
<div class="mt-2 flex items-center gap-2">
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={created.inviteUrl} />
<Button variant="outline" size="sm" onclick={() => copyInvite(String(created.inviteUrl))}>
{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}
</Button>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>Nueva invitación</Card.Title>
<Card.Description>Elige la compañía destino y el rol del usuario.</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/invite"
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'success') {
toast.success('Invitación creada');
await update({ reset: true });
} else if (result.type === 'failure') {
toast.error(String(result.data?.error ?? 'No se pudo crear la invitación'));
await update({ reset: false });
} else {
await update();
}
};
}}
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Email del usuario *</span>
<input type="email" class={inputCls} name="email" required value={prev('email')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Compañía destino *</span>
{#if data.companies.length > 0}
<select class={inputCls} name="tenant_slug" required value={prev('tenant_slug')}>
<option value="" disabled>Selecciona…</option>
{#each data.companies as c (c.id)}
<option value={c.tenant_slug}>{c.name} {c.tenant_name}</option>
{/each}
</select>
{:else}
<span class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
No hay compañías disponibles en tu tenant. Crea una en Compañías.
</span>
<input type="hidden" name="tenant_slug" value="" />
{/if}
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Rol *</span>
<select class={inputCls} name="role" required value={prev('role') || 'user'}>
{#each data.roles as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={submitting || data.companies.length === 0}>
{submitting ? 'Enviando…' : 'Enviar invitación'}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,7 +1,7 @@
import { redirect, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getServerApiUrl, getAuthTokens } from '$lib/server/api';
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { redirectToKeycloakLogin } from '$lib/server/workspace-auth';
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
const code = url.searchParams.get('code')?.toUpperCase().trim() ?? '';
@@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
if (!accessToken) {
// Sesión KC expiró entre redirecciones — volver a auth
redirectToWorkspaceLogin(cookies, url);
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
}
const apiUrl = getServerApiUrl();
@@ -76,7 +76,7 @@ export const actions: Actions = {
if (!accessToken) {
// Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume
redirectToWorkspaceLogin(cookies, url);
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
}
// Si ya hay sesión, consumir directamente vía redirect a step=consume

View File

@@ -4,9 +4,12 @@ import type { Actions, PageServerLoad } from './$types';
import { clearAuthTokens, getAuthTokens } from '$lib/server/api';
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
getWorkspaceLoginUrl,
readWorkspaceReturnPath,
clearWorkspaceReturnPath,
redirectToWorkspaceLogin,
storeReturnPath,
redirectToKeycloakAuthorization,
redirectToKeycloakLogin,
getHubBackendUrl,
isSecureContext,
} from '$lib/server/workspace-auth';
@@ -62,17 +65,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
clearAuthTokens(cookies);
// Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo
// OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher
// (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le
// manda al dashboard; si no hay sesión local, el layout lo reenvía al
// Workspace (App Launcher) para re-entrar por relay.
if (url.searchParams.get('sso_verified') === '1') {
throw redirect(303, '/dashboard');
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: (url.searchParams.get('redirect') || '/dashboard');
storeReturnPath(cookies, intendedPath);
redirectToKeycloakAuthorization(url.origin, intendedPath);
}
// Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo.
redirectToWorkspaceLogin(cookies, url);
const redirectParam = url.searchParams.get('redirect');
if (redirectParam) {
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
const intendedPath =
existingReturnPath && existingReturnPath !== '/login'
? existingReturnPath
: redirectParam;
storeReturnPath(cookies, intendedPath);
redirectToKeycloakLogin(url.origin, intendedPath);
}
storeReturnPath(cookies, '/dashboard');
throw redirect(303, getWorkspaceLoginUrl(url.origin));
};
export const actions: Actions = {

View File

@@ -1,28 +1,37 @@
import { redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';
import { clearAuthTokens } from '$lib/server/api';
import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
import {
buildKeycloakLogoutUrl,
clearWorkspaceReturnPath,
getWorkspaceLoginUrl
} from '$lib/server/workspace-auth';
/**
* Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace.
* NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace
* desde el Workspace. Patrón SIWEB.
*/
export const POST: RequestHandler = async ({ cookies }) => {
// Eliminar todas las cookies de autenticación (incluye sesión local + token KC)
clearAuthTokens(cookies);
export const POST: RequestHandler = async ({ cookies, url }) => {
const systemBaseUrl = url.origin;
const idToken = cookies.get('id_token');
// Eliminar todas las cookies de autenticación
clearAccessTokenCookies(cookies);
cookies.delete('refresh_token', { path: '/' });
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
cookies.delete('sso_tenant_id', { path: '/' });
cookies.delete('sso_tenant_pub', { path: '/' });
clearWorkspaceReturnPath(cookies);
// Modo local: no hay Workspace — ir al login local.
// En modo local no hay Keycloak ni Hub — ir directo al login local.
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
throw redirect(303, '/login');
}
// Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por
// completo, el usuario cierra sesión desde el Workspace.
throw redirect(303, getWorkspaceLoginUrl());
// Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado.
if (!idToken) {
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
}
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken));
};