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