# Horux360 SaaS Transformation — Design Spec **Date:** 2026-03-15 **Status:** Approved **Author:** Carlos Horux + Claude ## Overview Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212). **Target scale:** 10-50 clients within 6 months. **Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup. --- ## Section 1: Database-Per-Tenant Architecture ### Rationale Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides: - Independent backup/restore per client - No risk of cross-tenant data leakage - Each DB can be moved to a different server if needed ### Structure ``` PostgreSQL Server (max_connections: 300) ├── horux360 ← Central DB (Prisma-managed) ├── horux_cas2408138w2 ← Client DB (raw SQL) ├── horux_roem691011ez4 ← Client DB └── ... ``` ### Central DB (`horux360`) — Prisma-managed tables Existing tables (modified): - `tenants` — add `database_name` column, remove `schema_name` - `users` — no changes - `refresh_tokens` — no changes - `fiel_credentials` — no changes New tables: - `subscriptions` — MercadoPago subscription tracking - `payments` — payment history ### Client DB naming Formula: `horux_` ``` RFC "CAS2408138W2" → horux_cas2408138w2 RFC "TPR840604D98" → horux_tpr840604d98 ``` ### Client DB tables (created via raw SQL) Each client database contains these tables (no schema prefix, direct `public` schema): - `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique - `iva_mensual` - `isr_mensual` - `alertas` - `calendario_fiscal` ### TenantConnectionManager ```typescript class TenantConnectionManager { private pools: Map; private cleanupInterval: NodeJS.Timer; // Get or create a pool for a tenant getPool(tenantId: string, databaseName: string): pg.Pool; // Create a new tenant database with all tables and indexes provisionDatabase(rfc: string): Promise; // Drop a tenant database (soft-delete: rename to horux_deleted__) deprovisionDatabase(databaseName: string): Promise; // Cleanup idle pools (called every 60s, removes pools idle > 5min) private cleanupIdlePools(): void; } ``` Pool configuration per tenant: - `max`: 5 connections - `idleTimeoutMillis`: 300000 (5 min) - `connectionTimeoutMillis`: 10000 (10 sec) ### Tenant middleware change Current: Sets `search_path` on a shared connection. New: Returns a dedicated pool connected to the tenant's own database. ```typescript // Before req.tenantSchema = schema; await pool.query(`SET search_path TO "${schema}", public`); // After req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName); ``` All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names. ### Provisioning flow (new client) 1. Admin creates tenant via UI → POST `/api/tenants/` 2. Insert record in `horux360.tenants` with `database_name` 3. Execute `CREATE DATABASE horux_` 4. Connect to new DB, create all tables + indexes 5. Create admin user in `horux360.users` linked to tenant 6. Send welcome email with temporary credentials 7. Generate MercadoPago subscription link ### PostgreSQL tuning ``` max_connections = 300 shared_buffers = 4GB work_mem = 16MB effective_cache_size = 16GB maintenance_work_mem = 512MB ``` ### Server disk Expand from 29 GB to 100 GB to accommodate: - 25-50 client databases (~2-3 GB total) - Daily backups with 7-day retention (~15 GB) - FIEL encrypted files (<100 MB) - Logs, builds, OS (~10 GB) --- ## Section 2: SAT Credential Storage (FIEL) ### Dual storage strategy When a client uploads their FIEL (.cer + .key + password): **A. Filesystem (for manual linking):** ``` /var/horux/fiel/ ├── CAS2408138W2/ │ ├── certificate.cer.enc ← AES-256-GCM encrypted │ ├── private_key.key.enc ← AES-256-GCM encrypted │ └── metadata.json ← serial, validity dates, upload date └── ROEM691011EZ4/ ├── certificate.cer.enc ├── private_key.key.enc └── metadata.json ``` **B. Central DB (`fiel_credentials` table):** - Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag` - No changes needed to the table structure ### Encryption - Algorithm: AES-256-GCM - Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets) - Each file gets its own IV (initialization vector) - Password is encrypted, never stored in plaintext ### Manual decryption CLI ```bash node scripts/decrypt-fiel.js --rfc CAS2408138W2 ``` - Decrypts files to `/tmp/horux-fiel-/` - Files auto-delete after 30 minutes (via setTimeout or tmpwatch) - Requires SSH access to server ### Security - `/var/horux/fiel/` permissions: `700` (root only) - Encrypted files are useless without `FIEL_ENCRYPTION_KEY` - `metadata.json` is NOT encrypted (contains only non-sensitive info: serial number, validity dates) ### Upload flow 1. Client navigates to `/configuracion/sat` 2. Uploads `.cer` + `.key` files + enters password 3. API validates the certificate (checks it's a valid FIEL, not expired) 4. Encrypts and stores in both filesystem and database 5. Sends notification email to admin team: "Cliente X subió su FIEL" --- ## Section 3: Payment System (MercadoPago) ### Integration approach Using MercadoPago's **Preapproval (Subscription)** API for recurring payments. ### New tables in central DB ```sql CREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), plan VARCHAR(20) NOT NULL, mp_preapproval_id VARCHAR(100), status VARCHAR(20) NOT NULL DEFAULT 'pending', -- status: pending | authorized | paused | cancelled amount DECIMAL(10,2) NOT NULL, frequency VARCHAR(10) NOT NULL DEFAULT 'monthly', -- frequency: monthly | yearly current_period_start TIMESTAMP, current_period_end TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE TABLE payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), subscription_id UUID REFERENCES subscriptions(id), mp_payment_id VARCHAR(100), amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', -- status: approved | pending | rejected | refunded payment_method VARCHAR(50), paid_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); ``` ### Plans and pricing Defined in `packages/shared/src/constants/plans.ts` (update existing): | Plan | Monthly price (MXN) | CFDIs | Users | Features | |------|---------------------|-------|-------|----------| | starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr | | business | Configurable | 500 | 3 | + reportes, alertas, calendario | | professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting | | enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa | Prices are configured from admin panel, not hardcoded. ### Subscription flow 1. Admin creates tenant and assigns plan 2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval 3. Link is sent to client via email 4. Client pays → MercadoPago sends webhook 5. System activates subscription, records payment ### Webhook endpoint `POST /api/webhooks/mercadopago` (public, no auth) Validates webhook signature using `x-signature` header and `x-request-id`. Events handled: - `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period - `subscription_preapproval` → update subscription status (authorized, paused, cancelled) On payment failure or subscription cancellation: - Mark tenant `active = false` - Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.) ### Admin panel additions - View subscription status per client (active, amount, next billing date) - Generate payment link button - "Mark as paid manually" button (for bank transfer payments) - Payment history per client ### Client panel additions - New section in `/configuracion`: "Mi suscripción" - Shows: current plan, next billing date, payment history - Client cannot change plan themselves (admin does it) ### Environment variables ``` MP_ACCESS_TOKEN= MP_WEBHOOK_SECRET= MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago ``` --- ## Section 4: Transactional Emails ### Transport Nodemailer with Gmail SMTP (Google Workspace). ``` SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=@horuxfin.com SMTP_PASS= SMTP_FROM=Horux360 ``` Requires generating an App Password in Google Workspace admin. ### Email types | Event | Recipient | Subject | |-------|-----------|---------| | Client registered | Client | Bienvenido a Horux360 | | FIEL uploaded | Admin team | [Cliente] subió su FIEL | | Payment received | Client | Confirmación de pago - Horux360 | | Payment failed | Client + Admin | Problema con tu pago - Horux360 | | Subscription expiring | Client | Tu suscripción vence en 5 días | | Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 | ### Template approach HTML templates as TypeScript template literal functions. No external template engine. ```typescript // services/email/templates/welcome.ts export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string { return `...`; } ``` Each template: - Responsive HTML email (inline CSS) - Horux360 branding (logo, colors) - Plain text fallback ### Email service ```typescript class EmailService { sendWelcome(to: string, data: WelcomeData): Promise; sendFielNotification(data: FielNotificationData): Promise; sendPaymentConfirmation(to: string, data: PaymentData): Promise; sendPaymentFailed(to: string, data: PaymentData): Promise; sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise; sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise; } ``` ### Limits Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits. --- ## Section 5: Production Deployment ### Build pipeline **API:** ```bash cd apps/api && pnpm build # tsc → dist/ pnpm start # node dist/index.js ``` **Web:** ```bash cd apps/web && pnpm build # next build → .next/ pnpm start # next start (optimized server) ``` ### PM2 configuration ```javascript // ecosystem.config.js module.exports = { apps: [ { name: 'horux-api', script: 'dist/index.js', cwd: '/root/Horux/apps/api', instances: 2, exec_mode: 'cluster', env: { NODE_ENV: 'production' } }, { name: 'horux-web', script: 'node_modules/.bin/next', args: 'start', cwd: '/root/Horux/apps/web', instances: 1, exec_mode: 'fork', env: { NODE_ENV: 'production' } } ] }; ``` Auto-restart on crash. Log rotation via `pm2-logrotate`. ### Nginx reverse proxy ```nginx server { listen 80; server_name horux360.consultoria-as.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name horux360.consultoria-as.com; ssl_certificate /etc/letsencrypt/live/horux360.consultoria-as.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/horux360.consultoria-as.com/privkey.pem; # Security headers add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; # Gzip gzip on; gzip_types text/plain application/json application/javascript text/css; # Rate limiting for public endpoints location /api/auth/ { limit_req zone=auth burst=5 nodelay; proxy_pass http://127.0.0.1:4000; } location /api/webhooks/ { limit_req zone=webhooks burst=10 nodelay; proxy_pass http://127.0.0.1:4000; } # API location /api/ { proxy_pass http://127.0.0.1:4000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 1G; # For bulk XML uploads } # Next.js location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### SSL Let's Encrypt with certbot. Auto-renewal via cron. ```bash certbot --nginx -d horux360.consultoria-as.com ``` ### Firewall ```bash ufw allow 22/tcp # SSH ufw allow 80/tcp # HTTP (redirect to HTTPS) ufw allow 443/tcp # HTTPS ufw enable ``` PostgreSQL only on localhost (no external access). ### Backups Cron job at 2:00 AM daily: ```bash #!/bin/bash # /var/horux/scripts/backup.sh BACKUP_DIR=/var/horux/backups DATE=$(date +%Y-%m-%d) # Backup central DB pg_dump -h localhost -U postgres horux360 | gzip > $BACKUP_DIR/horux360_$DATE.sql.gz # Backup each tenant DB for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE active = true"); do pg_dump -h localhost -U postgres $db | gzip > $BACKUP_DIR/${db}_${DATE}.sql.gz done # Remove backups older than 7 days find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete # Keep weekly backups (Sundays) for 4 weeks # (daily cleanup skips files from Sundays in the last 28 days) ``` ### Environment variables (production) ``` NODE_ENV=production PORT=4000 DATABASE_URL=postgresql://postgres:@localhost:5432/horux360?schema=public JWT_SECRET= JWT_EXPIRES_IN=24h JWT_REFRESH_EXPIRES_IN=30d CORS_ORIGIN=https://horux360.consultoria-as.com FIEL_ENCRYPTION_KEY= MP_ACCESS_TOKEN= MP_WEBHOOK_SECRET= MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=@horuxfin.com SMTP_PASS= SMTP_FROM=Horux360 ADMIN_EMAIL=admin@horuxfin.com ``` ### SAT cron Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change. --- ## Section 6: Plan Enforcement & Feature Gating ### Enforcement middleware ```typescript // middleware: checkPlanLimits async function checkPlanLimits(req, res, next) { const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min const subscription = await getActiveSubscription(tenant.id); // Check subscription is active if (!subscription || subscription.status !== 'authorized') { // Allow read-only access if (req.method !== 'GET') { return res.status(403).json({ message: 'Suscripción inactiva. Contacta soporte para reactivar.' }); } } next(); } ``` ### CFDI limit check Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`: ```typescript async function checkCfdiLimit(req, res, next) { const tenant = await getTenantWithCache(req.user.tenantId); if (tenant.cfdiLimit === -1) return next(); // unlimited const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min const newCount = Array.isArray(req.body) ? req.body.length : 1; if (currentCount + newCount > tenant.cfdiLimit) { return res.status(403).json({ message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.` }); } next(); } ``` ### User limit check Applied on `POST /api/usuarios/invite` (already partially exists): ```typescript const userCount = await getUserCountForTenant(tenantId); if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) { return res.status(403).json({ message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).` }); } ``` ### Feature gating Applied per route using the existing `hasFeature()` function from shared: ```typescript function requireFeature(feature: string) { return async (req, res, next) => { const tenant = await getTenantWithCache(req.user.tenantId); if (!hasFeature(tenant.plan, feature)) { return res.status(403).json({ message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.' }); } next(); }; } // Usage in routes: router.get('/reportes', authenticate, requireFeature('reportes'), reportesController); router.get('/alertas', authenticate, requireFeature('alertas'), alertasController); ``` ### Feature matrix | Feature key | Starter | Business | Professional | Enterprise | |-------------|---------|----------|-------------|------------| | dashboard | Yes | Yes | Yes | Yes | | cfdi_basic | Yes | Yes | Yes | Yes | | iva_isr | Yes | Yes | Yes | Yes | | reportes | No | Yes | Yes | Yes | | alertas | No | Yes | Yes | Yes | | calendario | No | Yes | Yes | Yes | | xml_sat | No | No | Yes | Yes | | conciliacion | No | No | Yes | Yes | | forecasting | No | No | Yes | Yes | | multi_empresa | No | No | No | Yes | | api_externa | No | No | No | Yes | ### Frontend feature gating The sidebar/navigation hides menu items based on plan: ```typescript const tenant = useTenantInfo(); // new hook const menuItems = allMenuItems.filter(item => !item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature) ); ``` Pages also show an "upgrade" message if accessed directly via URL without the required plan. ### Caching Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request. --- ## Architecture Diagram ``` ┌─────────────────────┐ │ Nginx (443/80) │ │ SSL + Rate Limit │ └──────────┬──────────┘ │ ┌──────────────┼──────────────┐ │ │ │ ┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐ │ Next.js │ │ Express │ │ Webhook │ │ :3000 │ │ API x2 │ │ Handler │ │ (fork) │ │ :4000 │ │ (no auth) │ └───────────┘ │ (cluster)│ └──────┬──────┘ └────┬────┘ │ │ │ ┌─────────▼──────────┐ │ │ TenantConnection │ │ │ Manager │ │ │ (pool per tenant) │ │ └─────────┬──────────┘ │ │ │ ┌──────────────────┼──────┐ │ │ │ │ │ ┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │ │ horux360 │ │horux_ │ │horux│ │ │ (central) │ │client1 │ │_... │ │ │ │ └────────┘ └─────┘ │ │ tenants │ │ │ users │◄────────────────────────┘ │ subs │ (webhook updates) │ payments │ └───────────┘ ┌───────────────┐ ┌─────────────┐ │ /var/horux/ │ │ Gmail SMTP │ │ fiel// │ │ @horuxfin │ │ backups/ │ └─────────────┘ └───────────────┘ ┌───────────────┐ │ MercadoPago │ │ Preapproval │ │ API │ └───────────────┘ ``` --- ## Out of Scope - Landing page (already exists separately) - Self-service registration (clients are registered manually by admin) - Automatic SAT connector (manual FIEL linking for now) - Plan change by client (admin handles upgrades/downgrades) - Mobile app - Multi-region deployment