From c44e7cea34db765bd88305c9ee8198c38d7a7233 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 15 Mar 2026 22:46:12 +0000 Subject: [PATCH] docs: add SaaS transformation design spec Complete design document covering: - Database-per-tenant architecture (NDA compliance) - FIEL dual storage (filesystem + DB, encrypted) - MercadoPago subscription payments - Transactional emails via Gmail SMTP - Production deployment (Nginx, PM2, SSL, backups) - Plan enforcement and feature gating Co-Authored-By: Claude Opus 4.6 --- .../2026-03-15-saas-transformation-design.md | 700 ++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-saas-transformation-design.md diff --git a/docs/superpowers/specs/2026-03-15-saas-transformation-design.md b/docs/superpowers/specs/2026-03-15-saas-transformation-design.md new file mode 100644 index 0000000..48946fb --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-saas-transformation-design.md @@ -0,0 +1,700 @@ +# 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