From 5a112a017126e5db7cd16bcf0338f9befeb03f3a Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 16:58:09 -0500 Subject: [PATCH] feat(fin): pantallas de conceptos y datos fiscales del emisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/lib/api/fin/catalogs.ts | 94 ++++ frontend/src/lib/api/fin/concepts.ts | 81 ++++ frontend/src/lib/api/fin/index.ts | 4 + frontend/src/lib/api/fin/issuer.ts | 51 ++ .../src/lib/components/sidebar/modules.ts | 5 + .../dashboard/fin/conceptos/+page.svelte | 458 ++++++++++++++++++ .../settings/facturacion/+page.svelte | 200 ++++++++ .../dashboard/settings/facturacion/+page.ts | 1 + .../dashboard/settings/general/+page.svelte | 25 +- 9 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/api/fin/catalogs.ts create mode 100644 frontend/src/lib/api/fin/concepts.ts create mode 100644 frontend/src/lib/api/fin/issuer.ts create mode 100644 frontend/src/routes/dashboard/fin/conceptos/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/facturacion/+page.svelte create mode 100644 frontend/src/routes/dashboard/settings/facturacion/+page.ts 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. +

+
+ +
+ + + +
+
+ + companyId && load(companyId)} + /> +
+ +
+
+ + {#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'} + + + + + + + + {/each} + + +
+ {/if} +
+
+
+ + { + if (modalOpen && e.key === 'Escape') modalOpen = false; + }} +/> + +{#if modalOpen} + +{/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} +
+ + + + + + + + +
+ +
+ {#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. + + + + + + + {/if} + Configuración del sistema