Files
Horux360/docs/superpowers/plans/2026-03-15-saas-transformation.md
Consultoria AS 22543589c3 docs: add SaaS transformation implementation plan
28 tasks across 8 chunks:
- Chunk 1: Core infrastructure (DB-per-tenant, env, JWT, pools)
- Chunk 2: FIEL dual storage + encryption fix
- Chunk 3: Email service (Nodemailer + Gmail SMTP)
- Chunk 4: MercadoPago payments (subscriptions, webhooks)
- Chunk 5: Plan enforcement (limits, feature gates)
- Chunk 6: Tenant provisioning integration
- Chunk 7: Production deployment (PM2, Nginx, SSL, backups)
- Chunk 8: Frontend updates (subscription UI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:04:48 +00:00

67 KiB
Raw Blame History

Horux360 SaaS Transformation — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Transform Horux360 from a schema-per-tenant internal tool to a database-per-tenant SaaS with MercadoPago payments, transactional emails, plan enforcement, and production deployment.

Architecture: Monolith with dynamic connection pooling. One Express API (PM2 cluster ×2) manages connections to N PostgreSQL databases via TenantConnectionManager. Central DB (horux360) holds users/tenants/subscriptions. Each client gets their own isolated database (horux_<rfc>). Nginx reverse proxy with SSL terminates HTTPS.

Tech Stack: Express 4, PostgreSQL (pg pools), Prisma 5 (central DB only), Next.js 14, MercadoPago SDK, Nodemailer, PM2, Nginx, Let's Encrypt.

Spec: docs/superpowers/specs/2026-03-15-saas-transformation-design.md


File Structure

New files to create:

apps/api/src/
├── config/
│   └── database.ts                    # TenantConnectionManager class
├── services/
│   ├── email/
│   │   ├── email.service.ts           # EmailService (Nodemailer transport)
│   │   └── templates/
│   │       ├── base.ts                # Shared HTML base layout
│   │       ├── welcome.ts             # Welcome email template
│   │       ├── fiel-notification.ts   # FIEL upload notification
│   │       ├── payment-confirmed.ts   # Payment confirmation
│   │       ├── payment-failed.ts      # Payment failure alert
│   │       ├── subscription-expiring.ts
│   │       └── subscription-cancelled.ts
│   └── payment/
│       ├── mercadopago.service.ts     # MercadoPago Preapproval integration
│       └── subscription.service.ts    # Subscription lifecycle management
├── controllers/
│   ├── webhook.controller.ts          # MercadoPago webhook handler
│   └── subscription.controller.ts     # Subscription admin endpoints
├── routes/
│   ├── webhook.routes.ts              # Public webhook routes (no auth)
│   └── subscription.routes.ts         # Subscription management routes
├── middlewares/
│   ├── plan-limits.middleware.ts       # checkPlanLimits + checkCfdiLimit
│   └── feature-gate.middleware.ts     # requireFeature middleware
scripts/
├── decrypt-fiel.ts                    # CLI to decrypt FIEL files
├── backup.sh                          # Daily backup script
└── provision-tenant-db.ts             # Standalone DB provisioning (for testing)

deploy/
├── nginx/
│   └── horux360.conf                  # Nginx site config
└── pm2/
    └── ecosystem.config.js            # Production PM2 config

Files to modify:

apps/api/src/config/env.ts             # Add FIEL_ENCRYPTION_KEY, MP_*, SMTP_* vars
apps/api/prisma/schema.prisma          # Replace schemaName→databaseName, add Subscription+Payment models
apps/api/src/services/auth.service.ts  # JWT payload: schemaName→databaseName
apps/api/src/services/tenants.service.ts  # Rewrite provisioning for DB-per-tenant
apps/api/src/services/sat/sat-crypto.service.ts  # Use FIEL_ENCRYPTION_KEY, per-component IV
apps/api/src/services/fiel.service.ts  # Add filesystem storage, admin notification
apps/api/src/middlewares/tenant.middleware.ts  # Use TenantConnectionManager pools
apps/api/src/middlewares/auth.middleware.ts    # Update JWTPayload references
apps/api/src/utils/schema-manager.ts   # Rewrite as database-manager.ts (CREATE DATABASE)
apps/api/src/app.ts                    # Register new routes (webhooks, subscriptions)
apps/api/src/index.ts                  # Init TenantConnectionManager, graceful shutdown

packages/shared/src/types/auth.ts      # schemaName→databaseName in JWTPayload
packages/shared/src/types/tenant.ts    # schemaName→databaseName, add Subscription type

apps/web/stores/auth-store.ts          # Update UserInfo references if needed
apps/web/lib/api/client.ts             # Update tenant header logic for databaseName

# All tenant service files (cfdi, dashboard, impuestos, etc.) — change from
# schema-prefixed queries to using req.tenantPool directly
apps/api/src/services/cfdi.service.ts
apps/api/src/services/dashboard.service.ts
apps/api/src/services/impuestos.service.ts
apps/api/src/services/alertas.service.ts
apps/api/src/services/calendario.service.ts
apps/api/src/services/reportes.service.ts
apps/api/src/services/export.service.ts

Chunk 1: Core Infrastructure (DB-per-tenant + env + JWT)

This chunk establishes the database-per-tenant architecture. Everything else depends on this.

Task 1: Update environment configuration

Files:

  • Modify: apps/api/src/config/env.ts

  • Step 1: Add new environment variables to Zod schema

// apps/api/src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().default('4000'),
  DATABASE_URL: z.string(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('15m'),
  JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
  CORS_ORIGIN: z.string().default('http://localhost:3000'),

  // FIEL encryption (separate from JWT)
  FIEL_ENCRYPTION_KEY: z.string().min(32).default('dev-fiel-encryption-key-min-32-chars!!'),

  // MercadoPago
  MP_ACCESS_TOKEN: z.string().optional(),
  MP_WEBHOOK_SECRET: z.string().optional(),
  MP_NOTIFICATION_URL: z.string().optional(),

  // SMTP (Gmail)
  SMTP_HOST: z.string().default('smtp.gmail.com'),
  SMTP_PORT: z.string().default('587'),
  SMTP_USER: z.string().optional(),
  SMTP_PASS: z.string().optional(),
  SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),

  // Admin notification email
  ADMIN_EMAIL: z.string().default('admin@horuxfin.com'),

  // FIEL filesystem storage path
  FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
});
  • Step 2: Update .env file with new variables (development defaults)

Add to apps/api/.env:

FIEL_ENCRYPTION_KEY=dev-fiel-encryption-key-min-32-chars-long!!
FIEL_STORAGE_PATH=/var/horux/fiel
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
ADMIN_EMAIL=carlos@horuxfin.com
  • Step 3: Commit
git add apps/api/src/config/env.ts apps/api/.env
git commit -m "feat: add env vars for FIEL encryption, MercadoPago, SMTP, and admin email"

Task 2: Update Prisma schema (databaseName, Subscription, Payment)

Files:

  • Modify: apps/api/prisma/schema.prisma

  • Step 1: Update Tenant model — replace schemaName with databaseName

In schema.prisma, change the Tenant model:

model Tenant {
  id           String    @id @default(uuid())
  nombre       String
  rfc          String    @unique
  plan         Plan      @default(starter)
  databaseName String    @unique @map("database_name")
  cfdiLimit    Int       @default(100) @map("cfdi_limit")
  usersLimit   Int       @default(1) @map("users_limit")
  active       Boolean   @default(true)
  createdAt    DateTime  @default(now()) @map("created_at")
  expiresAt    DateTime? @map("expires_at")

  users           User[]
  fielCredential  FielCredential?
  satSyncJobs     SatSyncJob[]
  subscriptions   Subscription[]
  payments        Payment[]

  @@map("tenants")
}
  • Step 2: Update FielCredential model — add per-component encryption columns
