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:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

View 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}$/,
};

View 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>;

View 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>;

View 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';

View 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>;

View 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';

View 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;
}

View 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>;
}

View 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;
}

View 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;
}

View 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';

View 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]}`;
}
}