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>
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— adddatabase_namecolumn, removeschema_nameusers— no changesrefresh_tokens— no changesfiel_credentials— no changes
New tables:
subscriptions— MercadoPago subscription trackingpayments— 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 uniqueiva_mensualisr_mensualalertascalendario_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 connectionsidleTimeoutMillis: 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)
- Admin creates tenant via UI → POST
/api/tenants/ - Insert record in
horux360.tenantswithdatabase_name - Execute
CREATE DATABASE horux_<rfc> - Connect to new DB, create all tables + indexes
- Create admin user in
horux360.userslinked to tenant - Send welcome email with temporary credentials
- 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_KEYenvironment 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.jsonis NOT encrypted (contains only non-sensitive info: serial number, validity dates)
Upload flow
- Client navigates to
/configuracion/sat - Uploads
.cer+.keyfiles + enters password - API validates the certificate (checks it's a valid FIEL, not expired)
- Encrypts and stores in both filesystem and database
- 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
- Admin creates tenant and assigns plan
- Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
- Link is sent to client via email
- Client pays → MercadoPago sends webhook
- 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 intopayments, update subscription periodsubscription_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