model FielCredential {
  id                   String   @id @default(uuid())
  tenantId             String   @unique @map("tenant_id")
  rfc                  String
  cerData              Bytes    @map("cer_data")
  keyData              Bytes    @map("key_data")
  keyPasswordEncrypted Bytes    @map("key_password_encrypted")
  cerIv                Bytes    @map("cer_iv")
  cerTag               Bytes    @map("cer_tag")
  keyIv                Bytes    @map("key_iv")
  keyTag               Bytes    @map("key_tag")
  passwordIv           Bytes    @map("password_iv")
  passwordTag          Bytes    @map("password_tag")
  serialNumber         String?  @map("serial_number")
  validFrom            DateTime @map("valid_from")
  validUntil           DateTime @map("valid_until")
  isActive             Boolean  @default(true) @map("is_active")
  createdAt            DateTime @default(now()) @map("created_at")
  updatedAt            DateTime @updatedAt @map("updated_at")

  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@map("fiel_credentials")
}
  • Step 3: Add Subscription and Payment models
model Subscription {
  id                 String    @id @default(uuid())
  tenantId           String    @map("tenant_id")
  plan               Plan
  mpPreapprovalId    String?   @map("mp_preapproval_id")
  status             String    @default("pending")
  amount             Decimal   @db.Decimal(10, 2)
  frequency          String    @default("monthly")
  currentPeriodStart DateTime? @map("current_period_start")
  currentPeriodEnd   DateTime? @map("current_period_end")
  createdAt          DateTime  @default(now()) @map("created_at")
  updatedAt          DateTime  @updatedAt @map("updated_at")

  tenant   Tenant    @relation(fields: [tenantId], references: [id])
  payments Payment[]

  @@index([tenantId])
  @@index([status])
  @@map("subscriptions")
}

model Payment {
  id             String    @id @default(uuid())
  tenantId       String    @map("tenant_id")
  subscriptionId String?   @map("subscription_id")
  mpPaymentId    String?   @map("mp_payment_id")
  amount         Decimal   @db.Decimal(10, 2)
  status         String    @default("pending")
  paymentMethod  String?   @map("payment_method")
  paidAt         DateTime? @map("paid_at")
  createdAt      DateTime  @default(now()) @map("created_at")

  tenant       Tenant        @relation(fields: [tenantId], references: [id])
  subscription Subscription? @relation(fields: [subscriptionId], references: [id])

  @@index([tenantId])
  @@index([subscriptionId])
  @@map("payments")
}
  • Step 4: Generate and apply migration
cd /root/Horux/apps/api
npx prisma migrate dev --name saas-db-per-tenant
npx prisma generate

Expected: Migration creates new columns/tables. Old schema_name column is renamed to database_name. New subscriptions and payments tables are created.

Note: This migration will need to handle the rename from schema_name to database_name. If Prisma auto-detects as a rename, it will migrate the data. If not, we need a custom migration SQL to rename the column and update existing values from tenant_xxx to horux_xxx format.

  • Step 5: Commit
git add apps/api/prisma/
git commit -m "feat: update Prisma schema for DB-per-tenant, subscriptions, and payments"

Task 3: Update shared types (JWT payload, Tenant)

Files:

  • Modify: packages/shared/src/types/auth.ts

  • Modify: packages/shared/src/types/tenant.ts

  • Step 1: Update JWTPayload in auth.ts

Replace schemaName with databaseName:

export interface JWTPayload {
  userId: string;
  email: string;
  role: Role;
  tenantId: string;
  databaseName: string;  // was: schemaName
  iat?: number;
  exp?: number;
}
  • Step 2: Update Tenant interface in tenant.ts
export interface Tenant {
  id: string;
  nombre: string;
  rfc: string;
  plan: Plan;
  databaseName: string;  // was: schemaName
  cfdiLimit: number;
  usersLimit: number;
  active: boolean;
  createdAt: string;
  expiresAt?: string;
}
  • Step 3: Add Subscription and Payment types to tenant.ts
export interface Subscription {
  id: string;
  tenantId: string;
  plan: Plan;
  mpPreapprovalId?: string;
  status: 'pending' | 'authorized' | 'paused' | 'cancelled';
  amount: number;
  frequency: 'monthly' | 'yearly';
  currentPeriodStart?: string;
  currentPeriodEnd?: string;
  createdAt: string;
  updatedAt: string;
}

export interface Payment {
  id: string;
  tenantId: string;
  subscriptionId?: string;
  mpPaymentId?: string;
  amount: number;
  status: 'approved' | 'pending' | 'rejected' | 'refunded';
  paymentMethod?: string;
  paidAt?: string;
  createdAt: string;
}
  • Step 4: Commit
git add packages/shared/
git commit -m "feat: update shared types for databaseName and add Subscription/Payment types"

Task 4: Create TenantConnectionManager

Files:

  • Create: apps/api/src/config/database.ts

  • Step 1: Install pg dependency

cd /root/Horux/apps/api && pnpm add pg && pnpm add -D @types/pg
  • Step 2: Create TenantConnectionManager class
// apps/api/src/config/database.ts
import { Pool, PoolConfig } from 'pg';
import { env } from './env';

interface PoolEntry {
  pool: Pool;
  lastAccess: Date;
}

// Parse the central DATABASE_URL to extract host, port, user, password
function parseDatabaseUrl(url: string) {
  const parsed = new URL(url);
  return {
    host: parsed.hostname,
    port: parseInt(parsed.port || '5432'),
    user: parsed.username,
    password: parsed.password,
  };
}

class TenantConnectionManager {
  private pools: Map<string, PoolEntry> = new Map();
  private cleanupInterval: NodeJS.Timeout | null = null;
  private dbConfig: { host: string; port: number; user: string; password: string };

  constructor() {
    this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
    // Cleanup idle pools every 60 seconds
    this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
  }

