diff --git a/frontend/src/lib/api/fin/catalogs.ts b/frontend/src/lib/api/fin/catalogs.ts
new file mode 100644
index 0000000..a4cfb21
--- /dev/null
+++ b/frontend/src/lib/api/fin/catalogs.ts
@@ -0,0 +1,94 @@
+/**
+ * Cliente API — Catálogos del SAT (solo lectura).
+ *
+ * Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la
+ * sesión, así que se guardan en un `Map` del módulo para no repetir la petición en
+ * cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone.
+ */
+import { api } from '$lib/api';
+
+export interface SatCatalogItem {
+ id: number;
+ code: string;
+ description: string;
+ is_active: boolean;
+}
+
+export interface SatTaxRegime extends SatCatalogItem {
+ applies_to_individual: boolean; // persona física
+ applies_to_legal_entity: boolean; // persona moral
+}
+
+export interface SatTax extends SatCatalogItem {
+ is_withholding: boolean;
+ is_transferred: boolean;
+ is_local: boolean;
+}
+
+/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */
+export interface SatUnitOfMeasure extends Omit {
+ description: string | null;
+ name: string;
+ symbol: string | null;
+}
+
+export type PersonType = 'fisica' | 'moral';
+
+type CatalogParams = Record;
+
+/** Cache en memoria del módulo, con la query string completa como llave. */
+const cache = new Map();
+
+function buildQuery(companyId: number, params?: CatalogParams): string {
+ const qs = new URLSearchParams({ company_id: String(companyId) });
+ for (const [key, value] of Object.entries(params ?? {})) {
+ if (value !== undefined && value !== '') qs.set(key, String(value));
+ }
+ qs.sort(); // llave de cache estable sin importar el orden de los parámetros
+ return qs.toString();
+}
+
+async function fetchCatalog(
+ path: string,
+ companyId: number,
+ params?: CatalogParams
+): Promise {
+ const query = buildQuery(companyId, params);
+ const key = `${path}?${query}`;
+ const cached = cache.get(key);
+ if (cached) return cached as T[];
+
+ const res = await api.get(`/v1/fin/catalogs/${path}?${query}`);
+ if (res.error) throw new Error(res.error);
+ const rows = res.data ?? [];
+ cache.set(key, rows);
+ return rows;
+}
+
+/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */
+export function clearCatalogCache(): void {
+ cache.clear();
+}
+
+export const satCatalogsAPI = {
+ taxRegimes: (
+ companyId: number,
+ params?: { search?: string; person_type?: PersonType; active_only?: boolean }
+ ) => fetchCatalog('tax-regimes', companyId, params),
+ taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('taxes', companyId, params),
+ paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('payment-forms', companyId, params),
+ unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('units-of-measure', companyId, params),
+ productsServices: (
+ companyId: number,
+ params?: { search?: string; limit?: number; active_only?: boolean }
+ ) => fetchCatalog('products-services', companyId, params),
+ voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('voucher-types', companyId, params),
+ paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('payment-methods', companyId, params),
+ taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
+ fetchCatalog('tax-objects', companyId, params)
+};
diff --git a/frontend/src/lib/api/fin/concepts.ts b/frontend/src/lib/api/fin/concepts.ts
new file mode 100644
index 0000000..975aba2
--- /dev/null
+++ b/frontend/src/lib/api/fin/concepts.ts
@@ -0,0 +1,81 @@
+/**
+ * Cliente API — Catálogo de conceptos de facturación.
+ *
+ * Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
+ * empresa; el backend responde 409 si la clave ya está tomada.
+ */
+import { api } from '$lib/api';
+import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
+
+export interface Concept {
+ id: number;
+ code: string;
+ description: string;
+ product_service_id: number;
+ unit_of_measure_id: number | null;
+ tax_object_id: number | null;
+ unit_price: number | null;
+ currency: string;
+ is_active: boolean;
+ notes: string | null;
+ product_service: SatCatalogItem | null;
+ unit_of_measure: SatUnitOfMeasure | null;
+ tax_object: SatCatalogItem | null;
+ tenant_id: number;
+ company_id: number;
+ created_by: string | null;
+ updated_by: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ConceptInput {
+ code: string;
+ description: string;
+ product_service_id: number;
+ unit_of_measure_id?: number | null;
+ tax_object_id?: number | null;
+ unit_price?: number | null;
+ currency?: string;
+ is_active?: boolean;
+ notes?: string | null;
+}
+
+export const conceptsAPI = {
+ async list(
+ companyId: number,
+ params?: { search?: string; active_only?: boolean; product_service_id?: number }
+ ): Promise {
+ const qs = new URLSearchParams({ company_id: String(companyId) });
+ if (params?.search) qs.set('search', params.search);
+ if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
+ if (params?.product_service_id !== undefined)
+ qs.set('product_service_id', String(params.product_service_id));
+ const res = await api.get(`/v1/fin/concepts?${qs}`);
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ },
+
+ async get(id: number, companyId: number): Promise {
+ const res = await api.get(`/v1/fin/concepts/${id}?company_id=${companyId}`);
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ },
+
+ async create(data: ConceptInput, companyId: number): Promise {
+ const res = await api.post(`/v1/fin/concepts?company_id=${companyId}`, data);
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ },
+
+ async update(id: number, data: Partial, companyId: number): Promise {
+ const res = await api.patch(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ },
+
+ async remove(id: number, companyId: number): Promise {
+ const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
+ if (res.error) throw new Error(res.error);
+ }
+};
diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts
index e04e708..052329e 100644
--- a/frontend/src/lib/api/fin/index.ts
+++ b/frontend/src/lib/api/fin/index.ts
@@ -3,6 +3,10 @@
*/
import { api } from '$lib/api';
+export * from './catalogs';
+export * from './concepts';
+export * from './issuer';
+
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
export interface Invoice {
diff --git a/frontend/src/lib/api/fin/issuer.ts b/frontend/src/lib/api/fin/issuer.ts
new file mode 100644
index 0000000..69cfc9c
--- /dev/null
+++ b/frontend/src/lib/api/fin/issuer.ts
@@ -0,0 +1,51 @@
+/**
+ * Cliente API — Datos fiscales del emisor (una configuración por empresa).
+ */
+import { api } from '$lib/api';
+import type { SatTaxRegime } from './catalogs';
+
+/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */
+export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
+
+export interface IssuerSettings {
+ id: number;
+ tenant_id: number;
+ company_id: number;
+ legal_name: string;
+ rfc: string;
+ tax_regime_id: number;
+ tax_regime: SatTaxRegime | null;
+ zip_code: string | null;
+ updated_by: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface IssuerSettingsInput {
+ legal_name: string;
+ rfc: string;
+ tax_regime_id: number;
+ zip_code?: string | null;
+}
+
+export const issuerAPI = {
+ /**
+ * Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el
+ * backend responde 404 y la pantalla debe abrirse en modo alta, no en error.
+ */
+ async get(companyId: number): Promise {
+ const res = await api.get(`/v1/fin/settings/issuer?company_id=${companyId}`);
+ if (res.status === 404) return null;
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ },
+
+ async save(data: IssuerSettingsInput, companyId: number): Promise {
+ const res = await api.put(
+ `/v1/fin/settings/issuer?company_id=${companyId}`,
+ data
+ );
+ if (res.error) throw new Error(res.error);
+ return res.data!;
+ }
+};
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts
index dbd39e0..bd901a3 100644
--- a/frontend/src/lib/components/sidebar/modules.ts
+++ b/frontend/src/lib/components/sidebar/modules.ts
@@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] {
icon: Receipt,
items: [
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
+ { title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' },
],
},
{
@@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
title: 'Configuración',
url: '/dashboard/settings/general',
icon: Settings2,
+ items: [
+ { title: 'General', url: '/dashboard/settings/general' },
+ { title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
+ ],
},
];
}
diff --git a/frontend/src/routes/dashboard/fin/conceptos/+page.svelte b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte
new file mode 100644
index 0000000..ed87f66
--- /dev/null
+++ b/frontend/src/routes/dashboard/fin/conceptos/+page.svelte
@@ -0,0 +1,458 @@
+
+
+
+ Conceptos de facturación
+
+
+
+
+
+
+
+ Conceptos de facturación
+
+
+ Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
+ empresa.
+
+
+
+ Nuevo concepto
+
+
+
+
+
+
+
+
+ companyId && load(companyId)}
+ />
+
+
companyId && load(companyId)}
+ >
+ Todos
+ Solo activos
+ Solo inactivos
+
+
+
+
+ {#if loading}
+ Cargando…
+ {:else if items.length === 0}
+ Sin conceptos registrados.
+ {:else}
+
+
+
+
+ Clave
+ Descripción
+ Clave ProdServ
+ Unidad
+ Objeto de impuesto
+ Precio unitario
+ Estado
+ Acciones
+
+
+
+ {#each items as concept (concept.id)}
+
+ {concept.code}
+ {concept.description}
+
+ {concept.product_service?.code ?? '—'}
+ {#if concept.product_service}
+ {concept.product_service.description}
+ {/if}
+
+ {concept.unit_of_measure?.name ?? '—'}
+ {concept.tax_object?.code ?? '—'}
+ {money(concept.unit_price)} {concept.currency}
+
+
+ {concept.is_active ? 'Activo' : 'Inactivo'}
+
+
+
+ openEdit(concept)}
+ aria-label="Editar"
+ >
+
+
+ remove(concept)}
+ aria-label="Dar de baja"
+ >
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+ {
+ if (modalOpen && e.key === 'Escape') modalOpen = false;
+ }}
+/>
+
+{#if modalOpen}
+ (modalOpen = false)}
+ >
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
{editingId ? 'Editar concepto' : 'Nuevo concepto'}
+
+
+
+{/if}
diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.svelte b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte
new file mode 100644
index 0000000..1113d26
--- /dev/null
+++ b/frontend/src/routes/dashboard/settings/facturacion/+page.svelte
@@ -0,0 +1,200 @@
+
+
+
+ Configuración de Facturación
+
+
+
+
+
+
+ Datos fiscales del emisor
+
+
+ Identidad fiscal con la que la empresa emite sus comprobantes.
+
+
+
+ {#if !canView}
+
+
+
+ No tienes permiso para consultar los datos fiscales del emisor.
+
+
+
+ {:else}
+
+
+ {isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}
+
+ {isNew
+ ? 'Esta empresa aún no tiene datos fiscales configurados.'
+ : 'Actualiza la información con la que se emiten los comprobantes.'}
+
+
+
+ {#if loading}
+ Cargando…
+ {:else}
+
+
+ Razón social *
+
+
+
+
+ RFC *
+ (rfcError = '')}
+ />
+ {#if rfcError}{rfcError} {/if}
+
+
+
+ Código postal del lugar de expedición
+
+
+
+
+ Régimen fiscal *
+
+ Selecciona un régimen…
+ {#each taxRegimes as regime (regime.id)}
+ {regime.code} — {regime.description}
+ {/each}
+
+
+
+
+
+ {saving ? 'Guardando…' : 'Guardar'}
+
+
+ {#if !canEdit}
+
+ Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
+
+ {/if}
+
+ {/if}
+
+
+ {/if}
+
diff --git a/frontend/src/routes/dashboard/settings/facturacion/+page.ts b/frontend/src/routes/dashboard/settings/facturacion/+page.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/frontend/src/routes/dashboard/settings/facturacion/+page.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/frontend/src/routes/dashboard/settings/general/+page.svelte b/frontend/src/routes/dashboard/settings/general/+page.svelte
index 0f1206d..d01c340 100644
--- a/frontend/src/routes/dashboard/settings/general/+page.svelte
+++ b/frontend/src/routes/dashboard/settings/general/+page.svelte
@@ -1,6 +1,10 @@
@@ -18,6 +22,25 @@
+ {#if canViewIssuerSettings}
+
+
+
+
+ Facturación
+
+
+ Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
+
+
+
+
+ Abrir datos fiscales
+
+
+
+ {/if}
+
Configuración del sistema