Files
Horux360/docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Consultoria AS c44e7cea34 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 <noreply@anthropic.com>
2026-03-15 22:46:12 +00:00

21 KiB

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_normalized>

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

class TenantConnectionManager {
  private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
  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<string>;

  // Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
  deprovisionDatabase(databaseName: string): Promise<void>;

  // 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.

// 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_<rfc>
  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

node scripts/decrypt-fiel.js --rfc CAS2408138W2
  • Decrypts files to /tmp/horux-fiel-<rfc>/
  • 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

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=<mercadopago_access_token>
MP_WEBHOOK_SECRET=<webhook_signature_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=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>

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.

// services/email/templates/welcome.ts
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
  return `<!DOCTYPE html>...`;
}

Each template:

  • Responsive HTML email (inline CSS)
  • Horux360 branding (logo, colors)
  • Plain text fallback

Email service

class EmailService {
  sendWelcome(to: string, data: WelcomeData): Promise<void>;
  sendFielNotification(data: FielNotificationData): Promise<void>;
  sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
  sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
  sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
  sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
}

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:

cd apps/api && pnpm build    # tsc → dist/
pnpm start                    # node dist/index.js

Web:

cd apps/web && pnpm build    # next build → .next/
pnpm start                    # next start (optimized server)

PM2 configuration

// 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

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.

certbot --nginx -d horux360.consultoria-as.com

Firewall

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:

#!/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:<strong_password>@localhost:5432/horux360?schema=public
JWT_SECRET=<cryptographically_secure_random_64_chars>
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=30d
CORS_ORIGIN=https://horux360.consultoria-as.com
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
MP_ACCESS_TOKEN=<mercadopago_production_token>
MP_WEBHOOK_SECRET=<webhook_secret>
MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
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

// 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:

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):

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:

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:

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/<rfc>/   │    │ @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