  /**
   * Get or create a connection pool for a tenant's database.
   */
  getPool(tenantId: string, databaseName: string): Pool {
    const entry = this.pools.get(tenantId);
    if (entry) {
      entry.lastAccess = new Date();
      return entry.pool;
    }

    const poolConfig: PoolConfig = {
      host: this.dbConfig.host,
      port: this.dbConfig.port,
      user: this.dbConfig.user,
      password: this.dbConfig.password,
      database: databaseName,
      max: 3,                          // 3 per pool × 2 PM2 workers = 6/tenant
      idleTimeoutMillis: 300_000,      // 5 min
      connectionTimeoutMillis: 10_000, // 10 sec
    };

    const pool = new Pool(poolConfig);

    pool.on('error', (err) => {
      console.error(`Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
    });

    this.pools.set(tenantId, { pool, lastAccess: new Date() });
    return pool;
  }

  /**
   * Create a new database for a tenant with all required tables and indexes.
   */
  async provisionDatabase(rfc: string): Promise<string> {
    const databaseName = `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;

    // Connect to default 'postgres' DB to create new database
    const adminPool = new Pool({
      ...this.dbConfig,
      database: 'postgres',
      max: 1,
    });

    try {
      // Check if database already exists
      const exists = await adminPool.query(
        `SELECT 1 FROM pg_database WHERE datname = $1`,
        [databaseName]
      );

      if (exists.rows.length > 0) {
        throw new Error(`Database ${databaseName} already exists`);
      }

      // CREATE DATABASE cannot run inside a transaction
      await adminPool.query(`CREATE DATABASE "${databaseName}"`);

      // Connect to the new database to create tables
      const tenantPool = new Pool({
        ...this.dbConfig,
        database: databaseName,
        max: 1,
      });

      try {
        await this.createTables(tenantPool);
        await this.createIndexes(tenantPool);
      } finally {
        await tenantPool.end();
      }

      return databaseName;
    } finally {
      await adminPool.end();
    }
  }

  /**
   * Soft-delete: rename database so it can be recovered.
   */
  async deprovisionDatabase(databaseName: string): Promise<void> {
    // Close any active pool for this database
    for (const [tenantId, entry] of this.pools.entries()) {
      // We don't store databaseName in the map, so we close all and let them reconnect
      // In practice, the tenant is being deactivated so no more requests will come
    }

    const timestamp = Date.now();
    const adminPool = new Pool({
      ...this.dbConfig,
      database: 'postgres',
      max: 1,
    });

    try {
      // Terminate connections to the database
      await adminPool.query(`
        SELECT pg_terminate_backend(pid)
        FROM pg_stat_activity
        WHERE datname = $1 AND pid <> pg_backend_pid()
      `, [databaseName]);

      // Rename instead of drop
      await adminPool.query(
        `ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
      );
    } finally {
      await adminPool.end();
    }
  }

  /**
   * Remove idle pools (not accessed in last 5 minutes).
   */
  private cleanupIdlePools(): void {
    const now = new Date();
    const maxIdle = 5 * 60 * 1000; // 5 minutes

    for (const [tenantId, entry] of this.pools.entries()) {
      if (now.getTime() - entry.lastAccess.getTime() > maxIdle) {
        entry.pool.end().catch((err) =>
          console.error(`Error closing pool for tenant ${tenantId}:`, err.message)
        );
        this.pools.delete(tenantId);
      }
    }
  }

  /**
   * Invalidate (close and remove) a specific tenant's pool.
   * Used when subscription status changes via webhook.
   */
  invalidatePool(tenantId: string): void {
    const entry = this.pools.get(tenantId);
    if (entry) {
      entry.pool.end().catch(() => {});
      this.pools.delete(tenantId);
    }
  }

  /**
   * Graceful shutdown: close all pools.
   */
  async shutdown(): Promise<void> {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }

    const closePromises = Array.from(this.pools.values()).map((entry) =>
      entry.pool.end()
    );
    await Promise.all(closePromises);
    this.pools.clear();
  }

  /**
   * Get stats about active pools.
   */
  getStats(): { activePools: number; tenantIds: string[] } {
    return {
      activePools: this.pools.size,
      tenantIds: Array.from(this.pools.keys()),
    };
  }

  private async createTables(pool: Pool): Promise<void> {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS cfdis (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
        tipo VARCHAR(20) NOT NULL DEFAULT 'ingreso',
        serie VARCHAR(25),
        folio VARCHAR(40),
        fecha_emision TIMESTAMP NOT NULL,
        fecha_timbrado TIMESTAMP,
        rfc_emisor VARCHAR(13) NOT NULL,
        nombre_emisor VARCHAR(300) NOT NULL,
        rfc_receptor VARCHAR(13) NOT NULL,
        nombre_receptor VARCHAR(300) NOT NULL,
        subtotal DECIMAL(18,2) DEFAULT 0,
        descuento DECIMAL(18,2) DEFAULT 0,
        iva DECIMAL(18,2) DEFAULT 0,
        isr_retenido DECIMAL(18,2) DEFAULT 0,
        iva_retenido DECIMAL(18,2) DEFAULT 0,
        total DECIMAL(18,2) DEFAULT 0,
        moneda VARCHAR(10) DEFAULT 'MXN',
        tipo_cambio DECIMAL(10,4) DEFAULT 1,
        metodo_pago VARCHAR(10),
        forma_pago VARCHAR(10),
        uso_cfdi VARCHAR(10),
        estado VARCHAR(20) DEFAULT 'vigente',
        xml_url TEXT,
        pdf_url TEXT,
        xml_original TEXT,
        created_at TIMESTAMP DEFAULT NOW(),
        updated_at TIMESTAMP DEFAULT NOW(),
        last_sat_sync TIMESTAMP,
        sat_sync_job_id UUID,
        source VARCHAR(20) DEFAULT 'manual'
      );

      CREATE TABLE IF NOT EXISTS iva_mensual (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        año INTEGER NOT NULL,
        mes INTEGER NOT NULL,
        iva_trasladado DECIMAL(18,2) DEFAULT 0,
        iva_acreditable DECIMAL(18,2) DEFAULT 0,
        iva_retenido DECIMAL(18,2) DEFAULT 0,
        resultado DECIMAL(18,2) DEFAULT 0,
        acumulado DECIMAL(18,2) DEFAULT 0,
        estado VARCHAR(20) DEFAULT 'pendiente',
        fecha_declaracion TIMESTAMP,
        UNIQUE(año, mes)
      );

      CREATE TABLE IF NOT EXISTS isr_mensual (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        año INTEGER NOT NULL,
        mes INTEGER NOT NULL,
        ingresos_acumulados DECIMAL(18,2) DEFAULT 0,
        deducciones DECIMAL(18,2) DEFAULT 0,
        base_gravable DECIMAL(18,2) DEFAULT 0,
        isr_causado DECIMAL(18,2) DEFAULT 0,
        isr_retenido DECIMAL(18,2) DEFAULT 0,
        isr_a_pagar DECIMAL(18,2) DEFAULT 0,
        estado VARCHAR(20) DEFAULT 'pendiente',
        fecha_declaracion TIMESTAMP,
        UNIQUE(año, mes)
      );

      CREATE TABLE IF NOT EXISTS alertas (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        tipo VARCHAR(50) NOT NULL,
        titulo VARCHAR(200) NOT NULL,
        mensaje TEXT,
        prioridad VARCHAR(20) DEFAULT 'media',
        fecha_vencimiento TIMESTAMP,
        leida BOOLEAN DEFAULT FALSE,
        resuelta BOOLEAN DEFAULT FALSE,
        created_at TIMESTAMP DEFAULT NOW()
      );

      CREATE TABLE IF NOT EXISTS calendario_fiscal (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        titulo VARCHAR(200) NOT NULL,
        descripcion TEXT,
        tipo VARCHAR(50) NOT NULL,
        fecha_limite TIMESTAMP NOT NULL,
        recurrencia VARCHAR(20) DEFAULT 'unica',
        completado BOOLEAN DEFAULT FALSE,
        notas TEXT,
        created_at TIMESTAMP DEFAULT NOW()
      );
    `);
  }

  private async createIndexes(pool: Pool): Promise<void> {
    // Enable pg_trgm extension for fuzzy search
    await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);

    await pool.query(`
      CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
      CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
      CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
      CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
      CREATE INDEX IF NOT EXISTS idx_cfdis_estado ON cfdis(estado);
      CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
      CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
    `);
  }
}

// Singleton instance
export const tenantDb = new TenantConnectionManager();
  • Step 3: Commit
git add apps/api/src/config/database.ts apps/api/package.json pnpm-lock.yaml
git commit -m "feat: add TenantConnectionManager with dynamic pool management"

Task 5: Update tenant middleware to use connection pools

Files:

  • Modify: apps/api/src/middlewares/tenant.middleware.ts

  • Step 1: Rewrite tenant middleware

Replace the entire file with:

// apps/api/src/middlewares/tenant.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { Pool } from 'pg';
import { prisma } from '../config/prisma';
import { tenantDb } from '../config/database';

// Extend Express Request
declare global {
  namespace Express {
    interface Request {
      tenantPool?: Pool;
      viewingTenantId?: string;
    }
  }
}

export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.user) {
      return res.status(401).json({ message: 'No autenticado' });
    }

    let tenantId = req.user.tenantId;
    let databaseName = req.user.databaseName;

    // Admin impersonation via X-View-Tenant header
    const viewTenantHeader = req.headers['x-view-tenant'] as string;
    if (viewTenantHeader && req.user.role === 'admin') {
      const viewedTenant = await prisma.tenant.findFirst({
        where: {
          OR: [
            { id: viewTenantHeader },
            { rfc: viewTenantHeader },
          ],
        },
        select: { id: true, databaseName: true, active: true },
      });

      if (!viewedTenant) {
        return res.status(404).json({ message: 'Tenant no encontrado' });
      }

      if (!viewedTenant.active) {
        return res.status(403).json({ message: 'Tenant inactivo' });
      }

      tenantId = viewedTenant.id;
      databaseName = viewedTenant.databaseName;
      req.viewingTenantId = viewedTenant.id;
    }

    // Get the connection pool for this tenant's database
    req.tenantPool = tenantDb.getPool(tenantId, databaseName);
    next();
  } catch (error) {
    console.error('Tenant middleware error:', error);
    return res.status(500).json({ message: 'Error al resolver tenant' });
  }
}
  • Step 2: Commit
