feat: Implement Phase 1 & 2 - Full monorepo architecture
## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@horux/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Shared types and utilities for Horux Strategy",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"eslint": "^8.56.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
630
packages/shared/src/constants/index.ts
Normal file
630
packages/shared/src/constants/index.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* Constants for Horux Strategy
|
||||
* Roles, permissions, document states, and error codes
|
||||
*/
|
||||
|
||||
import type { UserRole, UserPermission } from '../types/auth';
|
||||
|
||||
// ============================================================================
|
||||
// Roles & Permissions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Available user roles
|
||||
*/
|
||||
export const USER_ROLES = {
|
||||
SUPER_ADMIN: 'super_admin' as const,
|
||||
TENANT_ADMIN: 'tenant_admin' as const,
|
||||
ACCOUNTANT: 'accountant' as const,
|
||||
ASSISTANT: 'assistant' as const,
|
||||
VIEWER: 'viewer' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Role display names in Spanish
|
||||
*/
|
||||
export const ROLE_NAMES: Record<UserRole, string> = {
|
||||
super_admin: 'Super Administrador',
|
||||
tenant_admin: 'Administrador',
|
||||
accountant: 'Contador',
|
||||
assistant: 'Asistente',
|
||||
viewer: 'Solo Lectura',
|
||||
};
|
||||
|
||||
/**
|
||||
* Role descriptions
|
||||
*/
|
||||
export const ROLE_DESCRIPTIONS: Record<UserRole, string> = {
|
||||
super_admin: 'Acceso completo al sistema y todas las empresas',
|
||||
tenant_admin: 'Administración completa de la empresa',
|
||||
accountant: 'Acceso completo a funciones contables y financieras',
|
||||
assistant: 'Acceso limitado para captura de información',
|
||||
viewer: 'Solo puede visualizar información, sin editar',
|
||||
};
|
||||
|
||||
/**
|
||||
* Available resources for permissions
|
||||
*/
|
||||
export const RESOURCES = {
|
||||
TRANSACTIONS: 'transactions',
|
||||
INVOICES: 'invoices',
|
||||
CONTACTS: 'contacts',
|
||||
ACCOUNTS: 'accounts',
|
||||
CATEGORIES: 'categories',
|
||||
REPORTS: 'reports',
|
||||
SETTINGS: 'settings',
|
||||
USERS: 'users',
|
||||
BILLING: 'billing',
|
||||
INTEGRATIONS: 'integrations',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default permissions per role
|
||||
*/
|
||||
export const DEFAULT_ROLE_PERMISSIONS: Record<UserRole, UserPermission[]> = {
|
||||
super_admin: [
|
||||
{ resource: '*', actions: ['create', 'read', 'update', 'delete'] },
|
||||
],
|
||||
tenant_admin: [
|
||||
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'contacts', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'accounts', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'categories', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'reports', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'settings', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'users', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'billing', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'integrations', actions: ['create', 'read', 'update', 'delete'] },
|
||||
],
|
||||
accountant: [
|
||||
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
|
||||
{ resource: 'contacts', actions: ['create', 'read', 'update'] },
|
||||
{ resource: 'accounts', actions: ['create', 'read', 'update'] },
|
||||
{ resource: 'categories', actions: ['create', 'read', 'update'] },
|
||||
{ resource: 'reports', actions: ['create', 'read'] },
|
||||
{ resource: 'settings', actions: ['read'] },
|
||||
{ resource: 'integrations', actions: ['read'] },
|
||||
],
|
||||
assistant: [
|
||||
{ resource: 'transactions', actions: ['create', 'read', 'update'] },
|
||||
{ resource: 'invoices', actions: ['create', 'read'] },
|
||||
{ resource: 'contacts', actions: ['create', 'read'] },
|
||||
{ resource: 'accounts', actions: ['read'] },
|
||||
{ resource: 'categories', actions: ['read'] },
|
||||
{ resource: 'reports', actions: ['read'] },
|
||||
],
|
||||
viewer: [
|
||||
{ resource: 'transactions', actions: ['read'] },
|
||||
{ resource: 'invoices', actions: ['read'] },
|
||||
{ resource: 'contacts', actions: ['read'] },
|
||||
{ resource: 'accounts', actions: ['read'] },
|
||||
{ resource: 'categories', actions: ['read'] },
|
||||
{ resource: 'reports', actions: ['read'] },
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Document States
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Transaction statuses
|
||||
*/
|
||||
export const TRANSACTION_STATUS = {
|
||||
PENDING: 'pending',
|
||||
CLEARED: 'cleared',
|
||||
RECONCILED: 'reconciled',
|
||||
VOIDED: 'voided',
|
||||
} as const;
|
||||
|
||||
export const TRANSACTION_STATUS_NAMES: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
cleared: 'Procesado',
|
||||
reconciled: 'Conciliado',
|
||||
voided: 'Anulado',
|
||||
};
|
||||
|
||||
export const TRANSACTION_STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'yellow',
|
||||
cleared: 'blue',
|
||||
reconciled: 'green',
|
||||
voided: 'gray',
|
||||
};
|
||||
|
||||
/**
|
||||
* CFDI statuses
|
||||
*/
|
||||
export const CFDI_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
PENDING: 'pending',
|
||||
STAMPED: 'stamped',
|
||||
SENT: 'sent',
|
||||
PAID: 'paid',
|
||||
PARTIAL_PAID: 'partial_paid',
|
||||
CANCELLED: 'cancelled',
|
||||
CANCELLATION_PENDING: 'cancellation_pending',
|
||||
} as const;
|
||||
|
||||
export const CFDI_STATUS_NAMES: Record<string, string> = {
|
||||
draft: 'Borrador',
|
||||
pending: 'Pendiente de Timbrar',
|
||||
stamped: 'Timbrado',
|
||||
sent: 'Enviado',
|
||||
paid: 'Pagado',
|
||||
partial_paid: 'Pago Parcial',
|
||||
cancelled: 'Cancelado',
|
||||
cancellation_pending: 'Cancelación Pendiente',
|
||||
};
|
||||
|
||||
export const CFDI_STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
pending: 'yellow',
|
||||
stamped: 'blue',
|
||||
sent: 'indigo',
|
||||
paid: 'green',
|
||||
partial_paid: 'orange',
|
||||
cancelled: 'red',
|
||||
cancellation_pending: 'pink',
|
||||
};
|
||||
|
||||
/**
|
||||
* CFDI types
|
||||
*/
|
||||
export const CFDI_TYPES = {
|
||||
INGRESO: 'I',
|
||||
EGRESO: 'E',
|
||||
TRASLADO: 'T',
|
||||
NOMINA: 'N',
|
||||
PAGO: 'P',
|
||||
} as const;
|
||||
|
||||
export const CFDI_TYPE_NAMES: Record<string, string> = {
|
||||
I: 'Ingreso',
|
||||
E: 'Egreso',
|
||||
T: 'Traslado',
|
||||
N: 'Nómina',
|
||||
P: 'Pago',
|
||||
};
|
||||
|
||||
/**
|
||||
* CFDI Usage codes (Uso del CFDI)
|
||||
*/
|
||||
export const CFDI_USAGE_CODES: Record<string, string> = {
|
||||
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',
|
||||
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',
|
||||
D10: 'Pagos por servicios educativos (colegiaturas)',
|
||||
S01: 'Sin efectos fiscales',
|
||||
CP01: 'Pagos',
|
||||
CN01: 'Nómina',
|
||||
};
|
||||
|
||||
/**
|
||||
* Payment forms (Forma de pago SAT)
|
||||
*/
|
||||
export const PAYMENT_FORMS: Record<string, string> = {
|
||||
'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',
|
||||
};
|
||||
|
||||
/**
|
||||
* CFDI Cancellation reasons
|
||||
*/
|
||||
export const CFDI_CANCELLATION_REASONS: Record<string, string> = {
|
||||
'01': 'Comprobante emitido con errores con relación',
|
||||
'02': 'Comprobante emitido con errores sin relación',
|
||||
'03': 'No se llevó a cabo la operación',
|
||||
'04': 'Operación nominativa relacionada en una factura global',
|
||||
};
|
||||
|
||||
/**
|
||||
* Tenant statuses
|
||||
*/
|
||||
export const TENANT_STATUS = {
|
||||
PENDING: 'pending',
|
||||
ACTIVE: 'active',
|
||||
SUSPENDED: 'suspended',
|
||||
CANCELLED: 'cancelled',
|
||||
TRIAL: 'trial',
|
||||
EXPIRED: 'expired',
|
||||
} as const;
|
||||
|
||||
export const TENANT_STATUS_NAMES: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
active: 'Activo',
|
||||
suspended: 'Suspendido',
|
||||
cancelled: 'Cancelado',
|
||||
trial: 'Prueba',
|
||||
expired: 'Expirado',
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription statuses
|
||||
*/
|
||||
export const SUBSCRIPTION_STATUS = {
|
||||
TRIALING: 'trialing',
|
||||
ACTIVE: 'active',
|
||||
PAST_DUE: 'past_due',
|
||||
CANCELED: 'canceled',
|
||||
UNPAID: 'unpaid',
|
||||
PAUSED: 'paused',
|
||||
} as const;
|
||||
|
||||
export const SUBSCRIPTION_STATUS_NAMES: Record<string, string> = {
|
||||
trialing: 'Período de Prueba',
|
||||
active: 'Activa',
|
||||
past_due: 'Pago Atrasado',
|
||||
canceled: 'Cancelada',
|
||||
unpaid: 'Sin Pagar',
|
||||
paused: 'Pausada',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Error Codes
|
||||
// ============================================================================
|
||||
|
||||
export const ERROR_CODES = {
|
||||
// Authentication errors (1xxx)
|
||||
AUTH_INVALID_CREDENTIALS: 'AUTH_001',
|
||||
AUTH_TOKEN_EXPIRED: 'AUTH_002',
|
||||
AUTH_TOKEN_INVALID: 'AUTH_003',
|
||||
AUTH_REFRESH_TOKEN_EXPIRED: 'AUTH_004',
|
||||
AUTH_USER_NOT_FOUND: 'AUTH_005',
|
||||
AUTH_USER_DISABLED: 'AUTH_006',
|
||||
AUTH_EMAIL_NOT_VERIFIED: 'AUTH_007',
|
||||
AUTH_TWO_FACTOR_REQUIRED: 'AUTH_008',
|
||||
AUTH_TWO_FACTOR_INVALID: 'AUTH_009',
|
||||
AUTH_SESSION_EXPIRED: 'AUTH_010',
|
||||
AUTH_PASSWORD_INCORRECT: 'AUTH_011',
|
||||
AUTH_PASSWORD_WEAK: 'AUTH_012',
|
||||
AUTH_EMAIL_ALREADY_EXISTS: 'AUTH_013',
|
||||
AUTH_INVITATION_EXPIRED: 'AUTH_014',
|
||||
AUTH_INVITATION_INVALID: 'AUTH_015',
|
||||
|
||||
// Authorization errors (2xxx)
|
||||
AUTHZ_FORBIDDEN: 'AUTHZ_001',
|
||||
AUTHZ_INSUFFICIENT_PERMISSIONS: 'AUTHZ_002',
|
||||
AUTHZ_RESOURCE_NOT_ACCESSIBLE: 'AUTHZ_003',
|
||||
AUTHZ_TENANT_MISMATCH: 'AUTHZ_004',
|
||||
|
||||
// Validation errors (3xxx)
|
||||
VALIDATION_FAILED: 'VAL_001',
|
||||
VALIDATION_REQUIRED_FIELD: 'VAL_002',
|
||||
VALIDATION_INVALID_FORMAT: 'VAL_003',
|
||||
VALIDATION_INVALID_VALUE: 'VAL_004',
|
||||
VALIDATION_TOO_LONG: 'VAL_005',
|
||||
VALIDATION_TOO_SHORT: 'VAL_006',
|
||||
VALIDATION_OUT_OF_RANGE: 'VAL_007',
|
||||
VALIDATION_DUPLICATE: 'VAL_008',
|
||||
|
||||
// Resource errors (4xxx)
|
||||
RESOURCE_NOT_FOUND: 'RES_001',
|
||||
RESOURCE_ALREADY_EXISTS: 'RES_002',
|
||||
RESOURCE_CONFLICT: 'RES_003',
|
||||
RESOURCE_LOCKED: 'RES_004',
|
||||
RESOURCE_DELETED: 'RES_005',
|
||||
|
||||
// Business logic errors (5xxx)
|
||||
BUSINESS_INVALID_OPERATION: 'BIZ_001',
|
||||
BUSINESS_INSUFFICIENT_BALANCE: 'BIZ_002',
|
||||
BUSINESS_LIMIT_EXCEEDED: 'BIZ_003',
|
||||
BUSINESS_INVALID_STATE: 'BIZ_004',
|
||||
BUSINESS_DEPENDENCY_ERROR: 'BIZ_005',
|
||||
|
||||
// CFDI errors (6xxx)
|
||||
CFDI_STAMPING_FAILED: 'CFDI_001',
|
||||
CFDI_CANCELLATION_FAILED: 'CFDI_002',
|
||||
CFDI_INVALID_RFC: 'CFDI_003',
|
||||
CFDI_INVALID_POSTAL_CODE: 'CFDI_004',
|
||||
CFDI_ALREADY_STAMPED: 'CFDI_005',
|
||||
CFDI_ALREADY_CANCELLED: 'CFDI_006',
|
||||
CFDI_NOT_FOUND_SAT: 'CFDI_007',
|
||||
CFDI_XML_INVALID: 'CFDI_008',
|
||||
CFDI_CERTIFICATE_EXPIRED: 'CFDI_009',
|
||||
CFDI_PAC_ERROR: 'CFDI_010',
|
||||
|
||||
// Subscription/Billing errors (7xxx)
|
||||
BILLING_PAYMENT_FAILED: 'BILL_001',
|
||||
BILLING_CARD_DECLINED: 'BILL_002',
|
||||
BILLING_SUBSCRIPTION_EXPIRED: 'BILL_003',
|
||||
BILLING_PLAN_NOT_AVAILABLE: 'BILL_004',
|
||||
BILLING_PROMO_CODE_INVALID: 'BILL_005',
|
||||
BILLING_PROMO_CODE_EXPIRED: 'BILL_006',
|
||||
BILLING_DOWNGRADE_NOT_ALLOWED: 'BILL_007',
|
||||
|
||||
// Integration errors (8xxx)
|
||||
INTEGRATION_CONNECTION_FAILED: 'INT_001',
|
||||
INTEGRATION_AUTH_FAILED: 'INT_002',
|
||||
INTEGRATION_SYNC_FAILED: 'INT_003',
|
||||
INTEGRATION_NOT_CONFIGURED: 'INT_004',
|
||||
INTEGRATION_RATE_LIMITED: 'INT_005',
|
||||
|
||||
// System errors (9xxx)
|
||||
SYSTEM_INTERNAL_ERROR: 'SYS_001',
|
||||
SYSTEM_SERVICE_UNAVAILABLE: 'SYS_002',
|
||||
SYSTEM_TIMEOUT: 'SYS_003',
|
||||
SYSTEM_MAINTENANCE: 'SYS_004',
|
||||
SYSTEM_RATE_LIMITED: 'SYS_005',
|
||||
SYSTEM_STORAGE_FULL: 'SYS_006',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
/**
|
||||
* Error messages in Spanish
|
||||
*/
|
||||
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
|
||||
// Auth
|
||||
AUTH_001: 'Credenciales inválidas',
|
||||
AUTH_002: 'La sesión ha expirado',
|
||||
AUTH_003: 'Token de acceso inválido',
|
||||
AUTH_004: 'Token de actualización expirado',
|
||||
AUTH_005: 'Usuario no encontrado',
|
||||
AUTH_006: 'Usuario deshabilitado',
|
||||
AUTH_007: 'Correo electrónico no verificado',
|
||||
AUTH_008: 'Se requiere autenticación de dos factores',
|
||||
AUTH_009: 'Código de verificación inválido',
|
||||
AUTH_010: 'La sesión ha expirado',
|
||||
AUTH_011: 'Contraseña incorrecta',
|
||||
AUTH_012: 'La contraseña no cumple los requisitos de seguridad',
|
||||
AUTH_013: 'El correo electrónico ya está registrado',
|
||||
AUTH_014: 'La invitación ha expirado',
|
||||
AUTH_015: 'Invitación inválida',
|
||||
|
||||
// Authz
|
||||
AUTHZ_001: 'No tienes permiso para realizar esta acción',
|
||||
AUTHZ_002: 'Permisos insuficientes',
|
||||
AUTHZ_003: 'No tienes acceso a este recurso',
|
||||
AUTHZ_004: 'No tienes acceso a esta empresa',
|
||||
|
||||
// Validation
|
||||
VAL_001: 'Error de validación',
|
||||
VAL_002: 'Campo requerido',
|
||||
VAL_003: 'Formato inválido',
|
||||
VAL_004: 'Valor inválido',
|
||||
VAL_005: 'El valor es demasiado largo',
|
||||
VAL_006: 'El valor es demasiado corto',
|
||||
VAL_007: 'Valor fuera de rango',
|
||||
VAL_008: 'El valor ya existe',
|
||||
|
||||
// Resource
|
||||
RES_001: 'Recurso no encontrado',
|
||||
RES_002: 'El recurso ya existe',
|
||||
RES_003: 'Conflicto de recursos',
|
||||
RES_004: 'El recurso está bloqueado',
|
||||
RES_005: 'El recurso ha sido eliminado',
|
||||
|
||||
// Business
|
||||
BIZ_001: 'Operación no válida',
|
||||
BIZ_002: 'Saldo insuficiente',
|
||||
BIZ_003: 'Límite excedido',
|
||||
BIZ_004: 'Estado no válido para esta operación',
|
||||
BIZ_005: 'Error de dependencia',
|
||||
|
||||
// CFDI
|
||||
CFDI_001: 'Error al timbrar el CFDI',
|
||||
CFDI_002: 'Error al cancelar el CFDI',
|
||||
CFDI_003: 'RFC inválido',
|
||||
CFDI_004: 'Código postal inválido',
|
||||
CFDI_005: 'El CFDI ya está timbrado',
|
||||
CFDI_006: 'El CFDI ya está cancelado',
|
||||
CFDI_007: 'CFDI no encontrado en el SAT',
|
||||
CFDI_008: 'XML inválido',
|
||||
CFDI_009: 'Certificado expirado',
|
||||
CFDI_010: 'Error del proveedor de certificación',
|
||||
|
||||
// Billing
|
||||
BILL_001: 'Error en el pago',
|
||||
BILL_002: 'Tarjeta rechazada',
|
||||
BILL_003: 'Suscripción expirada',
|
||||
BILL_004: 'Plan no disponible',
|
||||
BILL_005: 'Código promocional inválido',
|
||||
BILL_006: 'Código promocional expirado',
|
||||
BILL_007: 'No es posible cambiar a un plan inferior',
|
||||
|
||||
// Integration
|
||||
INT_001: 'Error de conexión con el servicio externo',
|
||||
INT_002: 'Error de autenticación con el servicio externo',
|
||||
INT_003: 'Error de sincronización',
|
||||
INT_004: 'Integración no configurada',
|
||||
INT_005: 'Límite de solicitudes excedido',
|
||||
|
||||
// System
|
||||
SYS_001: 'Error interno del servidor',
|
||||
SYS_002: 'Servicio no disponible',
|
||||
SYS_003: 'Tiempo de espera agotado',
|
||||
SYS_004: 'Sistema en mantenimiento',
|
||||
SYS_005: 'Demasiadas solicitudes, intenta más tarde',
|
||||
SYS_006: 'Almacenamiento lleno',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Fiscal Regimes (Mexico SAT)
|
||||
// ============================================================================
|
||||
|
||||
export const FISCAL_REGIMES: Record<string, string> = {
|
||||
'601': 'General de Ley Personas Morales',
|
||||
'603': 'Personas Morales con Fines no Lucrativos',
|
||||
'605': 'Sueldos y Salarios e Ingresos Asimilados a Salarios',
|
||||
'606': 'Arrendamiento',
|
||||
'607': 'Régimen de Enajenación o Adquisición de Bienes',
|
||||
'608': 'Demás ingresos',
|
||||
'609': 'Consolidación',
|
||||
'610': 'Residentes en el Extranjero sin Establecimiento Permanente en México',
|
||||
'611': 'Ingresos por Dividendos (Socios y Accionistas)',
|
||||
'612': 'Personas Físicas con Actividades Empresariales y Profesionales',
|
||||
'614': 'Ingresos por intereses',
|
||||
'615': 'Régimen de los ingresos por obtención de premios',
|
||||
'616': 'Sin obligaciones fiscales',
|
||||
'620': 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos',
|
||||
'621': 'Incorporación Fiscal',
|
||||
'622': 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras',
|
||||
'623': 'Opcional para Grupos de Sociedades',
|
||||
'624': 'Coordinados',
|
||||
'625': 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas',
|
||||
'626': 'Régimen Simplificado de Confianza',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mexican States
|
||||
// ============================================================================
|
||||
|
||||
export const MEXICAN_STATES: Record<string, string> = {
|
||||
AGU: 'Aguascalientes',
|
||||
BCN: 'Baja California',
|
||||
BCS: 'Baja California Sur',
|
||||
CAM: 'Campeche',
|
||||
CHP: 'Chiapas',
|
||||
CHH: 'Chihuahua',
|
||||
COA: 'Coahuila',
|
||||
COL: 'Colima',
|
||||
CMX: 'Ciudad de México',
|
||||
DUR: 'Durango',
|
||||
GUA: 'Guanajuato',
|
||||
GRO: 'Guerrero',
|
||||
HID: 'Hidalgo',
|
||||
JAL: 'Jalisco',
|
||||
MEX: 'Estado de México',
|
||||
MIC: 'Michoacán',
|
||||
MOR: 'Morelos',
|
||||
NAY: 'Nayarit',
|
||||
NLE: 'Nuevo León',
|
||||
OAX: 'Oaxaca',
|
||||
PUE: 'Puebla',
|
||||
QUE: 'Querétaro',
|
||||
ROO: 'Quintana Roo',
|
||||
SLP: 'San Luis Potosí',
|
||||
SIN: 'Sinaloa',
|
||||
SON: 'Sonora',
|
||||
TAB: 'Tabasco',
|
||||
TAM: 'Tamaulipas',
|
||||
TLA: 'Tlaxcala',
|
||||
VER: 'Veracruz',
|
||||
YUC: 'Yucatán',
|
||||
ZAC: 'Zacatecas',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Currencies
|
||||
// ============================================================================
|
||||
|
||||
export const CURRENCIES: Record<string, { name: string; symbol: string; decimals: number }> = {
|
||||
MXN: { name: 'Peso Mexicano', symbol: '$', decimals: 2 },
|
||||
USD: { name: 'Dólar Estadounidense', symbol: 'US$', decimals: 2 },
|
||||
EUR: { name: 'Euro', symbol: '€', decimals: 2 },
|
||||
CAD: { name: 'Dólar Canadiense', symbol: 'CA$', decimals: 2 },
|
||||
GBP: { name: 'Libra Esterlina', symbol: '£', decimals: 2 },
|
||||
JPY: { name: 'Yen Japonés', symbol: '¥', decimals: 0 },
|
||||
};
|
||||
|
||||
export const DEFAULT_CURRENCY = 'MXN';
|
||||
|
||||
// ============================================================================
|
||||
// Date & Time
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_TIMEZONE = 'America/Mexico_City';
|
||||
export const DEFAULT_LOCALE = 'es-MX';
|
||||
export const DEFAULT_DATE_FORMAT = 'dd/MM/yyyy';
|
||||
export const DEFAULT_TIME_FORMAT = 'HH:mm';
|
||||
export const DEFAULT_DATETIME_FORMAT = 'dd/MM/yyyy HH:mm';
|
||||
|
||||
// ============================================================================
|
||||
// Limits
|
||||
// ============================================================================
|
||||
|
||||
export const LIMITS = {
|
||||
// Pagination
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
MAX_PAGE_SIZE: 100,
|
||||
|
||||
// File uploads
|
||||
MAX_FILE_SIZE_MB: 10,
|
||||
MAX_ATTACHMENT_SIZE_MB: 25,
|
||||
ALLOWED_FILE_TYPES: ['pdf', 'xml', 'jpg', 'jpeg', 'png', 'xlsx', 'csv'],
|
||||
|
||||
// Text fields
|
||||
MAX_DESCRIPTION_LENGTH: 500,
|
||||
MAX_NOTES_LENGTH: 2000,
|
||||
MAX_NAME_LENGTH: 200,
|
||||
|
||||
// Lists
|
||||
MAX_TAGS: 10,
|
||||
MAX_BATCH_SIZE: 100,
|
||||
|
||||
// Rate limiting
|
||||
MAX_API_REQUESTS_PER_MINUTE: 60,
|
||||
MAX_LOGIN_ATTEMPTS: 5,
|
||||
LOGIN_LOCKOUT_MINUTES: 15,
|
||||
|
||||
// Sessions
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES: 15,
|
||||
REFRESH_TOKEN_EXPIRY_DAYS: 7,
|
||||
SESSION_TIMEOUT_MINUTES: 60,
|
||||
|
||||
// Passwords
|
||||
MIN_PASSWORD_LENGTH: 8,
|
||||
MAX_PASSWORD_LENGTH: 128,
|
||||
PASSWORD_HISTORY_COUNT: 5,
|
||||
|
||||
// Export/Import
|
||||
MAX_EXPORT_ROWS: 50000,
|
||||
MAX_IMPORT_ROWS: 10000,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Regular Expressions
|
||||
// ============================================================================
|
||||
|
||||
export const REGEX = {
|
||||
RFC: /^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||
CURP: /^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
|
||||
CLABE: /^\d{18}$/,
|
||||
POSTAL_CODE_MX: /^\d{5}$/,
|
||||
PHONE_MX: /^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||
UUID: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
|
||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
SLUG: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
HEX_COLOR: /^#[0-9A-Fa-f]{6}$/,
|
||||
SAT_PRODUCT_CODE: /^\d{8}$/,
|
||||
SAT_UNIT_CODE: /^[A-Z0-9]{2,3}$/,
|
||||
};
|
||||
362
packages/shared/src/schemas/auth.schema.ts
Normal file
362
packages/shared/src/schemas/auth.schema.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Authentication Validation Schemas
|
||||
* Zod schemas for auth-related data validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Common Validators
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Email validation
|
||||
*/
|
||||
export const emailSchema = z
|
||||
.string()
|
||||
.min(1, 'El correo electrónico es requerido')
|
||||
.email('El correo electrónico no es válido')
|
||||
.max(255, 'El correo electrónico es demasiado largo')
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Password validation with Mexican-friendly messages
|
||||
*/
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'La contraseña debe tener al menos 8 caracteres')
|
||||
.max(128, 'La contraseña es demasiado larga')
|
||||
.regex(/[A-Z]/, 'La contraseña debe contener al menos una mayúscula')
|
||||
.regex(/[a-z]/, 'La contraseña debe contener al menos una minúscula')
|
||||
.regex(/[0-9]/, 'La contraseña debe contener al menos un número')
|
||||
.regex(
|
||||
/[^A-Za-z0-9]/,
|
||||
'La contraseña debe contener al menos un carácter especial'
|
||||
);
|
||||
|
||||
/**
|
||||
* Simple password (for login, without complexity requirements)
|
||||
*/
|
||||
export const simplePasswordSchema = z
|
||||
.string()
|
||||
.min(1, 'La contraseña es requerida')
|
||||
.max(128, 'La contraseña es demasiado larga');
|
||||
|
||||
/**
|
||||
* User role enum
|
||||
*/
|
||||
export const userRoleSchema = z.enum([
|
||||
'super_admin',
|
||||
'tenant_admin',
|
||||
'accountant',
|
||||
'assistant',
|
||||
'viewer',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Phone number (Mexican format)
|
||||
*/
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||
'El número de teléfono no es válido'
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal(''));
|
||||
|
||||
/**
|
||||
* Name validation
|
||||
*/
|
||||
export const nameSchema = z
|
||||
.string()
|
||||
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||
.max(100, 'El nombre es demasiado largo')
|
||||
.regex(
|
||||
/^[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ\s'-]+$/,
|
||||
'El nombre contiene caracteres no válidos'
|
||||
)
|
||||
.trim();
|
||||
|
||||
// ============================================================================
|
||||
// Login Schema
|
||||
// ============================================================================
|
||||
|
||||
export const loginRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: simplePasswordSchema,
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
tenantSlug: z
|
||||
.string()
|
||||
.min(2, 'El identificador de empresa es muy corto')
|
||||
.max(50, 'El identificador de empresa es muy largo')
|
||||
.regex(
|
||||
/^[a-z0-9-]+$/,
|
||||
'El identificador solo puede contener letras minúsculas, números y guiones'
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type LoginRequestInput = z.infer<typeof loginRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Register Schema
|
||||
// ============================================================================
|
||||
|
||||
export const registerRequestSchema = z
|
||||
.object({
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
firstName: nameSchema,
|
||||
lastName: nameSchema,
|
||||
phone: phoneSchema,
|
||||
tenantName: z
|
||||
.string()
|
||||
.min(2, 'El nombre de empresa debe tener al menos 2 caracteres')
|
||||
.max(100, 'El nombre de empresa es demasiado largo')
|
||||
.optional(),
|
||||
inviteCode: z
|
||||
.string()
|
||||
.length(32, 'El código de invitación no es válido')
|
||||
.optional(),
|
||||
acceptTerms: z.literal(true, {
|
||||
errorMap: () => ({
|
||||
message: 'Debes aceptar los términos y condiciones',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.refine(
|
||||
(data) => data.tenantName || data.inviteCode,
|
||||
{
|
||||
message: 'Debes proporcionar un nombre de empresa o código de invitación',
|
||||
path: ['tenantName'],
|
||||
}
|
||||
);
|
||||
|
||||
export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Password Reset Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const forgotPasswordRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
});
|
||||
|
||||
export type ForgotPasswordRequestInput = z.infer<typeof forgotPasswordRequestSchema>;
|
||||
|
||||
export const resetPasswordRequestSchema = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, 'El token es requerido')
|
||||
.length(64, 'El token no es válido'),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type ResetPasswordRequestInput = z.infer<typeof resetPasswordRequestSchema>;
|
||||
|
||||
export const changePasswordRequestSchema = z
|
||||
.object({
|
||||
currentPassword: simplePasswordSchema,
|
||||
newPassword: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: 'La nueva contraseña debe ser diferente a la actual',
|
||||
path: ['newPassword'],
|
||||
});
|
||||
|
||||
export type ChangePasswordRequestInput = z.infer<typeof changePasswordRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Email Verification
|
||||
// ============================================================================
|
||||
|
||||
export const verifyEmailRequestSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, 'El token es requerido')
|
||||
.length(64, 'El token no es válido'),
|
||||
});
|
||||
|
||||
export type VerifyEmailRequestInput = z.infer<typeof verifyEmailRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Refresh Token
|
||||
// ============================================================================
|
||||
|
||||
export const refreshTokenRequestSchema = z.object({
|
||||
refreshToken: z.string().min(1, 'El token de actualización es requerido'),
|
||||
});
|
||||
|
||||
export type RefreshTokenRequestInput = z.infer<typeof refreshTokenRequestSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// User Profile Update
|
||||
// ============================================================================
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
firstName: nameSchema.optional(),
|
||||
lastName: nameSchema.optional(),
|
||||
phone: phoneSchema,
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1, 'La zona horaria es requerida')
|
||||
.max(50, 'La zona horaria no es válida')
|
||||
.optional(),
|
||||
locale: z
|
||||
.string()
|
||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'El idioma no es válido')
|
||||
.optional(),
|
||||
avatar: z.string().url('La URL del avatar no es válida').optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// User Invitation
|
||||
// ============================================================================
|
||||
|
||||
export const inviteUserRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
role: userRoleSchema,
|
||||
message: z
|
||||
.string()
|
||||
.max(500, 'El mensaje es demasiado largo')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type InviteUserRequestInput = z.infer<typeof inviteUserRequestSchema>;
|
||||
|
||||
export const acceptInvitationSchema = z
|
||||
.object({
|
||||
token: z.string().min(1, 'El token es requerido'),
|
||||
firstName: nameSchema,
|
||||
lastName: nameSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Two-Factor Authentication
|
||||
// ============================================================================
|
||||
|
||||
export const twoFactorVerifySchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(6, 'El código debe tener 6 dígitos')
|
||||
.regex(/^\d+$/, 'El código solo debe contener números'),
|
||||
});
|
||||
|
||||
export type TwoFactorVerifyInput = z.infer<typeof twoFactorVerifySchema>;
|
||||
|
||||
export const twoFactorLoginSchema = z.object({
|
||||
tempToken: z.string().min(1, 'El token temporal es requerido'),
|
||||
code: z
|
||||
.string()
|
||||
.length(6, 'El código debe tener 6 dígitos')
|
||||
.regex(/^\d+$/, 'El código solo debe contener números'),
|
||||
});
|
||||
|
||||
export type TwoFactorLoginInput = z.infer<typeof twoFactorLoginSchema>;
|
||||
|
||||
export const twoFactorBackupCodeSchema = z.object({
|
||||
backupCode: z
|
||||
.string()
|
||||
.length(10, 'El código de respaldo debe tener 10 caracteres')
|
||||
.regex(/^[A-Z0-9]+$/, 'El código de respaldo no es válido'),
|
||||
});
|
||||
|
||||
export type TwoFactorBackupCodeInput = z.infer<typeof twoFactorBackupCodeSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Session Management
|
||||
// ============================================================================
|
||||
|
||||
export const revokeSessionSchema = z.object({
|
||||
sessionId: z.string().uuid('El ID de sesión no es válido'),
|
||||
});
|
||||
|
||||
export type RevokeSessionInput = z.infer<typeof revokeSessionSchema>;
|
||||
|
||||
export const revokeAllSessionsSchema = z.object({
|
||||
exceptCurrent: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type RevokeAllSessionsInput = z.infer<typeof revokeAllSessionsSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// User Management (Admin)
|
||||
// ============================================================================
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
email: emailSchema,
|
||||
firstName: nameSchema,
|
||||
lastName: nameSchema,
|
||||
role: userRoleSchema,
|
||||
phone: phoneSchema,
|
||||
sendInvitation: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
firstName: nameSchema.optional(),
|
||||
lastName: nameSchema.optional(),
|
||||
role: userRoleSchema.optional(),
|
||||
phone: phoneSchema,
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
|
||||
export const userFilterSchema = z.object({
|
||||
search: z.string().max(100).optional(),
|
||||
role: userRoleSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sortBy: z.enum(['createdAt', 'email', 'firstName', 'lastName', 'role']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
export type UserFilterInput = z.infer<typeof userFilterSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Permission Schema
|
||||
// ============================================================================
|
||||
|
||||
export const permissionSchema = z.object({
|
||||
resource: z.string().min(1).max(50),
|
||||
actions: z.array(z.enum(['create', 'read', 'update', 'delete'])).min(1),
|
||||
});
|
||||
|
||||
export const rolePermissionsSchema = z.object({
|
||||
role: userRoleSchema,
|
||||
permissions: z.array(permissionSchema),
|
||||
});
|
||||
|
||||
export type PermissionInput = z.infer<typeof permissionSchema>;
|
||||
export type RolePermissionsInput = z.infer<typeof rolePermissionsSchema>;
|
||||
730
packages/shared/src/schemas/financial.schema.ts
Normal file
730
packages/shared/src/schemas/financial.schema.ts
Normal file
@@ -0,0 +1,730 @@
|
||||
/**
|
||||
* Financial Validation Schemas
|
||||
* Zod schemas for transactions, CFDI, contacts, accounts, and categories
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Common Validators
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* RFC validation (Mexican tax ID)
|
||||
*/
|
||||
export const rfcSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||
'El RFC no tiene un formato válido'
|
||||
)
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* CURP validation
|
||||
*/
|
||||
export const curpSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
|
||||
'El CURP no tiene un formato válido'
|
||||
)
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* CLABE validation (18 digits)
|
||||
*/
|
||||
export const clabeSchema = z
|
||||
.string()
|
||||
.regex(/^\d{18}$/, 'La CLABE debe tener 18 dígitos');
|
||||
|
||||
/**
|
||||
* Money amount validation
|
||||
*/
|
||||
export const moneySchema = z
|
||||
.number()
|
||||
.multipleOf(0.01, 'El monto debe tener máximo 2 decimales');
|
||||
|
||||
/**
|
||||
* Positive money amount
|
||||
*/
|
||||
export const positiveMoneySchema = moneySchema
|
||||
.positive('El monto debe ser mayor a cero');
|
||||
|
||||
/**
|
||||
* Non-negative money amount
|
||||
*/
|
||||
export const nonNegativeMoneySchema = moneySchema
|
||||
.nonnegative('El monto no puede ser negativo');
|
||||
|
||||
/**
|
||||
* Currency code
|
||||
*/
|
||||
export const currencySchema = z
|
||||
.string()
|
||||
.length(3, 'El código de moneda debe tener 3 letras')
|
||||
.toUpperCase()
|
||||
.default('MXN');
|
||||
|
||||
/**
|
||||
* UUID validation
|
||||
*/
|
||||
export const uuidSchema = z.string().uuid('El ID no es válido');
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export const transactionTypeSchema = z.enum([
|
||||
'income',
|
||||
'expense',
|
||||
'transfer',
|
||||
'adjustment',
|
||||
]);
|
||||
|
||||
export const transactionStatusSchema = z.enum([
|
||||
'pending',
|
||||
'cleared',
|
||||
'reconciled',
|
||||
'voided',
|
||||
]);
|
||||
|
||||
export const paymentMethodSchema = z.enum([
|
||||
'cash',
|
||||
'bank_transfer',
|
||||
'credit_card',
|
||||
'debit_card',
|
||||
'check',
|
||||
'digital_wallet',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const cfdiTypeSchema = z.enum(['I', 'E', 'T', 'N', 'P']);
|
||||
|
||||
export const cfdiStatusSchema = z.enum([
|
||||
'draft',
|
||||
'pending',
|
||||
'stamped',
|
||||
'sent',
|
||||
'paid',
|
||||
'partial_paid',
|
||||
'cancelled',
|
||||
'cancellation_pending',
|
||||
]);
|
||||
|
||||
export const cfdiUsageSchema = z.enum([
|
||||
'G01', 'G02', 'G03',
|
||||
'I01', 'I02', 'I03', 'I04', 'I05', 'I06', 'I07', 'I08',
|
||||
'D01', 'D02', 'D03', 'D04', 'D05', 'D06', 'D07', 'D08', 'D09', 'D10',
|
||||
'S01', 'CP01', 'CN01',
|
||||
]);
|
||||
|
||||
export const paymentFormSchema = z.enum([
|
||||
'01', '02', '03', '04', '05', '06', '08', '12', '13', '14', '15',
|
||||
'17', '23', '24', '25', '26', '27', '28', '29', '30', '31', '99',
|
||||
]);
|
||||
|
||||
export const paymentMethodCFDISchema = z.enum(['PUE', 'PPD']);
|
||||
|
||||
export const contactTypeSchema = z.enum([
|
||||
'customer',
|
||||
'supplier',
|
||||
'both',
|
||||
'employee',
|
||||
]);
|
||||
|
||||
export const categoryTypeSchema = z.enum(['income', 'expense']);
|
||||
|
||||
export const accountTypeSchema = z.enum([
|
||||
'bank',
|
||||
'cash',
|
||||
'credit_card',
|
||||
'loan',
|
||||
'investment',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const accountSubtypeSchema = z.enum([
|
||||
'checking',
|
||||
'savings',
|
||||
'money_market',
|
||||
'cd',
|
||||
'credit',
|
||||
'line_of_credit',
|
||||
'mortgage',
|
||||
'auto_loan',
|
||||
'personal_loan',
|
||||
'brokerage',
|
||||
'retirement',
|
||||
'other',
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const createTransactionSchema = z.object({
|
||||
type: transactionTypeSchema,
|
||||
amount: positiveMoneySchema,
|
||||
currency: currencySchema,
|
||||
exchangeRate: z.number().positive().optional(),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'La descripción es requerida')
|
||||
.max(500, 'La descripción es demasiado larga')
|
||||
.trim(),
|
||||
reference: z.string().max(100).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
date: z.coerce.date(),
|
||||
valueDate: z.coerce.date().optional(),
|
||||
accountId: uuidSchema,
|
||||
destinationAccountId: uuidSchema.optional(),
|
||||
categoryId: uuidSchema.optional(),
|
||||
contactId: uuidSchema.optional(),
|
||||
cfdiId: uuidSchema.optional(),
|
||||
paymentMethod: paymentMethodSchema.optional(),
|
||||
paymentReference: z.string().max(100).optional(),
|
||||
tags: z.array(z.string().max(50)).max(10).default([]),
|
||||
isRecurring: z.boolean().default(false),
|
||||
recurringRuleId: uuidSchema.optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.type === 'transfer') {
|
||||
return !!data.destinationAccountId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'La cuenta destino es requerida para transferencias',
|
||||
path: ['destinationAccountId'],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
if (data.type === 'transfer' && data.destinationAccountId) {
|
||||
return data.accountId !== data.destinationAccountId;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'La cuenta origen y destino no pueden ser la misma',
|
||||
path: ['destinationAccountId'],
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
|
||||
|
||||
export const updateTransactionSchema = z.object({
|
||||
amount: positiveMoneySchema.optional(),
|
||||
description: z.string().min(1).max(500).trim().optional(),
|
||||
reference: z.string().max(100).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
date: z.coerce.date().optional(),
|
||||
valueDate: z.coerce.date().optional().nullable(),
|
||||
categoryId: uuidSchema.optional().nullable(),
|
||||
contactId: uuidSchema.optional().nullable(),
|
||||
paymentMethod: paymentMethodSchema.optional().nullable(),
|
||||
paymentReference: z.string().max(100).optional().nullable(),
|
||||
tags: z.array(z.string().max(50)).max(10).optional(),
|
||||
});
|
||||
|
||||
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
|
||||
|
||||
export const transactionFilterSchema = z.object({
|
||||
type: z.array(transactionTypeSchema).optional(),
|
||||
status: z.array(transactionStatusSchema).optional(),
|
||||
accountId: uuidSchema.optional(),
|
||||
categoryId: uuidSchema.optional(),
|
||||
contactId: uuidSchema.optional(),
|
||||
dateFrom: z.coerce.date().optional(),
|
||||
dateTo: z.coerce.date().optional(),
|
||||
amountMin: nonNegativeMoneySchema.optional(),
|
||||
amountMax: nonNegativeMoneySchema.optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sortBy: z.enum(['date', 'amount', 'description', 'createdAt']).default('date'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
export type TransactionFilterInput = z.infer<typeof transactionFilterSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// CFDI Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const cfdiTaxSchema = z.object({
|
||||
type: z.enum(['transferred', 'withheld']),
|
||||
tax: z.enum(['IVA', 'ISR', 'IEPS']),
|
||||
factor: z.enum(['Tasa', 'Cuota', 'Exento']),
|
||||
rate: z.number().min(0).max(1),
|
||||
base: positiveMoneySchema,
|
||||
amount: nonNegativeMoneySchema,
|
||||
});
|
||||
|
||||
export const cfdiItemSchema = z.object({
|
||||
productCode: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, 'La clave del producto debe tener 8 dígitos'),
|
||||
unitCode: z
|
||||
.string()
|
||||
.regex(/^[A-Z0-9]{2,3}$/, 'La clave de unidad no es válida'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'La descripción es requerida')
|
||||
.max(1000, 'La descripción es demasiado larga'),
|
||||
quantity: z.number().positive('La cantidad debe ser mayor a cero'),
|
||||
unitPrice: positiveMoneySchema,
|
||||
discount: nonNegativeMoneySchema.optional().default(0),
|
||||
identificationNumber: z.string().max(100).optional(),
|
||||
unit: z.string().max(50).optional(),
|
||||
taxes: z.array(cfdiTaxSchema).default([]),
|
||||
});
|
||||
|
||||
export const cfdiRelationSchema = z.object({
|
||||
type: z.enum(['01', '02', '03', '04', '05', '06', '07', '08', '09']),
|
||||
uuid: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
|
||||
'El UUID no tiene un formato válido'
|
||||
),
|
||||
});
|
||||
|
||||
export const createCFDISchema = z.object({
|
||||
type: cfdiTypeSchema,
|
||||
series: z
|
||||
.string()
|
||||
.max(25, 'La serie es demasiado larga')
|
||||
.regex(/^[A-Z0-9]*$/, 'La serie solo puede contener letras y números')
|
||||
.optional(),
|
||||
|
||||
// Receiver
|
||||
receiverRfc: rfcSchema,
|
||||
receiverName: z
|
||||
.string()
|
||||
.min(1, 'El nombre del receptor es requerido')
|
||||
.max(300, 'El nombre del receptor es demasiado largo'),
|
||||
receiverFiscalRegime: z.string().min(3).max(3).optional(),
|
||||
receiverPostalCode: z.string().regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
|
||||
receiverUsage: cfdiUsageSchema,
|
||||
receiverEmail: z.string().email('El correo electrónico no es válido').optional(),
|
||||
|
||||
// Items
|
||||
items: z
|
||||
.array(cfdiItemSchema)
|
||||
.min(1, 'Debe haber al menos un concepto'),
|
||||
|
||||
// Payment
|
||||
paymentForm: paymentFormSchema,
|
||||
paymentMethod: paymentMethodCFDISchema,
|
||||
paymentConditions: z.string().max(1000).optional(),
|
||||
|
||||
// Currency
|
||||
currency: currencySchema,
|
||||
exchangeRate: z.number().positive().optional(),
|
||||
|
||||
// Related CFDIs
|
||||
relatedCfdis: z.array(cfdiRelationSchema).optional(),
|
||||
|
||||
// Contact
|
||||
contactId: uuidSchema.optional(),
|
||||
|
||||
// Dates
|
||||
issueDate: z.coerce.date().optional(),
|
||||
expirationDate: z.coerce.date().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.currency !== 'MXN') {
|
||||
return !!data.exchangeRate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'El tipo de cambio es requerido para monedas diferentes a MXN',
|
||||
path: ['exchangeRate'],
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateCFDIInput = z.infer<typeof createCFDISchema>;
|
||||
|
||||
export const updateCFDIDraftSchema = z.object({
|
||||
receiverRfc: rfcSchema.optional(),
|
||||
receiverName: z.string().min(1).max(300).optional(),
|
||||
receiverFiscalRegime: z.string().min(3).max(3).optional().nullable(),
|
||||
receiverPostalCode: z.string().regex(/^\d{5}$/).optional(),
|
||||
receiverUsage: cfdiUsageSchema.optional(),
|
||||
receiverEmail: z.string().email().optional().nullable(),
|
||||
items: z.array(cfdiItemSchema).min(1).optional(),
|
||||
paymentForm: paymentFormSchema.optional(),
|
||||
paymentMethod: paymentMethodCFDISchema.optional(),
|
||||
paymentConditions: z.string().max(1000).optional().nullable(),
|
||||
expirationDate: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
export type UpdateCFDIDraftInput = z.infer<typeof updateCFDIDraftSchema>;
|
||||
|
||||
export const cfdiFilterSchema = z.object({
|
||||
type: z.array(cfdiTypeSchema).optional(),
|
||||
status: z.array(cfdiStatusSchema).optional(),
|
||||
contactId: uuidSchema.optional(),
|
||||
receiverRfc: z.string().optional(),
|
||||
dateFrom: z.coerce.date().optional(),
|
||||
dateTo: z.coerce.date().optional(),
|
||||
amountMin: nonNegativeMoneySchema.optional(),
|
||||
amountMax: nonNegativeMoneySchema.optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sortBy: z.enum(['issueDate', 'total', 'folio', 'createdAt']).default('issueDate'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
export type CFDIFilterInput = z.infer<typeof cfdiFilterSchema>;
|
||||
|
||||
export const cancelCFDISchema = z.object({
|
||||
reason: z.enum(['01', '02', '03', '04'], {
|
||||
errorMap: () => ({ message: 'El motivo de cancelación no es válido' }),
|
||||
}),
|
||||
substitutedByUuid: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
||||
)
|
||||
.optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// If reason is 01 (substitution), substitutedByUuid is required
|
||||
if (data.reason === '01') {
|
||||
return !!data.substitutedByUuid;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'El UUID del CFDI sustituto es requerido para el motivo 01',
|
||||
path: ['substitutedByUuid'],
|
||||
}
|
||||
);
|
||||
|
||||
export type CancelCFDIInput = z.infer<typeof cancelCFDISchema>;
|
||||
|
||||
export const registerPaymentSchema = z.object({
|
||||
cfdiId: uuidSchema,
|
||||
amount: positiveMoneySchema,
|
||||
paymentDate: z.coerce.date(),
|
||||
paymentForm: paymentFormSchema,
|
||||
transactionId: uuidSchema.optional(),
|
||||
});
|
||||
|
||||
export type RegisterPaymentInput = z.infer<typeof registerPaymentSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Contact Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const contactAddressSchema = z.object({
|
||||
street: z.string().min(1).max(200),
|
||||
exteriorNumber: z.string().min(1).max(20),
|
||||
interiorNumber: z.string().max(20).optional().or(z.literal('')),
|
||||
neighborhood: z.string().min(1).max(100),
|
||||
city: z.string().min(1).max(100),
|
||||
state: z.string().min(1).max(100),
|
||||
country: z.string().min(1).max(100).default('México'),
|
||||
postalCode: z.string().regex(/^\d{5}$/),
|
||||
});
|
||||
|
||||
export const contactBankAccountSchema = z.object({
|
||||
bankName: z.string().min(1).max(100),
|
||||
accountNumber: z.string().min(1).max(20),
|
||||
clabe: clabeSchema.optional(),
|
||||
accountHolder: z.string().min(1).max(200),
|
||||
currency: currencySchema,
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const createContactSchema = z.object({
|
||||
type: contactTypeSchema,
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(200, 'El nombre es demasiado largo')
|
||||
.trim(),
|
||||
displayName: z.string().max(200).optional(),
|
||||
rfc: rfcSchema.optional(),
|
||||
curp: curpSchema.optional(),
|
||||
email: z.string().email('El correo electrónico no es válido').optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/
|
||||
)
|
||||
.optional(),
|
||||
mobile: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
fiscalRegime: z.string().min(3).max(3).optional(),
|
||||
fiscalName: z.string().max(300).optional(),
|
||||
cfdiUsage: cfdiUsageSchema.optional(),
|
||||
address: contactAddressSchema.optional(),
|
||||
creditLimit: nonNegativeMoneySchema.optional(),
|
||||
creditDays: z.number().int().min(0).max(365).optional(),
|
||||
bankAccounts: z.array(contactBankAccountSchema).optional(),
|
||||
tags: z.array(z.string().max(50)).max(10).default([]),
|
||||
groupId: uuidSchema.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export type CreateContactInput = z.infer<typeof createContactSchema>;
|
||||
|
||||
export const updateContactSchema = z.object({
|
||||
type: contactTypeSchema.optional(),
|
||||
name: z.string().min(1).max(200).trim().optional(),
|
||||
displayName: z.string().max(200).optional().nullable(),
|
||||
rfc: rfcSchema.optional().nullable(),
|
||||
curp: curpSchema.optional().nullable(),
|
||||
email: z.string().email().optional().nullable(),
|
||||
phone: z.string().optional().nullable(),
|
||||
mobile: z.string().optional().nullable(),
|
||||
website: z.string().url().optional().nullable(),
|
||||
fiscalRegime: z.string().min(3).max(3).optional().nullable(),
|
||||
fiscalName: z.string().max(300).optional().nullable(),
|
||||
cfdiUsage: cfdiUsageSchema.optional().nullable(),
|
||||
address: contactAddressSchema.optional().nullable(),
|
||||
creditLimit: nonNegativeMoneySchema.optional().nullable(),
|
||||
creditDays: z.number().int().min(0).max(365).optional().nullable(),
|
||||
bankAccounts: z.array(contactBankAccountSchema).optional(),
|
||||
tags: z.array(z.string().max(50)).max(10).optional(),
|
||||
groupId: uuidSchema.optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateContactInput = z.infer<typeof updateContactSchema>;
|
||||
|
||||
export const contactFilterSchema = z.object({
|
||||
type: z.array(contactTypeSchema).optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
hasBalance: z.boolean().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sortBy: z.enum(['name', 'balance', 'createdAt']).default('name'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
|
||||
export type ContactFilterInput = z.infer<typeof contactFilterSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Category Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const createCategorySchema = z.object({
|
||||
type: categoryTypeSchema,
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(100, 'El nombre es demasiado largo')
|
||||
.trim(),
|
||||
description: z.string().max(500).optional(),
|
||||
code: z.string().max(20).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional(),
|
||||
icon: z.string().max(50).optional(),
|
||||
parentId: uuidSchema.optional(),
|
||||
satCode: z.string().max(10).optional(),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export type CreateCategoryInput = z.infer<typeof createCategorySchema>;
|
||||
|
||||
export const updateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
code: z.string().max(20).optional().nullable(),
|
||||
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
|
||||
icon: z.string().max(50).optional().nullable(),
|
||||
parentId: uuidSchema.optional().nullable(),
|
||||
satCode: z.string().max(10).optional().nullable(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCategoryInput = z.infer<typeof updateCategorySchema>;
|
||||
|
||||
export const categoryFilterSchema = z.object({
|
||||
type: categoryTypeSchema.optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
parentId: uuidSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
includeSystem: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CategoryFilterInput = z.infer<typeof categoryFilterSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Account Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const createAccountSchema = z.object({
|
||||
type: accountTypeSchema,
|
||||
subtype: accountSubtypeSchema.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'El nombre es requerido')
|
||||
.max(100, 'El nombre es demasiado largo')
|
||||
.trim(),
|
||||
description: z.string().max(500).optional(),
|
||||
accountNumber: z.string().max(30).optional(),
|
||||
currency: currencySchema,
|
||||
bankName: z.string().max(100).optional(),
|
||||
bankBranch: z.string().max(100).optional(),
|
||||
clabe: clabeSchema.optional(),
|
||||
swiftCode: z.string().max(11).optional(),
|
||||
currentBalance: moneySchema.default(0),
|
||||
creditLimit: nonNegativeMoneySchema.optional(),
|
||||
isDefault: z.boolean().default(false),
|
||||
isReconcilable: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type CreateAccountInput = z.infer<typeof createAccountSchema>;
|
||||
|
||||
export const updateAccountSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
accountNumber: z.string().max(30).optional().nullable(),
|
||||
bankName: z.string().max(100).optional().nullable(),
|
||||
bankBranch: z.string().max(100).optional().nullable(),
|
||||
clabe: clabeSchema.optional().nullable(),
|
||||
swiftCode: z.string().max(11).optional().nullable(),
|
||||
creditLimit: nonNegativeMoneySchema.optional().nullable(),
|
||||
isDefault: z.boolean().optional(),
|
||||
isReconcilable: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateAccountInput = z.infer<typeof updateAccountSchema>;
|
||||
|
||||
export const accountFilterSchema = z.object({
|
||||
type: z.array(accountTypeSchema).optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
currency: currencySchema.optional(),
|
||||
});
|
||||
|
||||
export type AccountFilterInput = z.infer<typeof accountFilterSchema>;
|
||||
|
||||
export const adjustBalanceSchema = z.object({
|
||||
newBalance: moneySchema,
|
||||
reason: z
|
||||
.string()
|
||||
.min(1, 'El motivo es requerido')
|
||||
.max(500, 'El motivo es demasiado largo'),
|
||||
date: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Recurring Rule Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const recurringFrequencySchema = z.enum([
|
||||
'daily',
|
||||
'weekly',
|
||||
'biweekly',
|
||||
'monthly',
|
||||
'quarterly',
|
||||
'yearly',
|
||||
]);
|
||||
|
||||
export const createRecurringRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim(),
|
||||
type: transactionTypeSchema,
|
||||
frequency: recurringFrequencySchema,
|
||||
interval: z.number().int().min(1).max(12).default(1),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
amount: positiveMoneySchema,
|
||||
description: z.string().min(1).max(500).trim(),
|
||||
accountId: uuidSchema,
|
||||
categoryId: uuidSchema.optional(),
|
||||
contactId: uuidSchema.optional(),
|
||||
maxOccurrences: z.number().int().positive().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.endDate) {
|
||||
return data.endDate > data.startDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
|
||||
path: ['endDate'],
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateRecurringRuleInput = z.infer<typeof createRecurringRuleSchema>;
|
||||
|
||||
export const updateRecurringRuleSchema = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
frequency: recurringFrequencySchema.optional(),
|
||||
interval: z.number().int().min(1).max(12).optional(),
|
||||
endDate: z.coerce.date().optional().nullable(),
|
||||
amount: positiveMoneySchema.optional(),
|
||||
description: z.string().min(1).max(500).trim().optional(),
|
||||
categoryId: uuidSchema.optional().nullable(),
|
||||
contactId: uuidSchema.optional().nullable(),
|
||||
maxOccurrences: z.number().int().positive().optional().nullable(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateRecurringRuleInput = z.infer<typeof updateRecurringRuleSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Bank Statement Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const uploadBankStatementSchema = z.object({
|
||||
accountId: uuidSchema,
|
||||
file: z.string().min(1, 'El archivo es requerido'),
|
||||
format: z.enum(['ofx', 'csv', 'xlsx']).optional(),
|
||||
});
|
||||
|
||||
export type UploadBankStatementInput = z.infer<typeof uploadBankStatementSchema>;
|
||||
|
||||
export const matchTransactionSchema = z.object({
|
||||
statementLineId: uuidSchema,
|
||||
transactionId: uuidSchema,
|
||||
});
|
||||
|
||||
export type MatchTransactionInput = z.infer<typeof matchTransactionSchema>;
|
||||
|
||||
export const createFromStatementLineSchema = z.object({
|
||||
statementLineId: uuidSchema,
|
||||
categoryId: uuidSchema.optional(),
|
||||
contactId: uuidSchema.optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export type CreateFromStatementLineInput = z.infer<typeof createFromStatementLineSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Bulk Operations
|
||||
// ============================================================================
|
||||
|
||||
export const bulkCategorizeSchema = z.object({
|
||||
transactionIds: z.array(uuidSchema).min(1, 'Selecciona al menos una transacción'),
|
||||
categoryId: uuidSchema,
|
||||
});
|
||||
|
||||
export type BulkCategorizeInput = z.infer<typeof bulkCategorizeSchema>;
|
||||
|
||||
export const bulkDeleteSchema = z.object({
|
||||
ids: z.array(uuidSchema).min(1, 'Selecciona al menos un elemento'),
|
||||
});
|
||||
|
||||
export type BulkDeleteInput = z.infer<typeof bulkDeleteSchema>;
|
||||
12
packages/shared/src/schemas/index.ts
Normal file
12
packages/shared/src/schemas/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Schemas Index - Re-export all validation schemas
|
||||
*/
|
||||
|
||||
// Authentication schemas
|
||||
export * from './auth.schema';
|
||||
|
||||
// Tenant & subscription schemas
|
||||
export * from './tenant.schema';
|
||||
|
||||
// Financial schemas
|
||||
export * from './financial.schema';
|
||||
509
packages/shared/src/schemas/tenant.schema.ts
Normal file
509
packages/shared/src/schemas/tenant.schema.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Tenant Validation Schemas
|
||||
* Zod schemas for tenant, subscription, and billing validation
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Common Validators
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* RFC validation (Mexican tax ID)
|
||||
*/
|
||||
export const rfcSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
|
||||
'El RFC no tiene un formato válido'
|
||||
)
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Slug validation for URLs
|
||||
*/
|
||||
export const slugSchema = z
|
||||
.string()
|
||||
.min(2, 'El identificador debe tener al menos 2 caracteres')
|
||||
.max(50, 'El identificador es demasiado largo')
|
||||
.regex(
|
||||
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
'El identificador solo puede contener letras minúsculas, números y guiones'
|
||||
)
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Tenant status enum
|
||||
*/
|
||||
export const tenantStatusSchema = z.enum([
|
||||
'pending',
|
||||
'active',
|
||||
'suspended',
|
||||
'cancelled',
|
||||
'trial',
|
||||
'expired',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Subscription status enum
|
||||
*/
|
||||
export const subscriptionStatusSchema = z.enum([
|
||||
'trialing',
|
||||
'active',
|
||||
'past_due',
|
||||
'canceled',
|
||||
'unpaid',
|
||||
'paused',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Billing cycle enum
|
||||
*/
|
||||
export const billingCycleSchema = z.enum(['monthly', 'annual']);
|
||||
|
||||
/**
|
||||
* Plan tier enum
|
||||
*/
|
||||
export const planTierSchema = z.enum(['free', 'starter', 'professional', 'enterprise']);
|
||||
|
||||
// ============================================================================
|
||||
// Address Schema
|
||||
// ============================================================================
|
||||
|
||||
export const addressSchema = z.object({
|
||||
street: z
|
||||
.string()
|
||||
.min(1, 'La calle es requerida')
|
||||
.max(200, 'La calle es demasiado larga'),
|
||||
exteriorNumber: z
|
||||
.string()
|
||||
.min(1, 'El número exterior es requerido')
|
||||
.max(20, 'El número exterior es demasiado largo'),
|
||||
interiorNumber: z
|
||||
.string()
|
||||
.max(20, 'El número interior es demasiado largo')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
neighborhood: z
|
||||
.string()
|
||||
.min(1, 'La colonia es requerida')
|
||||
.max(100, 'La colonia es demasiado larga'),
|
||||
city: z
|
||||
.string()
|
||||
.min(1, 'La ciudad es requerida')
|
||||
.max(100, 'La ciudad es demasiado larga'),
|
||||
state: z
|
||||
.string()
|
||||
.min(1, 'El estado es requerido')
|
||||
.max(100, 'El estado es demasiado largo'),
|
||||
country: z
|
||||
.string()
|
||||
.min(1, 'El país es requerido')
|
||||
.max(100, 'El país es demasiado largo')
|
||||
.default('México'),
|
||||
postalCode: z
|
||||
.string()
|
||||
.regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
|
||||
});
|
||||
|
||||
export type AddressInput = z.infer<typeof addressSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Creation Schema
|
||||
// ============================================================================
|
||||
|
||||
export const createTenantSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||
.max(100, 'El nombre es demasiado largo')
|
||||
.trim(),
|
||||
slug: slugSchema,
|
||||
legalName: z
|
||||
.string()
|
||||
.min(2, 'La razón social debe tener al menos 2 caracteres')
|
||||
.max(200, 'La razón social es demasiado larga')
|
||||
.optional(),
|
||||
rfc: rfcSchema.optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email('El correo electrónico no es válido')
|
||||
.max(255),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||
'El número de teléfono no es válido'
|
||||
)
|
||||
.optional(),
|
||||
website: z.string().url('La URL del sitio web no es válida').optional(),
|
||||
planId: z.string().uuid('El ID del plan no es válido'),
|
||||
});
|
||||
|
||||
export type CreateTenantInput = z.infer<typeof createTenantSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Update Schema
|
||||
// ============================================================================
|
||||
|
||||
export const updateTenantSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'El nombre debe tener al menos 2 caracteres')
|
||||
.max(100, 'El nombre es demasiado largo')
|
||||
.trim()
|
||||
.optional(),
|
||||
legalName: z
|
||||
.string()
|
||||
.min(2, 'La razón social debe tener al menos 2 caracteres')
|
||||
.max(200, 'La razón social es demasiado larga')
|
||||
.optional()
|
||||
.nullable(),
|
||||
rfc: rfcSchema.optional().nullable(),
|
||||
fiscalRegime: z
|
||||
.string()
|
||||
.min(3, 'El régimen fiscal no es válido')
|
||||
.max(10, 'El régimen fiscal no es válido')
|
||||
.optional()
|
||||
.nullable(),
|
||||
fiscalAddress: addressSchema.optional().nullable(),
|
||||
email: z
|
||||
.string()
|
||||
.email('El correo electrónico no es válido')
|
||||
.max(255)
|
||||
.optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
|
||||
'El número de teléfono no es válido'
|
||||
)
|
||||
.optional()
|
||||
.nullable(),
|
||||
website: z.string().url('La URL del sitio web no es válida').optional().nullable(),
|
||||
logo: z.string().url('La URL del logo no es válida').optional().nullable(),
|
||||
primaryColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color primario no es válido')
|
||||
.optional()
|
||||
.nullable(),
|
||||
secondaryColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color secundario no es válido')
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type UpdateTenantInput = z.infer<typeof updateTenantSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Settings Schema
|
||||
// ============================================================================
|
||||
|
||||
export const tenantSettingsSchema = z.object({
|
||||
// General
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1, 'La zona horaria es requerida')
|
||||
.max(50)
|
||||
.default('America/Mexico_City'),
|
||||
locale: z
|
||||
.string()
|
||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
||||
.default('es-MX'),
|
||||
currency: z
|
||||
.string()
|
||||
.length(3, 'La moneda debe ser un código de 3 letras')
|
||||
.toUpperCase()
|
||||
.default('MXN'),
|
||||
fiscalYearStart: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(12)
|
||||
.default(1),
|
||||
|
||||
// Invoicing
|
||||
defaultPaymentTerms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(365)
|
||||
.default(30),
|
||||
invoicePrefix: z
|
||||
.string()
|
||||
.max(10, 'El prefijo es demasiado largo')
|
||||
.regex(/^[A-Z0-9]*$/, 'El prefijo solo puede contener letras mayúsculas y números')
|
||||
.default(''),
|
||||
invoiceNextNumber: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1),
|
||||
|
||||
// Notifications
|
||||
emailNotifications: z.boolean().default(true),
|
||||
invoiceReminders: z.boolean().default(true),
|
||||
paymentReminders: z.boolean().default(true),
|
||||
|
||||
// Security
|
||||
sessionTimeout: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5)
|
||||
.max(1440)
|
||||
.default(60),
|
||||
requireTwoFactor: z.boolean().default(false),
|
||||
allowedIPs: z
|
||||
.array(
|
||||
z.string().ip({ message: 'La dirección IP no es válida' })
|
||||
)
|
||||
.optional(),
|
||||
|
||||
// Integrations
|
||||
satIntegration: z.boolean().default(false),
|
||||
bankingIntegration: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type TenantSettingsInput = z.infer<typeof tenantSettingsSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Plan Schema
|
||||
// ============================================================================
|
||||
|
||||
export const planFeaturesSchema = z.object({
|
||||
// Modules
|
||||
invoicing: z.boolean(),
|
||||
expenses: z.boolean(),
|
||||
bankReconciliation: z.boolean(),
|
||||
reports: z.boolean(),
|
||||
budgets: z.boolean(),
|
||||
forecasting: z.boolean(),
|
||||
multiCurrency: z.boolean(),
|
||||
|
||||
// CFDI
|
||||
cfdiGeneration: z.boolean(),
|
||||
cfdiCancellation: z.boolean(),
|
||||
cfdiAddenda: z.boolean(),
|
||||
massInvoicing: z.boolean(),
|
||||
|
||||
// Integrations
|
||||
satIntegration: z.boolean(),
|
||||
bankIntegration: z.boolean(),
|
||||
erpIntegration: z.boolean(),
|
||||
apiAccess: z.boolean(),
|
||||
webhooks: z.boolean(),
|
||||
|
||||
// Collaboration
|
||||
multiUser: z.boolean(),
|
||||
customRoles: z.boolean(),
|
||||
auditLog: z.boolean(),
|
||||
comments: z.boolean(),
|
||||
|
||||
// Support
|
||||
emailSupport: z.boolean(),
|
||||
chatSupport: z.boolean(),
|
||||
phoneSupport: z.boolean(),
|
||||
prioritySupport: z.boolean(),
|
||||
dedicatedManager: z.boolean(),
|
||||
|
||||
// Extras
|
||||
customBranding: z.boolean(),
|
||||
whiteLabel: z.boolean(),
|
||||
dataExport: z.boolean(),
|
||||
advancedReports: z.boolean(),
|
||||
});
|
||||
|
||||
export const planLimitsSchema = z.object({
|
||||
maxUsers: z.number().int().positive(),
|
||||
maxTransactionsPerMonth: z.number().int().positive(),
|
||||
maxInvoicesPerMonth: z.number().int().positive(),
|
||||
maxContacts: z.number().int().positive(),
|
||||
maxBankAccounts: z.number().int().positive(),
|
||||
storageMB: z.number().int().positive(),
|
||||
apiRequestsPerDay: z.number().int().positive(),
|
||||
retentionDays: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const planPricingSchema = z.object({
|
||||
monthlyPrice: z.number().nonnegative(),
|
||||
annualPrice: z.number().nonnegative(),
|
||||
currency: z.string().length(3).default('MXN'),
|
||||
trialDays: z.number().int().nonnegative().default(14),
|
||||
setupFee: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const createPlanSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
tier: planTierSchema,
|
||||
description: z.string().max(500),
|
||||
features: planFeaturesSchema,
|
||||
limits: planLimitsSchema,
|
||||
pricing: planPricingSchema,
|
||||
isActive: z.boolean().default(true),
|
||||
isPopular: z.boolean().default(false),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
|
||||
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Subscription Schema
|
||||
// ============================================================================
|
||||
|
||||
export const createSubscriptionSchema = z.object({
|
||||
tenantId: z.string().uuid('El ID del tenant no es válido'),
|
||||
planId: z.string().uuid('El ID del plan no es válido'),
|
||||
billingCycle: billingCycleSchema,
|
||||
paymentMethodId: z.string().optional(),
|
||||
promoCode: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
|
||||
|
||||
export const updateSubscriptionSchema = z.object({
|
||||
planId: z.string().uuid('El ID del plan no es válido').optional(),
|
||||
billingCycle: billingCycleSchema.optional(),
|
||||
cancelAtPeriodEnd: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type UpdateSubscriptionInput = z.infer<typeof updateSubscriptionSchema>;
|
||||
|
||||
export const cancelSubscriptionSchema = z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.max(500, 'La razón es demasiado larga')
|
||||
.optional(),
|
||||
feedback: z
|
||||
.string()
|
||||
.max(1000, 'La retroalimentación es demasiado larga')
|
||||
.optional(),
|
||||
immediate: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CancelSubscriptionInput = z.infer<typeof cancelSubscriptionSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Payment Method Schema
|
||||
// ============================================================================
|
||||
|
||||
export const paymentMethodTypeSchema = z.enum([
|
||||
'card',
|
||||
'bank_transfer',
|
||||
'oxxo',
|
||||
'spei',
|
||||
]);
|
||||
|
||||
export const addPaymentMethodSchema = z.object({
|
||||
type: paymentMethodTypeSchema,
|
||||
token: z.string().min(1, 'El token es requerido'),
|
||||
setAsDefault: z.boolean().default(false),
|
||||
billingAddress: addressSchema.optional(),
|
||||
});
|
||||
|
||||
export type AddPaymentMethodInput = z.infer<typeof addPaymentMethodSchema>;
|
||||
|
||||
export const updatePaymentMethodSchema = z.object({
|
||||
setAsDefault: z.boolean().optional(),
|
||||
billingAddress: addressSchema.optional(),
|
||||
});
|
||||
|
||||
export type UpdatePaymentMethodInput = z.infer<typeof updatePaymentMethodSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Promo Code Schema
|
||||
// ============================================================================
|
||||
|
||||
export const promoCodeSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(3, 'El código es muy corto')
|
||||
.max(50, 'El código es muy largo')
|
||||
.toUpperCase()
|
||||
.trim(),
|
||||
});
|
||||
|
||||
export type PromoCodeInput = z.infer<typeof promoCodeSchema>;
|
||||
|
||||
export const createPromoCodeSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.regex(/^[A-Z0-9_-]+$/, 'El código solo puede contener letras, números, guiones y guiones bajos')
|
||||
.toUpperCase(),
|
||||
discountType: z.enum(['percentage', 'fixed']),
|
||||
discountValue: z.number().positive(),
|
||||
maxRedemptions: z.number().int().positive().optional(),
|
||||
validFrom: z.coerce.date(),
|
||||
validUntil: z.coerce.date(),
|
||||
applicablePlans: z.array(z.string().uuid()).optional(),
|
||||
minBillingCycles: z.number().int().positive().optional(),
|
||||
}).refine(
|
||||
(data) => data.validUntil > data.validFrom,
|
||||
{
|
||||
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
|
||||
path: ['validUntil'],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
if (data.discountType === 'percentage') {
|
||||
return data.discountValue <= 100;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'El porcentaje de descuento no puede ser mayor a 100',
|
||||
path: ['discountValue'],
|
||||
}
|
||||
);
|
||||
|
||||
export type CreatePromoCodeInput = z.infer<typeof createPromoCodeSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Invoice Schema
|
||||
// ============================================================================
|
||||
|
||||
export const invoiceFilterSchema = z.object({
|
||||
status: z.enum(['draft', 'open', 'paid', 'void', 'uncollectible']).optional(),
|
||||
dateFrom: z.coerce.date().optional(),
|
||||
dateTo: z.coerce.date().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type InvoiceFilterInput = z.infer<typeof invoiceFilterSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Usage Query Schema
|
||||
// ============================================================================
|
||||
|
||||
export const usageQuerySchema = z.object({
|
||||
period: z
|
||||
.string()
|
||||
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'El período debe tener formato YYYY-MM')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type UsageQueryInput = z.infer<typeof usageQuerySchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Filter Schema (Admin)
|
||||
// ============================================================================
|
||||
|
||||
export const tenantFilterSchema = z.object({
|
||||
search: z.string().max(100).optional(),
|
||||
status: tenantStatusSchema.optional(),
|
||||
planId: z.string().uuid().optional(),
|
||||
dateFrom: z.coerce.date().optional(),
|
||||
dateTo: z.coerce.date().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
sortBy: z.enum(['createdAt', 'name', 'status']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
export type TenantFilterInput = z.infer<typeof tenantFilterSchema>;
|
||||
264
packages/shared/src/types/auth.ts
Normal file
264
packages/shared/src/types/auth.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Authentication Types for Horux Strategy
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// User Roles
|
||||
// ============================================================================
|
||||
|
||||
export type UserRole =
|
||||
| 'super_admin' // Administrador del sistema completo
|
||||
| 'tenant_admin' // Administrador del tenant
|
||||
| 'accountant' // Contador con acceso completo a finanzas
|
||||
| 'assistant' // Asistente con acceso limitado
|
||||
| 'viewer'; // Solo lectura
|
||||
|
||||
export interface UserPermission {
|
||||
resource: string;
|
||||
actions: ('create' | 'read' | 'update' | 'delete')[];
|
||||
}
|
||||
|
||||
export interface RolePermissions {
|
||||
role: UserRole;
|
||||
permissions: UserPermission[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User
|
||||
// ============================================================================
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
permissions: UserPermission[];
|
||||
avatar?: string;
|
||||
phone?: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
isActive: boolean;
|
||||
isEmailVerified: boolean;
|
||||
lastLoginAt?: Date;
|
||||
passwordChangedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
role: UserRole;
|
||||
avatar?: string;
|
||||
phone?: string;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
tenant: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// User Session
|
||||
// ============================================================================
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
isValid: boolean;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
lastActivityAt: Date;
|
||||
}
|
||||
|
||||
export interface ActiveSession {
|
||||
id: string;
|
||||
userAgent?: string;
|
||||
ipAddress?: string;
|
||||
location?: string;
|
||||
lastActivityAt: Date;
|
||||
createdAt: Date;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Requests & Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
tenantSlug?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: UserProfile;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: 'Bearer';
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
tenantName?: string;
|
||||
inviteCode?: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
user: UserProfile;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: 'Bearer';
|
||||
requiresEmailVerification: boolean;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: 'Bearer';
|
||||
}
|
||||
|
||||
export interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
token: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface VerifyEmailRequest {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Payload
|
||||
// ============================================================================
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string; // User ID
|
||||
email: string;
|
||||
tenantId: string;
|
||||
role: UserRole;
|
||||
permissions: string[];
|
||||
sessionId: string;
|
||||
iat: number; // Issued at
|
||||
exp: number; // Expiration
|
||||
iss: string; // Issuer
|
||||
aud: string; // Audience
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
sub: string; // User ID
|
||||
sessionId: string;
|
||||
tokenFamily: string; // Para detección de reuso
|
||||
iat: number;
|
||||
exp: number;
|
||||
iss: string;
|
||||
aud: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invitation
|
||||
// ============================================================================
|
||||
|
||||
export interface UserInvitation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
invitedBy: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
acceptedAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface InviteUserRequest {
|
||||
email: string;
|
||||
role: UserRole;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Two-Factor Authentication
|
||||
// ============================================================================
|
||||
|
||||
export interface TwoFactorSetup {
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
export interface TwoFactorVerifyRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorLoginRequest {
|
||||
tempToken: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Log
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthAuditLog {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
action: AuthAction;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
success: boolean;
|
||||
failureReason?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type AuthAction =
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'register'
|
||||
| 'password_reset_request'
|
||||
| 'password_reset_complete'
|
||||
| 'password_change'
|
||||
| 'email_verification'
|
||||
| 'two_factor_enable'
|
||||
| 'two_factor_disable'
|
||||
| 'session_revoke'
|
||||
| 'token_refresh';
|
||||
634
packages/shared/src/types/financial.ts
Normal file
634
packages/shared/src/types/financial.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Financial Types for Horux Strategy
|
||||
* Core financial entities for Mexican accounting
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Types
|
||||
// ============================================================================
|
||||
|
||||
export type TransactionType =
|
||||
| 'income' // Ingreso
|
||||
| 'expense' // Egreso
|
||||
| 'transfer' // Transferencia entre cuentas
|
||||
| 'adjustment'; // Ajuste contable
|
||||
|
||||
export type TransactionStatus =
|
||||
| 'pending' // Pendiente de procesar
|
||||
| 'cleared' // Conciliado
|
||||
| 'reconciled' // Conciliado con banco
|
||||
| 'voided'; // Anulado
|
||||
|
||||
export type PaymentMethod =
|
||||
| 'cash' // Efectivo
|
||||
| 'bank_transfer' // Transferencia bancaria
|
||||
| 'credit_card' // Tarjeta de crédito
|
||||
| 'debit_card' // Tarjeta de débito
|
||||
| 'check' // Cheque
|
||||
| 'digital_wallet' // Wallet digital (SPEI, etc)
|
||||
| 'other'; // Otro
|
||||
|
||||
// ============================================================================
|
||||
// Transaction
|
||||
// ============================================================================
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: TransactionType;
|
||||
status: TransactionStatus;
|
||||
|
||||
// Monto
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRate?: number;
|
||||
amountInBaseCurrency: number;
|
||||
|
||||
// Detalles
|
||||
description: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
|
||||
// Fecha
|
||||
date: Date;
|
||||
valueDate?: Date;
|
||||
|
||||
// Relaciones
|
||||
accountId: string;
|
||||
destinationAccountId?: string; // Para transferencias
|
||||
categoryId?: string;
|
||||
contactId?: string;
|
||||
cfdiId?: string;
|
||||
|
||||
// Pago
|
||||
paymentMethod?: PaymentMethod;
|
||||
paymentReference?: string;
|
||||
|
||||
// Tags y metadata
|
||||
tags: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
// Recurrencia
|
||||
isRecurring: boolean;
|
||||
recurringRuleId?: string;
|
||||
|
||||
// Conciliación
|
||||
bankStatementId?: string;
|
||||
reconciledAt?: Date;
|
||||
|
||||
// Auditoría
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TransactionSummary {
|
||||
id: string;
|
||||
type: TransactionType;
|
||||
status: TransactionStatus;
|
||||
amount: number;
|
||||
description: string;
|
||||
date: Date;
|
||||
categoryName?: string;
|
||||
contactName?: string;
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
type?: TransactionType[];
|
||||
status?: TransactionStatus[];
|
||||
accountId?: string;
|
||||
categoryId?: string;
|
||||
contactId?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
amountMin?: number;
|
||||
amountMax?: number;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CFDI Types (Factura Electrónica México)
|
||||
// ============================================================================
|
||||
|
||||
export type CFDIType =
|
||||
| 'I' // Ingreso
|
||||
| 'E' // Egreso
|
||||
| 'T' // Traslado
|
||||
| 'N' // Nómina
|
||||
| 'P'; // Pago
|
||||
|
||||
export type CFDIStatus =
|
||||
| 'draft' // Borrador
|
||||
| 'pending' // Pendiente de timbrar
|
||||
| 'stamped' // Timbrado
|
||||
| 'sent' // Enviado al cliente
|
||||
| 'paid' // Pagado
|
||||
| 'partial_paid' // Parcialmente pagado
|
||||
| 'cancelled' // Cancelado
|
||||
| 'cancellation_pending'; // Cancelación pendiente
|
||||
|
||||
export type CFDIUsage =
|
||||
| 'G01' // Adquisición de mercancías
|
||||
| 'G02' // Devoluciones, descuentos o bonificaciones
|
||||
| 'G03' // Gastos en general
|
||||
| 'I01' // Construcciones
|
||||
| 'I02' // Mobiliario y equipo de oficina
|
||||
| 'I03' // Equipo de transporte
|
||||
| 'I04' // Equipo de cómputo
|
||||
| 'I05' // Dados, troqueles, moldes
|
||||
| 'I06' // Comunicaciones telefónicas
|
||||
| 'I07' // Comunicaciones satelitales
|
||||
| 'I08' // Otra maquinaria y equipo
|
||||
| 'D01' // Honorarios médicos
|
||||
| 'D02' // Gastos médicos por incapacidad
|
||||
| 'D03' // Gastos funerales
|
||||
| 'D04' // Donativos
|
||||
| 'D05' // Intereses hipotecarios
|
||||
| 'D06' // Aportaciones voluntarias SAR
|
||||
| 'D07' // Primas seguros gastos médicos
|
||||
| 'D08' // Gastos transportación escolar
|
||||
| 'D09' // Depósitos ahorro
|
||||
| 'D10' // Servicios educativos
|
||||
| 'S01' // Sin efectos fiscales
|
||||
| 'CP01' // Pagos
|
||||
| 'CN01'; // Nómina
|
||||
|
||||
export type PaymentForm =
|
||||
| '01' // Efectivo
|
||||
| '02' // Cheque nominativo
|
||||
| '03' // Transferencia electrónica
|
||||
| '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
|
||||
|
||||
export type PaymentMethod_CFDI =
|
||||
| 'PUE' // Pago en Una sola Exhibición
|
||||
| 'PPD'; // Pago en Parcialidades o Diferido
|
||||
|
||||
// ============================================================================
|
||||
// CFDI
|
||||
// ============================================================================
|
||||
|
||||
export interface CFDI {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: CFDIType;
|
||||
status: CFDIStatus;
|
||||
|
||||
// Identificación
|
||||
series?: string;
|
||||
folio?: string;
|
||||
uuid?: string;
|
||||
|
||||
// Emisor
|
||||
issuerRfc: string;
|
||||
issuerName: string;
|
||||
issuerFiscalRegime: string;
|
||||
issuerPostalCode: string;
|
||||
|
||||
// Receptor
|
||||
receiverRfc: string;
|
||||
receiverName: string;
|
||||
receiverFiscalRegime?: string;
|
||||
receiverPostalCode: string;
|
||||
receiverUsage: CFDIUsage;
|
||||
receiverEmail?: string;
|
||||
|
||||
// Montos
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxes: CFDITax[];
|
||||
total: number;
|
||||
currency: string;
|
||||
exchangeRate?: number;
|
||||
|
||||
// Pago
|
||||
paymentForm: PaymentForm;
|
||||
paymentMethod: PaymentMethod_CFDI;
|
||||
paymentConditions?: string;
|
||||
|
||||
// Conceptos
|
||||
items: CFDIItem[];
|
||||
|
||||
// Relaciones
|
||||
relatedCfdis?: CFDIRelation[];
|
||||
contactId?: string;
|
||||
|
||||
// Fechas
|
||||
issueDate: Date;
|
||||
certificationDate?: Date;
|
||||
cancellationDate?: Date;
|
||||
expirationDate?: Date;
|
||||
|
||||
// Certificación
|
||||
certificateNumber?: string;
|
||||
satCertificateNumber?: string;
|
||||
digitalSignature?: string;
|
||||
satSignature?: string;
|
||||
|
||||
// Archivos
|
||||
xmlUrl?: string;
|
||||
pdfUrl?: string;
|
||||
|
||||
// Cancelación
|
||||
cancellationReason?: string;
|
||||
substitutedByUuid?: string;
|
||||
cancellationAcknowledgment?: string;
|
||||
|
||||
// Pago tracking
|
||||
amountPaid: number;
|
||||
balance: number;
|
||||
lastPaymentDate?: Date;
|
||||
|
||||
// Auditoría
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CFDIItem {
|
||||
id: string;
|
||||
productCode: string; // Clave del producto SAT
|
||||
unitCode: string; // Clave de unidad SAT
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount?: number;
|
||||
subtotal: number;
|
||||
taxes: CFDIItemTax[];
|
||||
total: number;
|
||||
|
||||
// Opcional
|
||||
identificationNumber?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CFDITax {
|
||||
type: 'transferred' | 'withheld';
|
||||
tax: 'IVA' | 'ISR' | 'IEPS';
|
||||
factor: 'Tasa' | 'Cuota' | 'Exento';
|
||||
rate: number;
|
||||
base: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface CFDIItemTax {
|
||||
type: 'transferred' | 'withheld';
|
||||
tax: 'IVA' | 'ISR' | 'IEPS';
|
||||
factor: 'Tasa' | 'Cuota' | 'Exento';
|
||||
rate: number;
|
||||
base: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface CFDIRelation {
|
||||
type: CFDIRelationType;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export type CFDIRelationType =
|
||||
| '01' // Nota de crédito
|
||||
| '02' // Nota de débito
|
||||
| '03' // Devolución de mercancía
|
||||
| '04' // Sustitución de CFDI previos
|
||||
| '05' // Traslados de mercancías facturadas
|
||||
| '06' // Factura por traslados previos
|
||||
| '07' // CFDI por aplicación de anticipo
|
||||
| '08' // Factura por pagos en parcialidades
|
||||
| '09'; // Factura por pagos diferidos
|
||||
|
||||
export interface CFDIPayment {
|
||||
id: string;
|
||||
cfdiId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRate?: number;
|
||||
paymentDate: Date;
|
||||
paymentForm: PaymentForm;
|
||||
relatedCfdi: string;
|
||||
previousBalance: number;
|
||||
paidAmount: number;
|
||||
remainingBalance: number;
|
||||
transactionId?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Contact Types
|
||||
// ============================================================================
|
||||
|
||||
export type ContactType =
|
||||
| 'customer' // Cliente
|
||||
| 'supplier' // Proveedor
|
||||
| 'both' // Ambos
|
||||
| 'employee'; // Empleado
|
||||
|
||||
// ============================================================================
|
||||
// Contact
|
||||
// ============================================================================
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ContactType;
|
||||
|
||||
// Información básica
|
||||
name: string;
|
||||
displayName: string;
|
||||
rfc?: string;
|
||||
curp?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
|
||||
// Fiscal
|
||||
fiscalRegime?: string;
|
||||
fiscalName?: string;
|
||||
cfdiUsage?: CFDIUsage;
|
||||
|
||||
// Dirección
|
||||
address?: ContactAddress;
|
||||
|
||||
// Crédito
|
||||
creditLimit?: number;
|
||||
creditDays?: number;
|
||||
balance: number;
|
||||
|
||||
// Bancarios
|
||||
bankAccounts?: ContactBankAccount[];
|
||||
|
||||
// Categorización
|
||||
tags: string[];
|
||||
groupId?: string;
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
|
||||
// Notas
|
||||
notes?: string;
|
||||
|
||||
// Auditoría
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
street: string;
|
||||
exteriorNumber: string;
|
||||
interiorNumber?: string;
|
||||
neighborhood: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface ContactBankAccount {
|
||||
id: string;
|
||||
bankName: string;
|
||||
accountNumber: string;
|
||||
clabe?: string;
|
||||
accountHolder: string;
|
||||
currency: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ContactSummary {
|
||||
id: string;
|
||||
type: ContactType;
|
||||
name: string;
|
||||
rfc?: string;
|
||||
email?: string;
|
||||
balance: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category
|
||||
// ============================================================================
|
||||
|
||||
export type CategoryType = 'income' | 'expense';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: CategoryType;
|
||||
name: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
parentId?: string;
|
||||
satCode?: string; // Código SAT para mapeo
|
||||
isSystem: boolean; // Categoría del sistema (no editable)
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CategoryTree extends Category {
|
||||
children: CategoryTree[];
|
||||
fullPath: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Types
|
||||
// ============================================================================
|
||||
|
||||
export type AccountType =
|
||||
| 'bank' // Cuenta bancaria
|
||||
| 'cash' // Caja/Efectivo
|
||||
| 'credit_card' // Tarjeta de crédito
|
||||
| 'loan' // Préstamo
|
||||
| 'investment' // Inversión
|
||||
| 'other'; // Otro
|
||||
|
||||
export type AccountSubtype =
|
||||
| 'checking' // Cuenta de cheques
|
||||
| 'savings' // Cuenta de ahorro
|
||||
| 'money_market' // Mercado de dinero
|
||||
| 'cd' // Certificado de depósito
|
||||
| 'credit' // Crédito
|
||||
| 'line_of_credit' // Línea de crédito
|
||||
| 'mortgage' // Hipoteca
|
||||
| 'auto_loan' // Préstamo auto
|
||||
| 'personal_loan' // Préstamo personal
|
||||
| 'brokerage' // Corretaje
|
||||
| 'retirement' // Retiro
|
||||
| 'other'; // Otro
|
||||
|
||||
// ============================================================================
|
||||
// Account
|
||||
// ============================================================================
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: AccountType;
|
||||
subtype?: AccountSubtype;
|
||||
|
||||
// Información básica
|
||||
name: string;
|
||||
description?: string;
|
||||
accountNumber?: string;
|
||||
currency: string;
|
||||
|
||||
// Banco
|
||||
bankName?: string;
|
||||
bankBranch?: string;
|
||||
clabe?: string;
|
||||
swiftCode?: string;
|
||||
|
||||
// Saldos
|
||||
currentBalance: number;
|
||||
availableBalance: number;
|
||||
creditLimit?: number;
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
isReconcilable: boolean;
|
||||
|
||||
// Sincronización
|
||||
lastSyncAt?: Date;
|
||||
connectionId?: string;
|
||||
|
||||
// Auditoría
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string;
|
||||
type: AccountType;
|
||||
name: string;
|
||||
bankName?: string;
|
||||
currentBalance: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
accountId: string;
|
||||
date: Date;
|
||||
balance: number;
|
||||
availableBalance: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Recurring Rules
|
||||
// ============================================================================
|
||||
|
||||
export type RecurringFrequency =
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'biweekly'
|
||||
| 'monthly'
|
||||
| 'quarterly'
|
||||
| 'yearly';
|
||||
|
||||
export interface RecurringRule {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: TransactionType;
|
||||
frequency: RecurringFrequency;
|
||||
interval: number; // Cada N períodos
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
nextOccurrence: Date;
|
||||
lastOccurrence?: Date;
|
||||
|
||||
// Template de transacción
|
||||
amount: number;
|
||||
description: string;
|
||||
accountId: string;
|
||||
categoryId?: string;
|
||||
contactId?: string;
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
occurrenceCount: number;
|
||||
maxOccurrences?: number;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bank Statement & Reconciliation
|
||||
// ============================================================================
|
||||
|
||||
export interface BankStatement {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
accountId: string;
|
||||
|
||||
// Período
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
|
||||
// Saldos
|
||||
openingBalance: number;
|
||||
closingBalance: number;
|
||||
|
||||
// Estado
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
reconciledAt?: Date;
|
||||
reconciledBy?: string;
|
||||
|
||||
// Archivo
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
|
||||
// Conteos
|
||||
totalTransactions: number;
|
||||
matchedTransactions: number;
|
||||
unmatchedTransactions: number;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BankStatementLine {
|
||||
id: string;
|
||||
statementId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
reference?: string;
|
||||
amount: number;
|
||||
balance?: number;
|
||||
type: 'debit' | 'credit';
|
||||
|
||||
// Matching
|
||||
matchedTransactionId?: string;
|
||||
matchConfidence?: number;
|
||||
matchStatus: 'unmatched' | 'matched' | 'created' | 'ignored';
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
305
packages/shared/src/types/index.ts
Normal file
305
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Types Index - Re-export all types
|
||||
*/
|
||||
|
||||
// Authentication types
|
||||
export * from './auth';
|
||||
|
||||
// Tenant & multi-tenancy types
|
||||
export * from './tenant';
|
||||
|
||||
// Financial & accounting types
|
||||
export * from './financial';
|
||||
|
||||
// Metrics & analytics types
|
||||
export * from './metrics';
|
||||
|
||||
// Reports & alerts types
|
||||
export * from './reports';
|
||||
|
||||
// ============================================================================
|
||||
// Common Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic API response wrapper
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated API response
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
pagination: PaginationMeta;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination request parameters
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* API Error response
|
||||
*/
|
||||
export interface ApiError {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
field?: string;
|
||||
stack?: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error details
|
||||
*/
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch operation result
|
||||
*/
|
||||
export interface BatchResult<T> {
|
||||
success: boolean;
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: BatchItemResult<T>[];
|
||||
}
|
||||
|
||||
export interface BatchItemResult<T> {
|
||||
index: number;
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection for bulk operations
|
||||
*/
|
||||
export interface SelectionState {
|
||||
selectedIds: string[];
|
||||
selectAll: boolean;
|
||||
excludedIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configuration
|
||||
*/
|
||||
export interface FilterConfig {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'starts_with'
|
||||
| 'ends_with'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'between'
|
||||
| 'is_null'
|
||||
| 'is_not_null';
|
||||
|
||||
/**
|
||||
* Search parameters
|
||||
*/
|
||||
export interface SearchParams {
|
||||
query: string;
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit information
|
||||
*/
|
||||
export interface AuditInfo {
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedBy?: string;
|
||||
updatedAt: Date;
|
||||
deletedBy?: string;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity with soft delete
|
||||
*/
|
||||
export interface SoftDeletable {
|
||||
deletedAt?: Date;
|
||||
deletedBy?: string;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity with timestamps
|
||||
*/
|
||||
export interface Timestamped {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base entity with common fields
|
||||
*/
|
||||
export interface BaseEntity extends Timestamped {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup option for dropdowns
|
||||
*/
|
||||
export interface LookupOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree node for hierarchical data
|
||||
*/
|
||||
export interface TreeNode<T> {
|
||||
data: T;
|
||||
children: TreeNode<T>[];
|
||||
parent?: TreeNode<T>;
|
||||
level: number;
|
||||
isExpanded?: boolean;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload
|
||||
*/
|
||||
export interface FileUpload {
|
||||
id: string;
|
||||
name: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadedBy: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment
|
||||
*/
|
||||
export interface Attachment extends FileUpload {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment
|
||||
*/
|
||||
export interface Comment {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorAvatar?: string;
|
||||
parentId?: string;
|
||||
replies?: Comment[];
|
||||
attachments?: Attachment[];
|
||||
isEdited: boolean;
|
||||
editedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity log entry
|
||||
*/
|
||||
export interface ActivityLog {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityName?: string;
|
||||
changes?: Record<string, { old: unknown; new: unknown }>;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag
|
||||
*/
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
description?: string;
|
||||
rolloutPercentage?: number;
|
||||
conditions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* App configuration
|
||||
*/
|
||||
export interface AppConfig {
|
||||
environment: 'development' | 'staging' | 'production';
|
||||
version: string;
|
||||
apiUrl: string;
|
||||
features: Record<string, boolean>;
|
||||
limits: Record<string, number>;
|
||||
}
|
||||
490
packages/shared/src/types/metrics.ts
Normal file
490
packages/shared/src/types/metrics.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Metrics Types for Horux Strategy
|
||||
* Analytics, KPIs and Dashboard data structures
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Metric Period
|
||||
// ============================================================================
|
||||
|
||||
export type MetricPeriod =
|
||||
| 'today'
|
||||
| 'yesterday'
|
||||
| 'this_week'
|
||||
| 'last_week'
|
||||
| 'this_month'
|
||||
| 'last_month'
|
||||
| 'this_quarter'
|
||||
| 'last_quarter'
|
||||
| 'this_year'
|
||||
| 'last_year'
|
||||
| 'last_7_days'
|
||||
| 'last_30_days'
|
||||
| 'last_90_days'
|
||||
| 'last_12_months'
|
||||
| 'custom';
|
||||
|
||||
export type MetricGranularity =
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'quarter'
|
||||
| 'year';
|
||||
|
||||
export type MetricAggregation =
|
||||
| 'sum'
|
||||
| 'avg'
|
||||
| 'min'
|
||||
| 'max'
|
||||
| 'count'
|
||||
| 'first'
|
||||
| 'last';
|
||||
|
||||
// ============================================================================
|
||||
// Date Range
|
||||
// ============================================================================
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
period?: MetricPeriod;
|
||||
}
|
||||
|
||||
export interface DateRangeComparison {
|
||||
current: DateRange;
|
||||
previous: DateRange;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Metric
|
||||
// ============================================================================
|
||||
|
||||
export interface Metric {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
category: MetricCategory;
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
|
||||
// Tipo de dato
|
||||
valueType: 'number' | 'currency' | 'percentage' | 'count';
|
||||
currency?: string;
|
||||
|
||||
// Configuración
|
||||
aggregation: MetricAggregation;
|
||||
isPositiveGood: boolean; // Para determinar color del cambio
|
||||
|
||||
// Objetivo
|
||||
targetValue?: number;
|
||||
warningThreshold?: number;
|
||||
criticalThreshold?: number;
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type MetricCategory =
|
||||
| 'revenue'
|
||||
| 'expenses'
|
||||
| 'profit'
|
||||
| 'cash_flow'
|
||||
| 'receivables'
|
||||
| 'payables'
|
||||
| 'taxes'
|
||||
| 'invoicing'
|
||||
| 'operations';
|
||||
|
||||
// ============================================================================
|
||||
// Metric Value
|
||||
// ============================================================================
|
||||
|
||||
export interface MetricValue {
|
||||
metricId: string;
|
||||
metricKey: string;
|
||||
period: DateRange;
|
||||
value: number;
|
||||
formattedValue: string;
|
||||
count?: number; // Número de elementos que componen el valor
|
||||
breakdown?: MetricBreakdown[];
|
||||
}
|
||||
|
||||
export interface MetricBreakdown {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
percentage: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface MetricTimeSeries {
|
||||
metricId: string;
|
||||
metricKey: string;
|
||||
period: DateRange;
|
||||
granularity: MetricGranularity;
|
||||
dataPoints: MetricDataPoint[];
|
||||
}
|
||||
|
||||
export interface MetricDataPoint {
|
||||
date: Date;
|
||||
value: number;
|
||||
formattedValue?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Metric Comparison
|
||||
// ============================================================================
|
||||
|
||||
export interface MetricComparison {
|
||||
metricId: string;
|
||||
metricKey: string;
|
||||
current: MetricValue;
|
||||
previous: MetricValue;
|
||||
change: MetricChange;
|
||||
}
|
||||
|
||||
export interface MetricChange {
|
||||
absolute: number;
|
||||
percentage: number;
|
||||
direction: 'up' | 'down' | 'unchanged';
|
||||
isPositive: boolean; // Basado en isPositiveGood del Metric
|
||||
formattedAbsolute: string;
|
||||
formattedPercentage: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KPI Card
|
||||
// ============================================================================
|
||||
|
||||
export interface KPICard {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
|
||||
// Valor principal
|
||||
value: number;
|
||||
formattedValue: string;
|
||||
valueType: 'number' | 'currency' | 'percentage' | 'count';
|
||||
currency?: string;
|
||||
|
||||
// Comparación
|
||||
comparison?: MetricChange;
|
||||
comparisonLabel?: string;
|
||||
|
||||
// Objetivo
|
||||
target?: {
|
||||
value: number;
|
||||
formattedValue: string;
|
||||
progress: number; // 0-100
|
||||
};
|
||||
|
||||
// Trend
|
||||
trend?: {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
dataPoints: number[];
|
||||
};
|
||||
|
||||
// Desglose
|
||||
breakdown?: MetricBreakdown[];
|
||||
|
||||
// Acción
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Data
|
||||
// ============================================================================
|
||||
|
||||
export interface DashboardData {
|
||||
tenantId: string;
|
||||
period: DateRange;
|
||||
comparisonPeriod?: DateRange;
|
||||
generatedAt: Date;
|
||||
|
||||
// KPIs principales
|
||||
kpis: DashboardKPIs;
|
||||
|
||||
// Resumen financiero
|
||||
financialSummary: FinancialSummary;
|
||||
|
||||
// Flujo de efectivo
|
||||
cashFlow: CashFlowData;
|
||||
|
||||
// Por cobrar y por pagar
|
||||
receivables: ReceivablesData;
|
||||
payables: PayablesData;
|
||||
|
||||
// Gráficas
|
||||
revenueChart: MetricTimeSeries;
|
||||
expenseChart: MetricTimeSeries;
|
||||
profitChart: MetricTimeSeries;
|
||||
|
||||
// Top lists
|
||||
topCustomers: TopListItem[];
|
||||
topSuppliers: TopListItem[];
|
||||
topCategories: TopListItem[];
|
||||
|
||||
// Alertas y pendientes
|
||||
alerts: DashboardAlert[];
|
||||
pendingItems: PendingItem[];
|
||||
}
|
||||
|
||||
export interface DashboardKPIs {
|
||||
totalRevenue: KPICard;
|
||||
totalExpenses: KPICard;
|
||||
netProfit: KPICard;
|
||||
profitMargin: KPICard;
|
||||
cashBalance: KPICard;
|
||||
accountsReceivable: KPICard;
|
||||
accountsPayable: KPICard;
|
||||
pendingInvoices: KPICard;
|
||||
}
|
||||
|
||||
export interface FinancialSummary {
|
||||
period: DateRange;
|
||||
|
||||
// Ingresos
|
||||
totalRevenue: number;
|
||||
invoicedRevenue: number;
|
||||
otherRevenue: number;
|
||||
|
||||
// Gastos
|
||||
totalExpenses: number;
|
||||
operatingExpenses: number;
|
||||
costOfGoods: number;
|
||||
otherExpenses: number;
|
||||
|
||||
// Impuestos
|
||||
ivaCollected: number;
|
||||
ivaPaid: number;
|
||||
ivaBalance: number;
|
||||
isrRetained: number;
|
||||
|
||||
// Resultado
|
||||
grossProfit: number;
|
||||
grossMargin: number;
|
||||
netProfit: number;
|
||||
netMargin: number;
|
||||
|
||||
// Comparación
|
||||
comparison?: {
|
||||
revenueChange: MetricChange;
|
||||
expensesChange: MetricChange;
|
||||
profitChange: MetricChange;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CashFlowData {
|
||||
period: DateRange;
|
||||
|
||||
// Saldos
|
||||
openingBalance: number;
|
||||
closingBalance: number;
|
||||
netChange: number;
|
||||
|
||||
// Flujos
|
||||
operatingCashFlow: number;
|
||||
investingCashFlow: number;
|
||||
financingCashFlow: number;
|
||||
|
||||
// Desglose operativo
|
||||
cashFromCustomers: number;
|
||||
cashToSuppliers: number;
|
||||
cashToEmployees: number;
|
||||
taxesPaid: number;
|
||||
otherOperating: number;
|
||||
|
||||
// Proyección
|
||||
projection?: CashFlowProjection[];
|
||||
|
||||
// Serie temporal
|
||||
timeSeries: MetricDataPoint[];
|
||||
}
|
||||
|
||||
export interface CashFlowProjection {
|
||||
date: Date;
|
||||
projectedBalance: number;
|
||||
expectedInflows: number;
|
||||
expectedOutflows: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface ReceivablesData {
|
||||
total: number;
|
||||
current: number; // No vencido
|
||||
overdue: number; // Vencido
|
||||
overduePercentage: number;
|
||||
|
||||
// Por antigüedad
|
||||
aging: AgingBucket[];
|
||||
|
||||
// Top deudores
|
||||
topDebtors: {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
amount: number;
|
||||
oldestDueDate: Date;
|
||||
}[];
|
||||
|
||||
// Cobros esperados
|
||||
expectedCollections: {
|
||||
thisWeek: number;
|
||||
thisMonth: number;
|
||||
nextMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PayablesData {
|
||||
total: number;
|
||||
current: number;
|
||||
overdue: number;
|
||||
overduePercentage: number;
|
||||
|
||||
// Por antigüedad
|
||||
aging: AgingBucket[];
|
||||
|
||||
// Top acreedores
|
||||
topCreditors: {
|
||||
contactId: string;
|
||||
contactName: string;
|
||||
amount: number;
|
||||
oldestDueDate: Date;
|
||||
}[];
|
||||
|
||||
// Pagos programados
|
||||
scheduledPayments: {
|
||||
thisWeek: number;
|
||||
thisMonth: number;
|
||||
nextMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgingBucket {
|
||||
label: string;
|
||||
minDays: number;
|
||||
maxDays?: number;
|
||||
amount: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface TopListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
formattedValue: string;
|
||||
percentage: number;
|
||||
count?: number;
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface DashboardAlert {
|
||||
id: string;
|
||||
type: AlertType;
|
||||
severity: 'info' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type AlertType =
|
||||
| 'overdue_invoice'
|
||||
| 'overdue_payment'
|
||||
| 'low_cash'
|
||||
| 'high_expenses'
|
||||
| 'tax_deadline'
|
||||
| 'subscription_expiring'
|
||||
| 'usage_limit'
|
||||
| 'reconciliation_needed'
|
||||
| 'pending_approval';
|
||||
|
||||
export interface PendingItem {
|
||||
id: string;
|
||||
type: PendingItemType;
|
||||
title: string;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
dueDate?: Date;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
actionLabel: string;
|
||||
actionUrl: string;
|
||||
}
|
||||
|
||||
export type PendingItemType =
|
||||
| 'draft_invoice'
|
||||
| 'pending_approval'
|
||||
| 'unreconciled_transaction'
|
||||
| 'uncategorized_expense'
|
||||
| 'missing_document'
|
||||
| 'overdue_task';
|
||||
|
||||
// ============================================================================
|
||||
// Widget Configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
type: WidgetType;
|
||||
title: string;
|
||||
description?: string;
|
||||
|
||||
// Layout
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
// Configuración
|
||||
config: WidgetConfig;
|
||||
|
||||
// Estado
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export type WidgetType =
|
||||
| 'kpi'
|
||||
| 'chart_line'
|
||||
| 'chart_bar'
|
||||
| 'chart_pie'
|
||||
| 'chart_area'
|
||||
| 'table'
|
||||
| 'list'
|
||||
| 'calendar'
|
||||
| 'alerts';
|
||||
|
||||
export interface WidgetConfig {
|
||||
metricKey?: string;
|
||||
period?: MetricPeriod;
|
||||
showComparison?: boolean;
|
||||
showTarget?: boolean;
|
||||
showTrend?: boolean;
|
||||
showBreakdown?: boolean;
|
||||
limit?: number;
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Layout
|
||||
// ============================================================================
|
||||
|
||||
export interface DashboardLayout {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId?: string; // null = layout por defecto
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
widgets: DashboardWidget[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
578
packages/shared/src/types/reports.ts
Normal file
578
packages/shared/src/types/reports.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Report Types for Horux Strategy
|
||||
* Reports, exports, and alert configurations
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Report Types
|
||||
// ============================================================================
|
||||
|
||||
export type ReportType =
|
||||
// Financieros
|
||||
| 'income_statement' // Estado de resultados
|
||||
| 'balance_sheet' // Balance general
|
||||
| 'cash_flow' // Flujo de efectivo
|
||||
| 'trial_balance' // Balanza de comprobación
|
||||
|
||||
// Operativos
|
||||
| 'accounts_receivable' // Cuentas por cobrar
|
||||
| 'accounts_payable' // Cuentas por pagar
|
||||
| 'aging_report' // Antigüedad de saldos
|
||||
| 'transactions' // Movimientos
|
||||
|
||||
// Fiscales
|
||||
| 'tax_summary' // Resumen de impuestos
|
||||
| 'iva_report' // Reporte de IVA
|
||||
| 'isr_report' // Reporte de ISR
|
||||
| 'diot' // DIOT
|
||||
|
||||
// CFDI
|
||||
| 'invoices_issued' // Facturas emitidas
|
||||
| 'invoices_received' // Facturas recibidas
|
||||
| 'cfdi_cancellations' // Cancelaciones
|
||||
|
||||
// Análisis
|
||||
| 'expense_analysis' // Análisis de gastos
|
||||
| 'revenue_analysis' // Análisis de ingresos
|
||||
| 'category_analysis' // Análisis por categoría
|
||||
| 'contact_analysis' // Análisis por contacto
|
||||
|
||||
// Custom
|
||||
| 'custom';
|
||||
|
||||
export type ReportStatus =
|
||||
| 'pending' // En cola
|
||||
| 'processing' // Procesando
|
||||
| 'completed' // Completado
|
||||
| 'failed' // Error
|
||||
| 'expired'; // Expirado (archivo eliminado)
|
||||
|
||||
export type ReportFormat =
|
||||
| 'pdf'
|
||||
| 'xlsx'
|
||||
| 'csv'
|
||||
| 'xml'
|
||||
| 'json';
|
||||
|
||||
// ============================================================================
|
||||
// Report
|
||||
// ============================================================================
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ReportType;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: ReportStatus;
|
||||
|
||||
// Configuración
|
||||
config: ReportConfig;
|
||||
|
||||
// Período
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
|
||||
// Formato
|
||||
format: ReportFormat;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
|
||||
// Archivo generado
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileSizeBytes?: number;
|
||||
expiresAt?: Date;
|
||||
|
||||
// Procesamiento
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
error?: string;
|
||||
progress?: number; // 0-100
|
||||
|
||||
// Metadatos
|
||||
rowCount?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
// Auditoría
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ReportConfig {
|
||||
// Filtros generales
|
||||
accountIds?: string[];
|
||||
categoryIds?: string[];
|
||||
contactIds?: string[];
|
||||
transactionTypes?: string[];
|
||||
|
||||
// Agrupación
|
||||
groupBy?: ReportGroupBy[];
|
||||
sortBy?: ReportSortConfig[];
|
||||
|
||||
// Columnas
|
||||
columns?: string[];
|
||||
includeSubtotals?: boolean;
|
||||
includeTotals?: boolean;
|
||||
|
||||
// Comparación
|
||||
compareWithPreviousPeriod?: boolean;
|
||||
comparisonPeriodStart?: Date;
|
||||
comparisonPeriodEnd?: Date;
|
||||
|
||||
// Moneda
|
||||
currency?: string;
|
||||
showOriginalCurrency?: boolean;
|
||||
|
||||
// Formato específico
|
||||
showZeroBalances?: boolean;
|
||||
showInactiveAccounts?: boolean;
|
||||
consolidateAccounts?: boolean;
|
||||
|
||||
// PDF específico
|
||||
includeCharts?: boolean;
|
||||
includeSummary?: boolean;
|
||||
includeNotes?: boolean;
|
||||
companyLogo?: boolean;
|
||||
pageOrientation?: 'portrait' | 'landscape';
|
||||
|
||||
// Personalizado
|
||||
customFilters?: Record<string, unknown>;
|
||||
customOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ReportGroupBy =
|
||||
| 'date'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'quarter'
|
||||
| 'year'
|
||||
| 'account'
|
||||
| 'category'
|
||||
| 'contact'
|
||||
| 'type'
|
||||
| 'status';
|
||||
|
||||
export interface ReportSortConfig {
|
||||
field: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Template
|
||||
// ============================================================================
|
||||
|
||||
export interface ReportTemplate {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ReportType;
|
||||
name: string;
|
||||
description?: string;
|
||||
config: ReportConfig;
|
||||
isDefault: boolean;
|
||||
isSystem: boolean;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduled Report
|
||||
// ============================================================================
|
||||
|
||||
export type ScheduleFrequency =
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'biweekly'
|
||||
| 'monthly'
|
||||
| 'quarterly'
|
||||
| 'yearly';
|
||||
|
||||
export interface ScheduledReport {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
templateId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
|
||||
// Programación
|
||||
frequency: ScheduleFrequency;
|
||||
dayOfWeek?: number; // 0-6 (domingo-sábado)
|
||||
dayOfMonth?: number; // 1-31
|
||||
time: string; // HH:mm formato 24h
|
||||
timezone: string;
|
||||
|
||||
// Período del reporte
|
||||
periodType: 'previous' | 'current' | 'custom';
|
||||
periodOffset?: number; // Para períodos anteriores
|
||||
|
||||
// Formato
|
||||
format: ReportFormat;
|
||||
|
||||
// Entrega
|
||||
deliveryMethod: DeliveryMethod[];
|
||||
recipients: ReportRecipient[];
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
lastRunAt?: Date;
|
||||
nextRunAt: Date;
|
||||
lastReportId?: string;
|
||||
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type DeliveryMethod =
|
||||
| 'email'
|
||||
| 'download'
|
||||
| 'webhook'
|
||||
| 'storage';
|
||||
|
||||
export interface ReportRecipient {
|
||||
type: 'user' | 'email';
|
||||
userId?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Report Execution
|
||||
// ============================================================================
|
||||
|
||||
export interface ReportExecution {
|
||||
id: string;
|
||||
reportId?: string;
|
||||
scheduledReportId?: string;
|
||||
status: ReportStatus;
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Alert Types
|
||||
// ============================================================================
|
||||
|
||||
export type AlertSeverity =
|
||||
| 'info'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'critical';
|
||||
|
||||
export type AlertChannel =
|
||||
| 'in_app'
|
||||
| 'email'
|
||||
| 'sms'
|
||||
| 'push'
|
||||
| 'webhook';
|
||||
|
||||
export type AlertTriggerType =
|
||||
// Financieros
|
||||
| 'low_cash_balance'
|
||||
| 'high_expenses'
|
||||
| 'revenue_drop'
|
||||
| 'profit_margin_low'
|
||||
|
||||
// Cobros y pagos
|
||||
| 'invoice_overdue'
|
||||
| 'payment_due'
|
||||
| 'receivable_aging'
|
||||
| 'payable_aging'
|
||||
|
||||
// Límites
|
||||
| 'budget_exceeded'
|
||||
| 'credit_limit_reached'
|
||||
| 'usage_limit_warning'
|
||||
|
||||
// Operaciones
|
||||
| 'reconciliation_discrepancy'
|
||||
| 'duplicate_transaction'
|
||||
| 'unusual_activity'
|
||||
|
||||
// Fiscales
|
||||
| 'tax_deadline'
|
||||
| 'cfdi_rejection'
|
||||
| 'sat_notification'
|
||||
|
||||
// Sistema
|
||||
| 'subscription_expiring'
|
||||
| 'integration_error'
|
||||
| 'backup_failed';
|
||||
|
||||
// ============================================================================
|
||||
// Alert
|
||||
// ============================================================================
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
ruleId?: string;
|
||||
type: AlertTriggerType;
|
||||
severity: AlertSeverity;
|
||||
|
||||
// Contenido
|
||||
title: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
// Contexto
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
entityName?: string;
|
||||
|
||||
// Valores
|
||||
currentValue?: number;
|
||||
thresholdValue?: number;
|
||||
|
||||
// Acción
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
actionRequired: boolean;
|
||||
|
||||
// Estado
|
||||
status: AlertStatus;
|
||||
acknowledgedBy?: string;
|
||||
acknowledgedAt?: Date;
|
||||
resolvedBy?: string;
|
||||
resolvedAt?: Date;
|
||||
resolution?: string;
|
||||
|
||||
// Notificaciones
|
||||
channels: AlertChannel[];
|
||||
notifiedAt?: Date;
|
||||
|
||||
// Auditoría
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type AlertStatus =
|
||||
| 'active'
|
||||
| 'acknowledged'
|
||||
| 'resolved'
|
||||
| 'dismissed';
|
||||
|
||||
// ============================================================================
|
||||
// Alert Rule
|
||||
// ============================================================================
|
||||
|
||||
export interface AlertRule {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: AlertTriggerType;
|
||||
severity: AlertSeverity;
|
||||
|
||||
// Condición
|
||||
condition: AlertCondition;
|
||||
|
||||
// Mensaje
|
||||
titleTemplate: string;
|
||||
messageTemplate: string;
|
||||
|
||||
// Notificación
|
||||
channels: AlertChannel[];
|
||||
recipients: AlertRecipient[];
|
||||
|
||||
// Cooldown
|
||||
cooldownMinutes: number; // Tiempo mínimo entre alertas
|
||||
lastTriggeredAt?: Date;
|
||||
|
||||
// Estado
|
||||
isActive: boolean;
|
||||
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AlertCondition {
|
||||
metric: string;
|
||||
operator: AlertOperator;
|
||||
value: number;
|
||||
unit?: string;
|
||||
|
||||
// Para condiciones compuestas
|
||||
and?: AlertCondition[];
|
||||
or?: AlertCondition[];
|
||||
|
||||
// Contexto
|
||||
accountId?: string;
|
||||
categoryId?: string;
|
||||
contactId?: string;
|
||||
|
||||
// Período de evaluación
|
||||
evaluationPeriod?: string; // e.g., "1d", "7d", "30d"
|
||||
}
|
||||
|
||||
export type AlertOperator =
|
||||
| 'gt' // Mayor que
|
||||
| 'gte' // Mayor o igual que
|
||||
| 'lt' // Menor que
|
||||
| 'lte' // Menor o igual que
|
||||
| 'eq' // Igual a
|
||||
| 'neq' // Diferente de
|
||||
| 'between' // Entre (requiere value2)
|
||||
| 'change_gt' // Cambio mayor que %
|
||||
| 'change_lt'; // Cambio menor que %
|
||||
|
||||
export interface AlertRecipient {
|
||||
type: 'user' | 'role' | 'email' | 'webhook';
|
||||
userId?: string;
|
||||
role?: string;
|
||||
email?: string;
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Notification
|
||||
// ============================================================================
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
alertId?: string;
|
||||
|
||||
// Contenido
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
|
||||
// Acción
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
|
||||
// Estado
|
||||
isRead: boolean;
|
||||
readAt?: Date;
|
||||
|
||||
// Metadatos
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
createdAt: Date;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export type NotificationType =
|
||||
| 'alert'
|
||||
| 'report_ready'
|
||||
| 'task_assigned'
|
||||
| 'mention'
|
||||
| 'system'
|
||||
| 'update'
|
||||
| 'reminder';
|
||||
|
||||
// ============================================================================
|
||||
// Export Job
|
||||
// ============================================================================
|
||||
|
||||
export interface ExportJob {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ExportType;
|
||||
status: ReportStatus;
|
||||
|
||||
// Configuración
|
||||
entityType: string;
|
||||
filters?: Record<string, unknown>;
|
||||
columns?: string[];
|
||||
format: ReportFormat;
|
||||
|
||||
// Archivo
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileSizeBytes?: number;
|
||||
rowCount?: number;
|
||||
|
||||
// Procesamiento
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
expiresAt?: Date;
|
||||
error?: string;
|
||||
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type ExportType =
|
||||
| 'transactions'
|
||||
| 'invoices'
|
||||
| 'contacts'
|
||||
| 'categories'
|
||||
| 'accounts'
|
||||
| 'reports'
|
||||
| 'full_backup';
|
||||
|
||||
// ============================================================================
|
||||
// Import Job
|
||||
// ============================================================================
|
||||
|
||||
export interface ImportJob {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ImportType;
|
||||
status: ImportStatus;
|
||||
|
||||
// Archivo
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSizeBytes: number;
|
||||
|
||||
// Mapeo
|
||||
mapping?: ImportMapping;
|
||||
|
||||
// Resultados
|
||||
totalRows?: number;
|
||||
processedRows?: number;
|
||||
successRows?: number;
|
||||
errorRows?: number;
|
||||
errors?: ImportError[];
|
||||
|
||||
// Procesamiento
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type ImportType =
|
||||
| 'transactions'
|
||||
| 'invoices'
|
||||
| 'contacts'
|
||||
| 'categories'
|
||||
| 'bank_statement'
|
||||
| 'cfdi_xml';
|
||||
|
||||
export type ImportStatus =
|
||||
| 'pending'
|
||||
| 'mapping'
|
||||
| 'validating'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface ImportMapping {
|
||||
[targetField: string]: {
|
||||
sourceField: string;
|
||||
transform?: string;
|
||||
defaultValue?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
row: number;
|
||||
field?: string;
|
||||
value?: string;
|
||||
message: string;
|
||||
}
|
||||
379
packages/shared/src/types/tenant.ts
Normal file
379
packages/shared/src/types/tenant.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Tenant Types for Horux Strategy
|
||||
* Multi-tenancy support for SaaS architecture
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Status
|
||||
// ============================================================================
|
||||
|
||||
export type TenantStatus =
|
||||
| 'pending' // Registro pendiente de aprobación
|
||||
| 'active' // Tenant activo y operativo
|
||||
| 'suspended' // Suspendido por falta de pago o violación
|
||||
| 'cancelled' // Cancelado por el usuario
|
||||
| 'trial' // En período de prueba
|
||||
| 'expired'; // Período de prueba expirado
|
||||
|
||||
// ============================================================================
|
||||
// Tenant
|
||||
// ============================================================================
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
legalName?: string;
|
||||
rfc?: string;
|
||||
status: TenantStatus;
|
||||
planId: string;
|
||||
subscriptionId?: string;
|
||||
|
||||
// Configuración fiscal México
|
||||
fiscalRegime?: string;
|
||||
fiscalAddress?: TenantAddress;
|
||||
|
||||
// Información de contacto
|
||||
email: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
|
||||
// Branding
|
||||
logo?: string;
|
||||
primaryColor?: string;
|
||||
secondaryColor?: string;
|
||||
|
||||
// Configuración
|
||||
settings: TenantSettings;
|
||||
features: string[];
|
||||
|
||||
// Límites
|
||||
maxUsers: number;
|
||||
maxTransactionsPerMonth: number;
|
||||
storageUsedMB: number;
|
||||
storageLimitMB: number;
|
||||
|
||||
// Fechas
|
||||
trialEndsAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export interface TenantAddress {
|
||||
street: string;
|
||||
exteriorNumber: string;
|
||||
interiorNumber?: string;
|
||||
neighborhood: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface TenantSettings {
|
||||
// General
|
||||
timezone: string;
|
||||
locale: string;
|
||||
currency: string;
|
||||
fiscalYearStart: number; // Mes (1-12)
|
||||
|
||||
// Facturación
|
||||
defaultPaymentTerms: number; // Días
|
||||
invoicePrefix: string;
|
||||
invoiceNextNumber: number;
|
||||
|
||||
// Notificaciones
|
||||
emailNotifications: boolean;
|
||||
invoiceReminders: boolean;
|
||||
paymentReminders: boolean;
|
||||
|
||||
// Seguridad
|
||||
sessionTimeout: number; // Minutos
|
||||
requireTwoFactor: boolean;
|
||||
allowedIPs?: string[];
|
||||
|
||||
// Integraciones
|
||||
satIntegration: boolean;
|
||||
bankingIntegration: boolean;
|
||||
}
|
||||
|
||||
export interface TenantSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo?: string;
|
||||
status: TenantStatus;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plan & Features
|
||||
// ============================================================================
|
||||
|
||||
export type PlanTier = 'free' | 'starter' | 'professional' | 'enterprise';
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: PlanTier;
|
||||
description: string;
|
||||
features: PlanFeatures;
|
||||
limits: PlanLimits;
|
||||
pricing: PlanPricing;
|
||||
isActive: boolean;
|
||||
isPopular: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface PlanFeatures {
|
||||
// Módulos
|
||||
invoicing: boolean;
|
||||
expenses: boolean;
|
||||
bankReconciliation: boolean;
|
||||
reports: boolean;
|
||||
budgets: boolean;
|
||||
forecasting: boolean;
|
||||
multiCurrency: boolean;
|
||||
|
||||
// Facturación electrónica
|
||||
cfdiGeneration: boolean;
|
||||
cfdiCancellation: boolean;
|
||||
cfdiAddenda: boolean;
|
||||
massInvoicing: boolean;
|
||||
|
||||
// Integraciones
|
||||
satIntegration: boolean;
|
||||
bankIntegration: boolean;
|
||||
erpIntegration: boolean;
|
||||
apiAccess: boolean;
|
||||
webhooks: boolean;
|
||||
|
||||
// Colaboración
|
||||
multiUser: boolean;
|
||||
customRoles: boolean;
|
||||
auditLog: boolean;
|
||||
comments: boolean;
|
||||
|
||||
// Soporte
|
||||
emailSupport: boolean;
|
||||
chatSupport: boolean;
|
||||
phoneSupport: boolean;
|
||||
prioritySupport: boolean;
|
||||
dedicatedManager: boolean;
|
||||
|
||||
// Extras
|
||||
customBranding: boolean;
|
||||
whiteLabel: boolean;
|
||||
dataExport: boolean;
|
||||
advancedReports: boolean;
|
||||
}
|
||||
|
||||
export interface PlanLimits {
|
||||
maxUsers: number;
|
||||
maxTransactionsPerMonth: number;
|
||||
maxInvoicesPerMonth: number;
|
||||
maxContacts: number;
|
||||
maxBankAccounts: number;
|
||||
storageMB: number;
|
||||
apiRequestsPerDay: number;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface PlanPricing {
|
||||
monthlyPrice: number;
|
||||
annualPrice: number;
|
||||
currency: string;
|
||||
trialDays: number;
|
||||
setupFee?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subscription
|
||||
// ============================================================================
|
||||
|
||||
export type SubscriptionStatus =
|
||||
| 'trialing' // En período de prueba
|
||||
| 'active' // Suscripción activa
|
||||
| 'past_due' // Pago atrasado
|
||||
| 'canceled' // Cancelada
|
||||
| 'unpaid' // Sin pagar
|
||||
| 'paused'; // Pausada
|
||||
|
||||
export type BillingCycle = 'monthly' | 'annual';
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
planId: string;
|
||||
status: SubscriptionStatus;
|
||||
billingCycle: BillingCycle;
|
||||
|
||||
// Precios
|
||||
pricePerCycle: number;
|
||||
currency: string;
|
||||
discount?: SubscriptionDiscount;
|
||||
|
||||
// Fechas
|
||||
currentPeriodStart: Date;
|
||||
currentPeriodEnd: Date;
|
||||
trialStart?: Date;
|
||||
trialEnd?: Date;
|
||||
canceledAt?: Date;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
|
||||
// Pago
|
||||
paymentMethodId?: string;
|
||||
lastPaymentAt?: Date;
|
||||
nextPaymentAt?: Date;
|
||||
|
||||
// Stripe/Pasarela
|
||||
externalId?: string;
|
||||
externalCustomerId?: string;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SubscriptionDiscount {
|
||||
code: string;
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
validUntil?: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Usage & Billing
|
||||
// ============================================================================
|
||||
|
||||
export interface TenantUsage {
|
||||
tenantId: string;
|
||||
period: string; // YYYY-MM
|
||||
|
||||
// Conteos
|
||||
activeUsers: number;
|
||||
totalTransactions: number;
|
||||
totalInvoices: number;
|
||||
totalContacts: number;
|
||||
|
||||
// Storage
|
||||
documentsStorageMB: number;
|
||||
attachmentsStorageMB: number;
|
||||
totalStorageMB: number;
|
||||
|
||||
// API
|
||||
apiRequests: number;
|
||||
webhookDeliveries: number;
|
||||
|
||||
// Límites
|
||||
limits: PlanLimits;
|
||||
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
subscriptionId: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
|
||||
// Montos
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
|
||||
// Detalles
|
||||
items: InvoiceItem[];
|
||||
|
||||
// Fechas
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
dueDate: Date;
|
||||
paidAt?: Date;
|
||||
|
||||
// Pago
|
||||
paymentMethod?: string;
|
||||
paymentIntentId?: string;
|
||||
|
||||
// PDF
|
||||
pdfUrl?: string;
|
||||
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type InvoiceStatus =
|
||||
| 'draft'
|
||||
| 'open'
|
||||
| 'paid'
|
||||
| 'void'
|
||||
| 'uncollectible';
|
||||
|
||||
export interface InvoiceItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment Method
|
||||
// ============================================================================
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: PaymentMethodType;
|
||||
isDefault: boolean;
|
||||
|
||||
// Card details (masked)
|
||||
card?: {
|
||||
brand: string;
|
||||
last4: string;
|
||||
expMonth: number;
|
||||
expYear: number;
|
||||
};
|
||||
|
||||
// Bank account (masked)
|
||||
bankAccount?: {
|
||||
bankName: string;
|
||||
last4: string;
|
||||
};
|
||||
|
||||
billingAddress?: TenantAddress;
|
||||
|
||||
externalId?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type PaymentMethodType = 'card' | 'bank_transfer' | 'oxxo' | 'spei';
|
||||
|
||||
// ============================================================================
|
||||
// Tenant Events
|
||||
// ============================================================================
|
||||
|
||||
export interface TenantEvent {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: TenantEventType;
|
||||
data: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type TenantEventType =
|
||||
| 'tenant.created'
|
||||
| 'tenant.updated'
|
||||
| 'tenant.suspended'
|
||||
| 'tenant.activated'
|
||||
| 'tenant.deleted'
|
||||
| 'subscription.created'
|
||||
| 'subscription.updated'
|
||||
| 'subscription.canceled'
|
||||
| 'subscription.renewed'
|
||||
| 'payment.succeeded'
|
||||
| 'payment.failed'
|
||||
| 'usage.limit_warning'
|
||||
| 'usage.limit_reached';
|
||||
658
packages/shared/src/utils/format.ts
Normal file
658
packages/shared/src/utils/format.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Formatting Utilities for Horux Strategy
|
||||
* Currency, percentage, date, and other formatting functions
|
||||
*/
|
||||
|
||||
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, CURRENCIES, DEFAULT_CURRENCY } from '../constants';
|
||||
|
||||
// ============================================================================
|
||||
// Currency Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface CurrencyFormatOptions {
|
||||
currency?: string;
|
||||
locale?: string;
|
||||
showSymbol?: boolean;
|
||||
showCode?: boolean;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as currency (default: Mexican Pesos)
|
||||
*
|
||||
* @example
|
||||
* formatCurrency(1234.56) // "$1,234.56"
|
||||
* formatCurrency(1234.56, { currency: 'USD' }) // "US$1,234.56"
|
||||
* formatCurrency(-1234.56) // "-$1,234.56"
|
||||
* formatCurrency(1234.56, { showCode: true }) // "$1,234.56 MXN"
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
options: CurrencyFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
currency = DEFAULT_CURRENCY,
|
||||
locale = DEFAULT_LOCALE,
|
||||
showSymbol = true,
|
||||
showCode = false,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
signDisplay = 'auto',
|
||||
} = options;
|
||||
|
||||
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||
const decimals = minimumFractionDigits ?? maximumFractionDigits ?? currencyInfo.decimals;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: showSymbol ? 'currency' : 'decimal',
|
||||
currency: showSymbol ? currency : undefined,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: maximumFractionDigits ?? decimals,
|
||||
signDisplay,
|
||||
});
|
||||
|
||||
let formatted = formatter.format(amount);
|
||||
|
||||
// Add currency code if requested
|
||||
if (showCode) {
|
||||
formatted = `${formatted} ${currency}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
// Fallback formatting
|
||||
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
const absAmount = Math.abs(amount).toFixed(decimals);
|
||||
const [intPart, decPart] = absAmount.split('.');
|
||||
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
let result = `${sign}${symbol}${formattedInt}`;
|
||||
if (decPart) {
|
||||
result += `.${decPart}`;
|
||||
}
|
||||
if (showCode) {
|
||||
result += ` ${currency}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency for display in compact form
|
||||
*
|
||||
* @example
|
||||
* formatCurrencyCompact(1234) // "$1.2K"
|
||||
* formatCurrencyCompact(1234567) // "$1.2M"
|
||||
*/
|
||||
export function formatCurrencyCompact(
|
||||
amount: number,
|
||||
options: Omit<CurrencyFormatOptions, 'minimumFractionDigits' | 'maximumFractionDigits'> = {}
|
||||
): string {
|
||||
const {
|
||||
currency = DEFAULT_CURRENCY,
|
||||
locale = DEFAULT_LOCALE,
|
||||
showSymbol = true,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: showSymbol ? 'currency' : 'decimal',
|
||||
currency: showSymbol ? currency : undefined,
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
return formatter.format(amount);
|
||||
} catch {
|
||||
// Fallback
|
||||
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||
const absAmount = Math.abs(amount);
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
|
||||
if (absAmount >= 1000000000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000000000).toFixed(1)}B`;
|
||||
}
|
||||
if (absAmount >= 1000000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (absAmount >= 1000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `${sign}${symbol}${absAmount.toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a currency string to number
|
||||
*
|
||||
* @example
|
||||
* parseCurrency("$1,234.56") // 1234.56
|
||||
* parseCurrency("-$1,234.56") // -1234.56
|
||||
*/
|
||||
export function parseCurrency(value: string): number {
|
||||
// Remove currency symbols, spaces, and thousand separators
|
||||
const cleaned = value
|
||||
.replace(/[^0-9.,-]/g, '')
|
||||
.replace(/,/g, '');
|
||||
|
||||
const number = parseFloat(cleaned);
|
||||
return isNaN(number) ? 0 : number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Percentage Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface PercentFormatOptions {
|
||||
locale?: string;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
multiply?: boolean; // If true, multiply by 100 (e.g., 0.16 -> 16%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as percentage
|
||||
*
|
||||
* @example
|
||||
* formatPercent(16.5) // "16.5%"
|
||||
* formatPercent(0.165, { multiply: true }) // "16.5%"
|
||||
* formatPercent(-5.2, { signDisplay: 'always' }) // "-5.2%"
|
||||
*/
|
||||
export function formatPercent(
|
||||
value: number,
|
||||
options: PercentFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
minimumFractionDigits = 0,
|
||||
maximumFractionDigits = 2,
|
||||
signDisplay = 'auto',
|
||||
multiply = false,
|
||||
} = options;
|
||||
|
||||
const displayValue = multiply ? value : value / 100;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
signDisplay,
|
||||
});
|
||||
|
||||
return formatter.format(displayValue);
|
||||
} catch {
|
||||
// Fallback
|
||||
const sign = signDisplay === 'always' && value > 0 ? '+' : '';
|
||||
const actualValue = multiply ? value * 100 : value;
|
||||
return `${sign}${actualValue.toFixed(maximumFractionDigits)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage change with color indicator
|
||||
* Returns an object with formatted value and direction
|
||||
*/
|
||||
export function formatPercentChange(
|
||||
value: number,
|
||||
options: PercentFormatOptions = {}
|
||||
): {
|
||||
formatted: string;
|
||||
direction: 'up' | 'down' | 'unchanged';
|
||||
isPositive: boolean;
|
||||
} {
|
||||
const formatted = formatPercent(value, { ...options, signDisplay: 'exceptZero' });
|
||||
const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged';
|
||||
|
||||
return {
|
||||
formatted,
|
||||
direction,
|
||||
isPositive: value > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Date Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface DateFormatOptions {
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
format?: 'short' | 'medium' | 'long' | 'full' | 'relative' | 'iso';
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date
|
||||
*
|
||||
* @example
|
||||
* formatDate(new Date()) // "31/01/2024"
|
||||
* formatDate(new Date(), { format: 'long' }) // "31 de enero de 2024"
|
||||
* formatDate(new Date(), { includeTime: true }) // "31/01/2024 14:30"
|
||||
*/
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
options: DateFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
timezone = DEFAULT_TIMEZONE,
|
||||
format = 'short',
|
||||
includeTime = false,
|
||||
} = options;
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Fecha inválida';
|
||||
}
|
||||
|
||||
// ISO format
|
||||
if (format === 'iso') {
|
||||
return dateObj.toISOString();
|
||||
}
|
||||
|
||||
// Relative format
|
||||
if (format === 'relative') {
|
||||
return formatRelativeTime(dateObj, locale);
|
||||
}
|
||||
|
||||
try {
|
||||
const dateStyle = format === 'short' ? 'short'
|
||||
: format === 'medium' ? 'medium'
|
||||
: format === 'long' ? 'long'
|
||||
: 'full';
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle,
|
||||
timeStyle: includeTime ? 'short' : undefined,
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
return formatter.format(dateObj);
|
||||
} catch {
|
||||
// Fallback
|
||||
const day = dateObj.getDate().toString().padStart(2, '0');
|
||||
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = dateObj.getFullYear();
|
||||
let result = `${day}/${month}/${year}`;
|
||||
|
||||
if (includeTime) {
|
||||
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||
result += ` ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "hace 2 días")
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: Date | string | number,
|
||||
locale: string = DEFAULT_LOCALE
|
||||
): string {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - dateObj.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
|
||||
if (Math.abs(diffSeconds) < 60) {
|
||||
return rtf.format(-diffSeconds, 'second');
|
||||
}
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return rtf.format(-diffMinutes, 'minute');
|
||||
}
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return rtf.format(-diffHours, 'hour');
|
||||
}
|
||||
if (Math.abs(diffDays) < 7) {
|
||||
return rtf.format(-diffDays, 'day');
|
||||
}
|
||||
if (Math.abs(diffWeeks) < 4) {
|
||||
return rtf.format(-diffWeeks, 'week');
|
||||
}
|
||||
if (Math.abs(diffMonths) < 12) {
|
||||
return rtf.format(-diffMonths, 'month');
|
||||
}
|
||||
return rtf.format(-diffYears, 'year');
|
||||
} catch {
|
||||
// Fallback for environments without Intl.RelativeTimeFormat
|
||||
if (diffSeconds < 60) return 'hace un momento';
|
||||
if (diffMinutes < 60) return `hace ${diffMinutes} minuto${diffMinutes !== 1 ? 's' : ''}`;
|
||||
if (diffHours < 24) return `hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`;
|
||||
if (diffDays < 7) return `hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`;
|
||||
if (diffWeeks < 4) return `hace ${diffWeeks} semana${diffWeeks !== 1 ? 's' : ''}`;
|
||||
if (diffMonths < 12) return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`;
|
||||
return `hace ${diffYears} año${diffYears !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date range
|
||||
*
|
||||
* @example
|
||||
* formatDateRange(start, end) // "1 - 31 de enero de 2024"
|
||||
*/
|
||||
export function formatDateRange(
|
||||
start: Date | string | number,
|
||||
end: Date | string | number,
|
||||
options: Omit<DateFormatOptions, 'includeTime'> = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE } = options;
|
||||
|
||||
const startDate = start instanceof Date ? start : new Date(start);
|
||||
const endDate = end instanceof Date ? end : new Date(end);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'long',
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
return formatter.formatRange(startDate, endDate);
|
||||
} catch {
|
||||
// Fallback
|
||||
return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time only
|
||||
*/
|
||||
export function formatTime(
|
||||
date: Date | string | number,
|
||||
options: { locale?: string; timezone?: string; style?: 'short' | 'medium' } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, style = 'short' } = options;
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: style,
|
||||
timeZone: timezone,
|
||||
});
|
||||
return formatter.format(dateObj);
|
||||
} catch {
|
||||
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Number Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface NumberFormatOptions {
|
||||
locale?: string;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with thousand separators
|
||||
*
|
||||
* @example
|
||||
* formatNumber(1234567.89) // "1,234,567.89"
|
||||
* formatNumber(1234567, { notation: 'compact' }) // "1.2M"
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
options: NumberFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
notation = 'standard',
|
||||
signDisplay = 'auto',
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
notation,
|
||||
signDisplay,
|
||||
});
|
||||
return formatter.format(value);
|
||||
} catch {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number in compact notation
|
||||
*
|
||||
* @example
|
||||
* formatCompactNumber(1234) // "1.2K"
|
||||
* formatCompactNumber(1234567) // "1.2M"
|
||||
*/
|
||||
export function formatCompactNumber(
|
||||
value: number,
|
||||
options: Omit<NumberFormatOptions, 'notation'> = {}
|
||||
): string {
|
||||
return formatNumber(value, { ...options, notation: 'compact' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*
|
||||
* @example
|
||||
* formatBytes(1024) // "1 KB"
|
||||
* formatBytes(1536) // "1.5 KB"
|
||||
* formatBytes(1048576) // "1 MB"
|
||||
*/
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
options: { locale?: string; decimals?: number } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, decimals = 1 } = options;
|
||||
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
return `${formatter.format(value)} ${sizes[i]}`;
|
||||
} catch {
|
||||
return `${value.toFixed(decimals)} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*
|
||||
* @example
|
||||
* truncate("Hello World", 8) // "Hello..."
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number, suffix = '...'): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - suffix.length).trim() + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize first letter
|
||||
*
|
||||
* @example
|
||||
* capitalize("hello world") // "Hello world"
|
||||
*/
|
||||
export function capitalize(text: string): string {
|
||||
if (!text) return '';
|
||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title case
|
||||
*
|
||||
* @example
|
||||
* titleCase("hello world") // "Hello World"
|
||||
*/
|
||||
export function titleCase(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RFC for display (with spaces)
|
||||
*
|
||||
* @example
|
||||
* formatRFC("XAXX010101000") // "XAXX 010101 000"
|
||||
*/
|
||||
export function formatRFC(rfc: string): string {
|
||||
const cleaned = rfc.replace(/\s/g, '').toUpperCase();
|
||||
if (cleaned.length === 12) {
|
||||
// Persona física
|
||||
return `${cleaned.slice(0, 4)} ${cleaned.slice(4, 10)} ${cleaned.slice(10)}`;
|
||||
}
|
||||
if (cleaned.length === 13) {
|
||||
// Persona moral
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 9)} ${cleaned.slice(9)}`;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive data
|
||||
*
|
||||
* @example
|
||||
* maskString("1234567890", 4) // "******7890"
|
||||
* maskString("email@example.com", 3, { maskChar: '*', type: 'email' }) // "ema***@example.com"
|
||||
*/
|
||||
export function maskString(
|
||||
value: string,
|
||||
visibleChars: number = 4,
|
||||
options: { maskChar?: string; position?: 'start' | 'end' } = {}
|
||||
): string {
|
||||
const { maskChar = '*', position = 'end' } = options;
|
||||
|
||||
if (value.length <= visibleChars) return value;
|
||||
|
||||
const maskLength = value.length - visibleChars;
|
||||
const mask = maskChar.repeat(maskLength);
|
||||
|
||||
if (position === 'start') {
|
||||
return value.slice(0, visibleChars) + mask;
|
||||
}
|
||||
return mask + value.slice(-visibleChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CLABE for display
|
||||
*
|
||||
* @example
|
||||
* formatCLABE("123456789012345678") // "123 456 789012345678"
|
||||
*/
|
||||
export function formatCLABE(clabe: string): string {
|
||||
const cleaned = clabe.replace(/\s/g, '');
|
||||
if (cleaned.length !== 18) return clabe;
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)} ${cleaned.slice(6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number (Mexican format)
|
||||
*
|
||||
* @example
|
||||
* formatPhone("5512345678") // "(55) 1234-5678"
|
||||
*/
|
||||
export function formatPhone(phone: string): string {
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
if (cleaned.length === 12 && cleaned.startsWith('52')) {
|
||||
const national = cleaned.slice(2);
|
||||
return `+52 (${national.slice(0, 2)}) ${national.slice(2, 6)}-${national.slice(6)}`;
|
||||
}
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pluralization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple Spanish pluralization
|
||||
*
|
||||
* @example
|
||||
* pluralize(1, 'factura', 'facturas') // "1 factura"
|
||||
* pluralize(5, 'factura', 'facturas') // "5 facturas"
|
||||
*/
|
||||
export function pluralize(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string
|
||||
): string {
|
||||
const word = count === 1 ? singular : plural;
|
||||
return `${formatNumber(count)} ${word}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list of items with proper grammar
|
||||
*
|
||||
* @example
|
||||
* formatList(['a', 'b', 'c']) // "a, b y c"
|
||||
* formatList(['a', 'b']) // "a y b"
|
||||
* formatList(['a']) // "a"
|
||||
*/
|
||||
export function formatList(
|
||||
items: string[],
|
||||
options: { locale?: string; type?: 'conjunction' | 'disjunction' } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, type = 'conjunction' } = options;
|
||||
|
||||
if (items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
|
||||
try {
|
||||
const formatter = new Intl.ListFormat(locale, {
|
||||
style: 'long',
|
||||
type,
|
||||
});
|
||||
return formatter.format(items);
|
||||
} catch {
|
||||
// Fallback
|
||||
const connector = type === 'conjunction' ? 'y' : 'o';
|
||||
if (items.length === 2) {
|
||||
return `${items[0]} ${connector} ${items[1]}`;
|
||||
}
|
||||
return `${items.slice(0, -1).join(', ')} ${connector} ${items[items.length - 1]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user