git add apps/api/src/middlewares/tenant.middleware.ts
git commit -m "feat: rewrite tenant middleware to use TenantConnectionManager pools"

Task 6: Update auth service (JWT payload + registration)

Files:

  • Modify: apps/api/src/services/auth.service.ts

  • Step 1: Update JWT payload generation

In auth.service.ts, find all places where the token payload is created (the register function around line 52 and the login function around line 115, and refreshTokens around line 149) and change schemaName to databaseName:

// In register():
const tokenPayload = {
  userId: user.id,
  email: user.email,
  role: user.role as 'admin' | 'contador' | 'visor',
  tenantId: tenant.id,
  databaseName: tenant.databaseName,  // was: schemaName: tenant.schemaName
};

// In login():
const tokenPayload = {
  userId: user.id,
  email: user.email,
  role: user.role as 'admin' | 'contador' | 'visor',
  tenantId: user.tenant.id,
  databaseName: user.tenant.databaseName,  // was: schemaName: user.tenant.schemaName
};

// In refreshTokens() — update the select to fetch databaseName:
const tokenRecord = await tx.refreshToken.findUnique({
  where: { token },
  include: {
    user: {
      include: {
        tenant: {
          select: { id: true, databaseName: true },  // was: schemaName
        },
      },
    },
  },
});
// ... and the payload:
const newPayload = {
  userId: tokenRecord.user.id,
  email: tokenRecord.user.email,
  role: tokenRecord.user.role as 'admin' | 'contador' | 'visor',
  tenantId: tokenRecord.user.tenant.id,
  databaseName: tokenRecord.user.tenant.databaseName,
};
  • Step 2: Update register to use database provisioning instead of schema

Replace the call to createTenantSchema(schemaName) with tenantDb.provisionDatabase(rfc):

import { tenantDb } from '../config/database';

// In register():
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);

const tenant = await prisma.tenant.create({
  data: {
    nombre: data.empresa.nombre,
    rfc: data.empresa.rfc.toUpperCase(),
    databaseName,  // was: schemaName
    plan: 'starter',
  },
});
  • Step 3: Also update the login query select to use databaseName

In the login function, find the prisma.user.findUnique query and update:

const user = await prisma.user.findUnique({
  where: { email: data.email },
  include: {
    tenant: {
      select: { id: true, nombre: true, rfc: true, databaseName: true, active: true },
    },
  },
});
  • Step 4: Update UserInfo response in login and register

Find where UserInfo is constructed and update tenantName etc. The schemaName is not sent to frontend in UserInfo, so this may only need databaseName in the JWT payload, not the response body. Verify the UserInfo interface — it has tenantId, tenantName, tenantRfc but NOT schemaName, so the response body doesn't change.

  • Step 5: Commit
git add apps/api/src/services/auth.service.ts
git commit -m "feat: update auth service JWT payload and registration for DB-per-tenant"

Task 7: Update tenants service for DB provisioning

Files:

  • Modify: apps/api/src/services/tenants.service.ts

  • Step 1: Rewrite createTenant to use TenantConnectionManager

Replace the inline SQL provisioning with tenantDb.provisionDatabase():

import { tenantDb } from '../config/database';

export async function createTenant(data: {
  nombre: string;
  rfc: string;
  plan?: string;
  cfdiLimit?: number;
  usersLimit?: number;
}) {
  const rfc = data.rfc.toUpperCase();

  // Check if tenant already exists
  const existing = await prisma.tenant.findUnique({ where: { rfc } });
  if (existing) {
    throw new Error('Ya existe un tenant con ese RFC');
  }

  let databaseName: string;
  let tenant: any;

  try {
    // Step 1: Provision database
    databaseName = await tenantDb.provisionDatabase(rfc);

    // Step 2: Create tenant record
    tenant = await prisma.tenant.create({
      data: {
        nombre: data.nombre,
        rfc,
        plan: (data.plan as any) || 'starter',
        databaseName,
        cfdiLimit: data.cfdiLimit ?? 100,
        usersLimit: data.usersLimit ?? 1,
      },
    });

    return tenant;
  } catch (error) {
    // Rollback: drop database if it was created
    if (databaseName!) {
      try {
        await tenantDb.deprovisionDatabase(databaseName);
      } catch (rollbackErr) {
        console.error('Rollback failed:', rollbackErr);
      }
    }
    // Rollback: delete tenant record if it was created
    if (tenant) {
      try {
        await prisma.tenant.delete({ where: { id: tenant.id } });
      } catch (rollbackErr) {
        console.error('Tenant record rollback failed:', rollbackErr);
      }
    }
    throw error;
  }
}
  • Step 2: Update all queries that reference schemaName to databaseName

Find all schemaName references in this file and replace with databaseName:

In getAllTenants():

select: { id: true, nombre: true, rfc: true, plan: true, databaseName: true, active: true, createdAt: true, ... }

In getTenantById():

select: { id: true, nombre: true, rfc: true, plan: true, databaseName: true, cfdiLimit: true, usersLimit: true, active: true, ... }
  • Step 3: Commit
git add apps/api/src/services/tenants.service.ts
git commit -m "feat: rewrite tenant provisioning for database-per-tenant with rollback"

Task 8: Update all tenant services to use req.tenantPool

Files:

  • Modify: All service files that use tenant-scoped queries

This is the biggest change. Every service that currently uses prisma.$queryRawUnsafe with schema-prefixed table names ("${schema}".cfdis) needs to use pool.query with direct table names (cfdis).

  • Step 1: Update cfdi.service.ts

The pattern for every query changes from:

// Before
const result = await prisma.$queryRawUnsafe(`SELECT * FROM "${schema}".cfdis WHERE ...`);

// After — function now receives pool as first argument
export async function getCfdis(pool: Pool, filters: CfdiFilters) {
  const result = await pool.query(`SELECT * FROM cfdis WHERE ...`, params);
  return result.rows;
}

Update every exported function in cfdi.service.ts to accept Pool as the first parameter. Remove all schema prefix references ("${schema}".). Replace prisma.$queryRawUnsafe with pool.query.

  • Step 2: Update cfdi controller/routes to pass req.tenantPool

In the route handlers, change from passing req.tenantSchema to passing req.tenantPool:

// Before
const result = await getCfdis(req.tenantSchema!, filters);

// After
const result = await getCfdis(req.tenantPool!, filters);
  • Step 3: Repeat for all other tenant services

Apply the same pattern to:

  • dashboard.service.ts
  • impuestos.service.ts
  • alertas.service.ts
  • calendario.service.ts
  • reportes.service.ts
  • export.service.ts

Each function: replace first param with Pool, remove schema prefix, use pool.query().

  • Step 4: Commit
git add apps/api/src/services/ apps/api/src/routes/
git commit -m "feat: migrate all tenant services from schema queries to pool-based queries"

Task 9: Update app entry point and graceful shutdown

Files:

  • Modify: apps/api/src/index.ts

  • Modify: apps/api/src/app.ts

  • Step 1: Add graceful shutdown in index.ts

import { tenantDb } from './config/database';

// After server.listen():
const gracefulShutdown = async (signal: string) => {
  console.log(`${signal} received. Shutting down gracefully...`);
  await tenantDb.shutdown();
  process.exit(0);
};

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
  • Step 2: Add PM2 cross-worker cache invalidation support
// Listen for PM2 messages to invalidate tenant caches
process.on('message', (msg: any) => {
  if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
    tenantDb.invalidatePool(msg.tenantId);
  }
});
  • Step 3: Commit
git add apps/api/src/index.ts apps/api/src/app.ts
git commit -m "feat: add graceful shutdown and PM2 cross-worker messaging"

Task 10: Remove old schema-manager and clean up

Files:

  • Delete or archive: apps/api/src/utils/schema-manager.ts

  • Modify: any imports that reference it

  • Step 1: Remove schema-manager.ts imports

The schema-manager.ts is imported in auth.service.ts. Since we've already replaced the call with tenantDb.provisionDatabase(), we can safely remove the import and delete the file.

  • Step 2: Delete schema-manager.ts
rm apps/api/src/utils/schema-manager.ts
  • Step 3: Search for any remaining schemaName references in the API codebase
grep -r "schemaName\|schema_name\|tenantSchema\|search_path" apps/api/src/ --include="*.ts"

Fix any remaining references.

  • Step 4: Build and verify
cd /root/Horux/apps/api && pnpm build

Expected: No TypeScript errors.

  • Step 5: Commit
git add -A
git commit -m "refactor: remove schema-manager, clean up all schemaName references"

Chunk 2: FIEL Dual Storage + Encryption Fix

Task 11: Update SAT crypto service for dedicated encryption key

Files:

  • Modify: apps/api/src/services/sat/sat-crypto.service.ts

  • Step 1: Change key derivation to use FIEL_ENCRYPTION_KEY

Replace:

function deriveKey(): Buffer {
  return createHash('sha256').update(env.JWT_SECRET).digest();
}

With:

function deriveKey(): Buffer {
  return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
}
  • Step 2: Rewrite encryptFielCredentials for per-component encryption

Replace the single-encryption approach with independent encryption per component:

interface EncryptedFielData {
  encryptedCer: Buffer;
  cerIv: Buffer;
  cerTag: Buffer;
  encryptedKey: Buffer;
  keyIv: Buffer;
  keyTag: Buffer;
  encryptedPassword: Buffer;
  passwordIv: Buffer;
  passwordTag: Buffer;
}

export function encryptFielCredentials(
  cerData: Buffer,
  keyData: Buffer,
  password: string
): EncryptedFielData {
  const key = deriveKey();

  const cerResult = encryptBuffer(cerData, key);
  const keyResult = encryptBuffer(keyData, key);
  const passResult = encryptBuffer(Buffer.from(password, 'utf-8'), key);

  return {
    encryptedCer: cerResult.encrypted,
    cerIv: cerResult.iv,
    cerTag: cerResult.tag,
    encryptedKey: keyResult.encrypted,
    keyIv: keyResult.iv,
    keyTag: keyResult.tag,
    encryptedPassword: passResult.encrypted,
    passwordIv: passResult.iv,
    passwordTag: passResult.tag,
  };
}

function encryptBuffer(data: Buffer, key: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
  const iv = randomBytes(IV_LENGTH);
  const cipher = createCipheriv('aes-256-gcm', key, iv);
  const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
  const tag = cipher.getAuthTag();
  return { encrypted, iv, tag };
}

export function decryptFielCredentials(
  encryptedCer: Buffer, cerIv: Buffer, cerTag: Buffer,
  encryptedKey: Buffer, keyIv: Buffer, keyTag: Buffer,
  encryptedPassword: Buffer, passwordIv: Buffer, passwordTag: Buffer
): { cerData: Buffer; keyData: Buffer; password: string } {
  const key = deriveKey();

  const cerData = decryptBuffer(encryptedCer, key, cerIv, cerTag);
  const keyData = decryptBuffer(encryptedKey, key, keyIv, keyTag);
  const password = decryptBuffer(encryptedPassword, key, passwordIv, passwordTag).toString('utf-8');

  return { cerData, keyData, password };
}

function decryptBuffer(encrypted: Buffer, key: Buffer, iv: Buffer, tag: Buffer): Buffer {
  const decipher = createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
  • Step 3: Commit
git add apps/api/src/services/sat/sat-crypto.service.ts
git commit -m "feat: use dedicated FIEL_ENCRYPTION_KEY with per-component encryption"

Task 12: Update FIEL service for dual storage

Files:

  • Modify: apps/api/src/services/fiel.service.ts

  • Step 1: Add filesystem storage to uploadFiel

After encrypting and saving to DB, also save to filesystem:

import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { env } from '../config/env';
import { encryptFielCredentials } from './sat/sat-crypto.service';

// In uploadFiel(), after the prisma.fielCredential.upsert:

// Save to filesystem
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
await mkdir(fielDir, { recursive: true, mode: 0o700 });

// Encrypt files for filesystem storage
const fsEncrypted = encryptFielCredentials(cerBuffer, keyBuffer, password);

await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });

// Encrypt metadata too
const metadata = JSON.stringify({
  serial: serialNumber,
  validFrom: validFrom.toISOString(),
  validUntil: validUntil.toISOString(),
  uploadedAt: new Date().toISOString(),
  rfc: rfc.toUpperCase(),
});
const metaEncrypted = encryptBuffer(Buffer.from(metadata, 'utf-8'), deriveKey());
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
// Also store IV and tag for metadata
await writeFile(join(fielDir, 'metadata.iv'), metaEncrypted.iv, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.tag'), metaEncrypted.tag, { mode: 0o600 });
  • Step 2: Update the upsert call to use new per-component columns
await prisma.fielCredential.upsert({
  where: { tenantId },
  create: {
    tenantId,
    rfc: rfc.toUpperCase(),
    cerData: encrypted.encryptedCer,
    keyData: encrypted.encryptedKey,
    keyPasswordEncrypted: encrypted.encryptedPassword,
    cerIv: encrypted.cerIv,
    cerTag: encrypted.cerTag,
    keyIv: encrypted.keyIv,
    keyTag: encrypted.keyTag,
    passwordIv: encrypted.passwordIv,
    passwordTag: encrypted.passwordTag,
    serialNumber,
    validFrom,
    validUntil,
  },
  update: {
    // same fields...
  },
});
  • Step 3: Update getDecryptedFiel to use new columns

  • Step 4: Commit

git add apps/api/src/services/fiel.service.ts
git commit -m "feat: add filesystem FIEL storage with per-component encryption"

Task 13: Create FIEL decrypt CLI script

Files:

  • Create: apps/api/scripts/decrypt-fiel.ts

  • Step 1: Write the CLI decrypt script

// apps/api/scripts/decrypt-fiel.ts
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { createDecipheriv, createHash } from 'crypto';

const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;

const rfc = process.argv[2];
if (!rfc) {
  console.error('Usage: npx tsx scripts/decrypt-fiel.ts <RFC>');
  process.exit(1);
}
if (!FIEL_KEY) {
  console.error('FIEL_ENCRYPTION_KEY env var is required');
  process.exit(1);
}

async function main() {
  const fielDir = join(FIEL_PATH, rfc.toUpperCase());
  const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;

  const key = createHash('sha256').update(FIEL_KEY!).digest();

  // Read encrypted files
  const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
  const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));

  // We need IV and tag — for filesystem, we'll read from metadata
  // For now, a simplified approach: store iv/tag alongside the .enc files
  // (This will be refined during implementation to match the storage format)

  await mkdir(outputDir, { recursive: true, mode: 0o700 });

  console.log(`Decrypted files written to: ${outputDir}`);
  console.log('Files will be auto-deleted in 30 minutes.');

  // Auto-delete after 30 minutes
  setTimeout(async () => {
    await rm(outputDir, { recursive: true, force: true });
    console.log(`Cleaned up ${outputDir}`);
    process.exit(0);
  }, 30 * 60 * 1000);
}

main().catch(console.error);

Note: The exact implementation will depend on how IVs and tags are stored on the filesystem. This will be refined during implementation to match the actual file storage format from Task 12.

  • Step 2: Commit
git add apps/api/scripts/decrypt-fiel.ts
git commit -m "feat: add CLI script for decrypting FIEL credentials"

Chunk 3: Email Service

Task 14: Create email service and templates

Files:

  • Create: apps/api/src/services/email/email.service.ts

  • Create: apps/api/src/services/email/templates/base.ts

  • Create: apps/api/src/services/email/templates/welcome.ts

  • Create: apps/api/src/services/email/templates/fiel-notification.ts

  • Create: apps/api/src/services/email/templates/payment-confirmed.ts

  • Create: apps/api/src/services/email/templates/payment-failed.ts

  • Create: apps/api/src/services/email/templates/subscription-expiring.ts

  • Create: apps/api/src/services/email/templates/subscription-cancelled.ts

  • Step 1: Install nodemailer

cd /root/Horux/apps/api && pnpm add nodemailer && pnpm add -D @types/nodemailer
  • Step 2: Create email service
// apps/api/src/services/email/email.service.ts
import { createTransport, Transporter } from 'nodemailer';
import { env } from '../../config/env';

let transporter: Transporter | null = null;

function getTransporter(): Transporter {
  if (!transporter) {
    if (!env.SMTP_USER || !env.SMTP_PASS) {
      console.warn('SMTP not configured. Emails will be logged to console.');
      // Return a mock transporter that logs to console
      return {
        sendMail: async (opts: any) => {
          console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
          return { messageId: 'mock' };
        },
      } as any;
    }

    transporter = createTransport({
      host: env.SMTP_HOST,
      port: parseInt(env.SMTP_PORT),
      secure: false, // STARTTLS
      auth: {
        user: env.SMTP_USER,
        pass: env.SMTP_PASS,
      },
    });
  }
  return transporter;
}

async function sendEmail(to: string, subject: string, html: string, text?: string) {
  const transport = getTransporter();
  try {
    await transport.sendMail({
      from: env.SMTP_FROM,
      to,
      subject,
      html,
      text: text || html.replace(/<[^>]*>/g, ''),
    });
  } catch (error) {
    console.error('Error sending email:', error);
    // Don't throw — email failure shouldn't break the main flow
  }
}

// Public API
export const emailService = {
  sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
    const { welcomeEmail } = await import('./templates/welcome');
    await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
  },

  sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
    const { fielNotificationEmail } = await import('./templates/fiel-notification');
    await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
  },

  sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
    const { paymentConfirmedEmail } = await import('./templates/payment-confirmed');
    await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
  },

  sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
    const { paymentFailedEmail } = await import('./templates/payment-failed');
    await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
    // Also notify admin
    await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
  },

  sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
    const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring');
    await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
  },

  sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
    const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled');
    await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
    await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
  },
};
  • Step 3: Create base HTML template
// apps/api/src/services/email/templates/base.ts
export function baseTemplate(content: string): string {
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:Arial,sans-serif;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
    <tr>
      <td align="center">
        <table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
          <tr>
            <td style="background-color:#1e293b;padding:24px 32px;text-align:center;">
              <h1 style="color:#ffffff;margin:0;font-size:24px;">Horux360</h1>
            </td>
          </tr>
          <tr>
            <td style="padding:32px;">
              ${content}
            </td>
          </tr>
          <tr>
            <td style="background-color:#f8fafc;padding:16px 32px;text-align:center;font-size:12px;color:#94a3b8;">
              <p style="margin:0;">&copy; ${new Date().getFullYear()} Horux360 - Plataforma Fiscal Inteligente</p>
              <p style="margin:4px 0 0;">Consultoria Alcaraz Salazar</p>
            </td>
          </tr>
        </table>
      </td>
    </tr>
  </table>
</body>
</html>`;
}
  • Step 4: Create each email template

Create welcome, fiel-notification, payment-confirmed, payment-failed, subscription-expiring, subscription-cancelled templates. Each uses baseTemplate() and receives typed data.

Example for welcome.ts:

// apps/api/src/services/email/templates/welcome.ts
import { baseTemplate } from './base';

export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
  return baseTemplate(`
    <h2 style="color:#1e293b;margin:0 0 16px;">Bienvenido a Horux360</h2>
    <p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
    <p style="color:#475569;line-height:1.6;">Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:</p>
    <div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
      <p style="margin:0;color:#334155;"><strong>Email:</strong> ${data.email}</p>
      <p style="margin:8px 0 0;color:#334155;"><strong>Contraseña temporal:</strong> ${data.tempPassword}</p>
    </div>
    <p style="color:#475569;line-height:1.6;">Te recomendamos cambiar tu contraseña después de iniciar sesión.</p>
    <a href="https://horux360.consultoria-as.com/login" style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:16px;">Iniciar sesión</a>
  `);
}
  • Step 5: Commit
git add apps/api/src/services/email/ apps/api/package.json pnpm-lock.yaml
git commit -m "feat: add email service with Nodemailer and 6 HTML templates"

Chunk 4: MercadoPago Payments

Task 15: Create MercadoPago service

Files:

  • Create: apps/api/src/services/payment/mercadopago.service.ts

  • Step 1: Install MercadoPago SDK

cd /root/Horux/apps/api && pnpm add mercadopago
  • Step 2: Create MercadoPago service

Implement functions:

  • createSubscription(tenantId, plan, amount, email) — creates a Preapproval and returns the init_point URL

  • getSubscription(mpPreapprovalId) — gets subscription status from MP

  • verifyWebhookSignature(headers, body) — validates webhook authenticity

  • getPaymentDetails(paymentId) — gets payment info from MP API

  • Step 3: Commit

git add apps/api/src/services/payment/
git commit -m "feat: add MercadoPago preapproval subscription service"

Task 16: Create subscription service and webhook handler

Files:

  • Create: apps/api/src/services/payment/subscription.service.ts

  • Create: apps/api/src/controllers/webhook.controller.ts

  • Create: apps/api/src/routes/webhook.routes.ts

  • Step 1: Create subscription service

Functions:

  • createSubscription(tenantId, plan, amount, frequency) — creates DB record + MP preapproval

  • getActiveSubscription(tenantId) — cached 5 min TTL

  • updateSubscriptionStatus(mpPreapprovalId, status) — updates DB + invalidates cache

  • recordPayment(tenantId, subscriptionId, mpPaymentId, amount, status, method) — inserts payment

  • markAsPaidManually(tenantId, amount) — for bank transfers

  • generatePaymentLink(tenantId) — returns MP init_point URL

  • Step 2: Create webhook controller

Handle POST /api/webhooks/mercadopago:

  • Verify signature

  • Handle payment type: fetch payment details from MP, record in DB

  • Handle subscription_preapproval type: update subscription status

  • On cancellation: mark tenant as inactive, send notification email

  • Broadcast cache invalidation to all PM2 workers

  • Step 3: Create webhook routes

// apps/api/src/routes/webhook.routes.ts
import { Router } from 'express';
import { handleMercadoPagoWebhook } from '../controllers/webhook.controller';

const router = Router();

// Public endpoint — no auth middleware
router.post('/mercadopago', handleMercadoPagoWebhook);

export default router;
  • Step 4: Register route in app.ts

Add to apps/api/src/app.ts:

import webhookRoutes from './routes/webhook.routes';
app.use('/api/webhooks', webhookRoutes);
  • Step 5: Commit
git add apps/api/src/services/payment/ apps/api/src/controllers/ apps/api/src/routes/ apps/api/src/app.ts
git commit -m "feat: add subscription service, webhook handler, and payment routes"

Task 17: Create subscription admin endpoints

Files:

  • Create: apps/api/src/controllers/subscription.controller.ts

  • Create: apps/api/src/routes/subscription.routes.ts

  • Step 1: Create controller with admin endpoints

Endpoints:

  • GET /api/subscriptions/:tenantId — get subscription info

  • POST /api/subscriptions/:tenantId/generate-link — generate payment link

  • POST /api/subscriptions/:tenantId/mark-paid — manual payment

  • GET /api/subscriptions/:tenantId/payments — payment history

  • Step 2: Create routes with auth + admin authorization

  • Step 3: Register in app.ts

  • Step 4: Commit

git add apps/api/src/controllers/subscription.controller.ts apps/api/src/routes/subscription.routes.ts apps/api/src/app.ts
git commit -m "feat: add subscription admin endpoints for payment management"

Chunk 5: Plan Enforcement

Task 18: Create plan limits and feature gate middleware

Files:

  • Create: apps/api/src/middlewares/plan-limits.middleware.ts

  • Create: apps/api/src/middlewares/feature-gate.middleware.ts

  • Step 1: Create plan limits middleware

// apps/api/src/middlewares/plan-limits.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/prisma';

// Simple in-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();

async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
  const entry = cache.get(key);
  if (entry && entry.expires > Date.now()) return entry.data;
  const data = await fetcher();
  cache.set(key, { data, expires: Date.now() + ttlMs });
  return data;
}

export function invalidateTenantCache(tenantId: string) {
  for (const key of cache.keys()) {
    if (key.includes(tenantId)) cache.delete(key);
  }
}

export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
  if (!req.user) return next();

  // Admin impersonation bypasses subscription check
  if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
    return next();
  }

  const subscription = await getCached(
    `sub:${req.user.tenantId}`,
    5 * 60 * 1000, // 5 min TTL
    () => prisma.subscription.findFirst({
      where: { tenantId: req.user!.tenantId },
      orderBy: { createdAt: 'desc' },
    })
  );

  const allowedStatuses = ['authorized', 'pending'];

  if (!subscription || !allowedStatuses.includes(subscription.status)) {
    if (req.method !== 'GET') {
      return res.status(403).json({
        message: 'Suscripción inactiva. Contacta soporte para reactivar.',
      });
    }
  }

  next();
}

export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) {
  if (!req.user || !req.tenantPool) return next();

  const tenant = await getCached(
    `tenant:${req.user.tenantId}`,
    5 * 60 * 1000,
    () => prisma.tenant.findUnique({
      where: { id: req.user!.tenantId },
      select: { cfdiLimit: true },
    })
  );

  if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited

  const countResult = await getCached(
    `cfdi-count:${req.user.tenantId}`,
    5 * 60 * 1000,
    async () => {
      const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis');
      return parseInt(result.rows[0].count);
    }
  );

  const newCount = Array.isArray(req.body) ? req.body.length : 1;
  if (countResult + newCount > tenant.cfdiLimit) {
    return res.status(403).json({
      message: `Límite de CFDIs alcanzado (${countResult}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`,
    });
  }

  next();
}
  • Step 2: Create feature gate middleware
// apps/api/src/middlewares/feature-gate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { hasFeature } from '@horux/shared';
import { prisma } from '../config/prisma';

const planCache = new Map<string, { plan: string; expires: number }>();

export function requireFeature(feature: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) return res.status(401).json({ message: 'No autenticado' });

    let plan: string;
    const cached = planCache.get(req.user.tenantId);
    if (cached && cached.expires > Date.now()) {
      plan = cached.plan;
    } else {
      const tenant = await prisma.tenant.findUnique({
        where: { id: req.user.tenantId },
        select: { plan: true },
      });
      if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' });
      plan = tenant.plan;
      planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 });
    }

    if (!hasFeature(plan as any, feature)) {
      return res.status(403).json({
        message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.',
      });
    }

    next();
  };
}
  • Step 3: Apply middleware to routes in app.ts

Add checkPlanLimits to tenant-scoped routes and checkCfdiLimit to CFDI write endpoints. Add requireFeature to protected routes:

// In route registration:
app.use('/api/cfdi', authenticate, tenantMiddleware, checkPlanLimits, cfdiRoutes);
// In cfdi routes, POST endpoints get additional checkCfdiLimit
router.post('/', checkCfdiLimit, createCfdi);
router.post('/bulk', checkCfdiLimit, createManyCfdis);

// Feature-gated routes:
app.use('/api/reportes', authenticate, tenantMiddleware, checkPlanLimits, requireFeature('reportes'), reportesRoutes);
app.use('/api/alertas', authenticate, tenantMiddleware, checkPlanLimits, requireFeature('alertas'), alertasRoutes);
app.use('/api/calendario', authenticate, tenantMiddleware, checkPlanLimits, requireFeature('calendario'), calendarioRoutes);
  • Step 4: Add PM2 message handler for cache invalidation
// In index.ts, extend the existing message handler:
process.on('message', (msg: any) => {
  if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
    tenantDb.invalidatePool(msg.tenantId);
    invalidateTenantCache(msg.tenantId); // from plan-limits middleware
  }
});
  • Step 5: Commit
git add apps/api/src/middlewares/ apps/api/src/app.ts apps/api/src/index.ts
git commit -m "feat: add plan limits, CFDI limit check, and feature gating middleware"

Chunk 6: Tenant Provisioning Flow (Full Integration)

Task 19: Integrate email + subscription into tenant creation

Files:

  • Modify: apps/api/src/services/tenants.service.ts

  • Step 1: Update createTenant to generate user, email, and subscription

The full provisioning flow:

  1. Provision database
  2. Create tenant record
  3. Create admin user with temp password
  4. Create initial subscription record (status: pending)
  5. Send welcome email
import { emailService } from './email/email.service';
import { randomBytes } from 'crypto';

export async function createTenant(data: {
  nombre: string;
  rfc: string;
  plan?: string;
  cfdiLimit?: number;
  usersLimit?: number;
  adminEmail: string;
  adminNombre: string;
  amount: number;
}) {
  // ... provisioning code from Task 7 ...

  // After tenant is created:

  // Create admin user with temp password
  const tempPassword = randomBytes(4).toString('hex'); // 8-char random
  const hashedPassword = await bcrypt.hash(tempPassword, 10);

  const user = await prisma.user.create({
    data: {
      tenantId: tenant.id,
      email: data.adminEmail,
      passwordHash: hashedPassword,
      nombre: data.adminNombre,
      role: 'admin',
    },
  });

  // Create initial subscription
  await prisma.subscription.create({
    data: {
      tenantId: tenant.id,
      plan: (data.plan as any) || 'starter',
      status: 'pending',
      amount: data.amount,
      frequency: 'monthly',
    },
  });

  // Send welcome email (non-blocking)
  emailService.sendWelcome(data.adminEmail, {
    nombre: data.adminNombre,
    email: data.adminEmail,
    tempPassword,
  }).catch(err => console.error('Welcome email failed:', err));

  return { tenant, user, tempPassword };
}
  • Step 2: Update the tenants controller to accept new fields

  • Step 3: Commit

git add apps/api/src/services/tenants.service.ts apps/api/src/controllers/ apps/api/src/routes/
git commit -m "feat: integrate email and subscription into tenant provisioning flow"

Task 20: Send FIEL notification email on upload

Files:

  • Modify: apps/api/src/services/fiel.service.ts

  • Step 1: Add email notification after FIEL upload

After successful FIEL upload in uploadFiel():

import { emailService } from './email/email.service';

// After saving to DB and filesystem:
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
if (tenant) {
  emailService.sendFielNotification({
    clienteNombre: tenant.nombre,
    clienteRfc: tenant.rfc,
  }).catch(err => console.error('FIEL notification email failed:', err));
}
  • Step 2: Commit
git add apps/api/src/services/fiel.service.ts
git commit -m "feat: send admin notification email when client uploads FIEL"

Chunk 7: Production Deployment

Task 21: Create production PM2 config

Files:

  • Modify: ecosystem.config.js

  • Step 1: Update ecosystem.config.js for production

module.exports = {
  apps: [
    {
      name: 'horux-api',
      script: 'dist/index.js',
      cwd: '/root/Horux/apps/api',
      instances: 2,
      exec_mode: 'cluster',
      autorestart: true,
      max_memory_restart: '1G',
      env: {
        NODE_ENV: 'production',
        PORT: 4000,
      },
    },
    {
      name: 'horux-web',
      script: 'node_modules/.bin/next',
      args: 'start',
      cwd: '/root/Horux/apps/web',
      instances: 1,
      exec_mode: 'fork',
      autorestart: true,
      max_memory_restart: '512M',
      env: {
        NODE_ENV: 'production',
        PORT: 3000,
      },
    },
  ],
};
  • Step 2: Commit
git add ecosystem.config.js
git commit -m "feat: update PM2 config for production cluster mode"

Task 22: Create Nginx config

Files:

  • Create: deploy/nginx/horux360.conf

  • Step 1: Write Nginx site configuration

Full Nginx config with SSL, rate limiting zones, security headers, and proxy rules as defined in the spec.

  • Step 2: Commit
git add deploy/nginx/
git commit -m "feat: add Nginx reverse proxy config with SSL and rate limiting"

Task 23: Create backup script

Files:

  • Create: scripts/backup.sh

  • Step 1: Write backup script as defined in spec

Daily/weekly rotation, .pgpass auth, empty file verification.

  • Step 2: Commit
git add scripts/backup.sh
git commit -m "feat: add automated backup script with daily/weekly rotation"

Task 24: PostgreSQL tuning

  • Step 1: Create PostgreSQL config tuning script
# scripts/tune-postgres.sh
sudo -u postgres psql -c "ALTER SYSTEM SET max_connections = 300;"
sudo -u postgres psql -c "ALTER SYSTEM SET shared_buffers = '4GB';"
sudo -u postgres psql -c "ALTER SYSTEM SET work_mem = '16MB';"
sudo -u postgres psql -c "ALTER SYSTEM SET effective_cache_size = '16GB';"
sudo -u postgres psql -c "ALTER SYSTEM SET maintenance_work_mem = '512MB';"
sudo systemctl restart postgresql
  • Step 2: Commit
git add scripts/tune-postgres.sh
git commit -m "feat: add PostgreSQL production tuning script"

Task 25: Create FIEL storage directory and set up server

  • Step 1: Create required directories
sudo mkdir -p /var/horux/fiel
sudo mkdir -p /var/horux/backups/daily
sudo mkdir -p /var/horux/backups/weekly
sudo chmod 700 /var/horux/fiel
sudo chmod 700 /var/horux/backups
  • Step 2: Set up .pgpass for backups
echo "localhost:5432:*:postgres:<password>" > /root/.pgpass
chmod 600 /root/.pgpass
  • Step 3: Add backup cron job
crontab -e
# Add: 0 1 * * * /var/horux/scripts/backup.sh >> /var/log/horux-backup.log 2>&1

Task 26: Install and configure Nginx + SSL

  • Step 1: Install Nginx and Certbot
apt update && apt install -y nginx certbot python3-certbot-nginx
  • Step 2: Deploy Nginx config
cp deploy/nginx/horux360.conf /etc/nginx/sites-available/
ln -s /etc/nginx/sites-available/horux360.conf /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
nginx -t && systemctl restart nginx
  • Step 3: Get SSL certificate
certbot --nginx -d horux360.consultoria-as.com
  • Step 4: Configure firewall
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

Task 27: Build and deploy production

  • Step 1: Build both apps
cd /root/Horux
pnpm build
  • Step 2: Set up production environment

Create apps/api/.env.production with all production values (JWT secret, FIEL key, MercadoPago tokens, SMTP credentials).

  • Step 3: Stop old services and start PM2
systemctl stop horux-api horux-web
systemctl disable horux-api horux-web
npx pm2 start ecosystem.config.js
npx pm2 save
npx pm2 startup
  • Step 4: Apply PostgreSQL tuning
bash scripts/tune-postgres.sh
  • Step 5: Verify everything works
curl https://horux360.consultoria-as.com/api/health
npx pm2 status
  • Step 6: Flush old refresh tokens
PGPASSWORD=<password> psql -h localhost -U postgres -d horux360 -c "DELETE FROM refresh_tokens;"

This forces all users to re-login and get new JWTs with databaseName instead of schemaName.

  • Step 7: Commit any final changes
git add -A
git commit -m "feat: production deployment configuration complete"

Chunk 8: Frontend Updates

Task 28: Update frontend for plan display and subscription info

Files:

  • Modify: apps/web/lib/api/client.ts — update tenant header logic if needed

  • Create: apps/web/lib/api/subscription.ts — API client for subscription endpoints

  • Create: apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx — subscription info page (client view)

  • Modify: apps/web/app/(dashboard)/clientes/page.tsx — add subscription status + payment link generation

  • Step 1: Create subscription API client

// apps/web/lib/api/subscription.ts
import { api } from './client';

export async function getSubscription(tenantId: string) {
  const { data } = await api.get(`/subscriptions/${tenantId}`);
  return data;
}

export async function generatePaymentLink(tenantId: string) {
  const { data } = await api.post(`/subscriptions/${tenantId}/generate-link`);
  return data;
}

export async function markAsPaid(tenantId: string, amount: number) {
  const { data } = await api.post(`/subscriptions/${tenantId}/mark-paid`, { amount });
  return data;
}

export async function getPaymentHistory(tenantId: string) {
  const { data } = await api.get(`/subscriptions/${tenantId}/payments`);
  return data;
}
  • Step 2: Create subscription info page for clients

A page at /configuracion/suscripcion that shows:

  • Current plan and status

  • Next billing date

  • Payment history table

  • Step 3: Update clientes page for admin

Add to each client card:

  • Subscription status badge

  • "Generar link de pago" button

  • "Marcar como pagado" button

  • Payment history expandable section

  • Step 4: Update sidebar/navigation for feature gating

In the navigation components, filter menu items based on the tenant's plan using hasFeature().

  • Step 5: Commit
git add apps/web/
git commit -m "feat: add subscription UI for clients and admin panel"

Implementation Order Summary

Order Task Description Depends on
1 Task 1 Environment config
2 Task 2 Prisma schema migration Task 1
3 Task 3 Shared types update
4 Task 4 TenantConnectionManager Task 1
5 Task 5 Tenant middleware rewrite Tasks 3, 4
6 Task 6 Auth service update Tasks 2, 3
7 Task 7 Tenants service rewrite Tasks 4, 6
8 Task 8 All tenant services migration Tasks 4, 5
9 Task 9 App entry + shutdown Task 4
10 Task 10 Cleanup old schema-manager Tasks 6, 7, 8
11 Task 11 SAT crypto update Task 1
12 Task 12 FIEL dual storage Tasks 2, 11
13 Task 13 FIEL decrypt CLI Task 12
14 Task 14 Email service + templates Task 1
15 Task 15 MercadoPago service Task 1
16 Task 16 Subscription + webhooks Tasks 2, 14, 15
17 Task 17 Subscription admin endpoints Task 16
18 Task 18 Plan enforcement middleware Tasks 2, 5
19 Task 19 Full provisioning integration Tasks 7, 14, 16
20 Task 20 FIEL email notification Tasks 12, 14
21 Task 21 PM2 production config
22 Task 22 Nginx config
23 Task 23 Backup script
24 Task 24 PostgreSQL tuning
25 Task 25 Server directories setup
26 Task 26 Nginx + SSL install Task 22
27 Task 27 Build + deploy All above
28 Task 28 Frontend updates Tasks 16, 17, 18