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>
701 lines
21 KiB
Markdown
701 lines
21 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
// Before
|
|
req.tenantSchema = schema;
|
|
await pool.query(`SET search_path TO "${schema}", public`);
|
|
|
|
// After
|
|
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
|
|
```
|
|
|
|
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
|
|
|
|
### Provisioning flow (new client)
|
|
|
|
1. Admin creates tenant via UI → POST `/api/tenants/`
|
|
2. Insert record in `horux360.tenants` with `database_name`
|
|
3. Execute `CREATE DATABASE horux_<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
|
|
|
|
```bash
|
|
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
|
|
|
|
```sql
|
|
CREATE TABLE subscriptions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
plan VARCHAR(20) NOT NULL,
|
|
mp_preapproval_id VARCHAR(100),
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
-- status: pending | authorized | paused | cancelled
|
|
amount DECIMAL(10,2) NOT NULL,
|
|
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
|
|
-- frequency: monthly | yearly
|
|
current_period_start TIMESTAMP,
|
|
current_period_end TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE payments (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
|
subscription_id UUID REFERENCES subscriptions(id),
|
|
mp_payment_id VARCHAR(100),
|
|
amount DECIMAL(10,2) NOT NULL,
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
-- status: approved | pending | rejected | refunded
|
|
payment_method VARCHAR(50),
|
|
paid_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
### Plans and pricing
|
|
|
|
Defined in `packages/shared/src/constants/plans.ts` (update existing):
|
|
|
|
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|
|
|------|---------------------|-------|-------|----------|
|
|
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
|
|
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
|
|
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
|
|
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
|
|
|
|
Prices are configured from admin panel, not hardcoded.
|
|
|
|
### Subscription flow
|
|
|
|
1. Admin creates tenant and assigns plan
|
|
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
|
|
3. Link is sent to client via email
|
|
4. Client pays → MercadoPago sends webhook
|
|
5. System activates subscription, records payment
|
|
|
|
### Webhook endpoint
|
|
|
|
`POST /api/webhooks/mercadopago` (public, no auth)
|
|
|
|
Validates webhook signature using `x-signature` header and `x-request-id`.
|
|
|
|
Events handled:
|
|
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
|
|
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
|
|
|
|
On payment failure or subscription cancellation:
|
|
- Mark tenant `active = false`
|
|
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
|
|
|
|
### Admin panel additions
|
|
|
|
- View subscription status per client (active, amount, next billing date)
|
|
- Generate payment link button
|
|
- "Mark as paid manually" button (for bank transfer payments)
|
|
- Payment history per client
|
|
|
|
### Client panel additions
|
|
|
|
- New section in `/configuracion`: "Mi suscripción"
|
|
- Shows: current plan, next billing date, payment history
|
|
- Client cannot change plan themselves (admin does it)
|
|
|
|
### Environment variables
|
|
|
|
```
|
|
MP_ACCESS_TOKEN=<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.
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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:**
|
|
```bash
|
|
cd apps/api && pnpm build # tsc → dist/
|
|
pnpm start # node dist/index.js
|
|
```
|
|
|
|
**Web:**
|
|
```bash
|
|
cd apps/web && pnpm build # next build → .next/
|
|
pnpm start # next start (optimized server)
|
|
```
|
|
|
|
### PM2 configuration
|
|
|
|
```javascript
|
|
// ecosystem.config.js
|
|
module.exports = {
|
|
apps: [
|
|
{
|
|
name: 'horux-api',
|
|
script: 'dist/index.js',
|
|
cwd: '/root/Horux/apps/api',
|
|
instances: 2,
|
|
exec_mode: 'cluster',
|
|
env: { NODE_ENV: 'production' }
|
|
},
|
|
{
|
|
name: 'horux-web',
|
|
script: 'node_modules/.bin/next',
|
|
args: 'start',
|
|
cwd: '/root/Horux/apps/web',
|
|
instances: 1,
|
|
exec_mode: 'fork',
|
|
env: { NODE_ENV: 'production' }
|
|
}
|
|
]
|
|
};
|
|
```
|
|
|
|
Auto-restart on crash. Log rotation via `pm2-logrotate`.
|
|
|
|
### Nginx reverse proxy
|
|
|
|
```nginx
|
|
server {
|
|
listen 80;
|
|
server_name horux360.consultoria-as.com;
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name horux360.consultoria-as.com;
|
|
|
|
ssl_certificate /etc/letsencrypt/live/horux360.consultoria-as.com/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/horux360.consultoria-as.com/privkey.pem;
|
|
|
|
# Security headers
|
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
add_header X-Frame-Options DENY;
|
|
add_header X-Content-Type-Options nosniff;
|
|
|
|
# Gzip
|
|
gzip on;
|
|
gzip_types text/plain application/json application/javascript text/css;
|
|
|
|
# Rate limiting for public endpoints
|
|
location /api/auth/ {
|
|
limit_req zone=auth burst=5 nodelay;
|
|
proxy_pass http://127.0.0.1:4000;
|
|
}
|
|
|
|
location /api/webhooks/ {
|
|
limit_req zone=webhooks burst=10 nodelay;
|
|
proxy_pass http://127.0.0.1:4000;
|
|
}
|
|
|
|
# API
|
|
location /api/ {
|
|
proxy_pass http://127.0.0.1:4000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
client_max_body_size 1G; # For bulk XML uploads
|
|
}
|
|
|
|
# Next.js
|
|
location / {
|
|
proxy_pass http://127.0.0.1:3000;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}
|
|
}
|
|
```
|
|
|
|
### SSL
|
|
|
|
Let's Encrypt with certbot. Auto-renewal via cron.
|
|
|
|
```bash
|
|
certbot --nginx -d horux360.consultoria-as.com
|
|
```
|
|
|
|
### Firewall
|
|
|
|
```bash
|
|
ufw allow 22/tcp # SSH
|
|
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
|
ufw allow 443/tcp # HTTPS
|
|
ufw enable
|
|
```
|
|
|
|
PostgreSQL only on localhost (no external access).
|
|
|
|
### Backups
|
|
|
|
Cron job at 2:00 AM daily:
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
# /var/horux/scripts/backup.sh
|
|
BACKUP_DIR=/var/horux/backups
|
|
DATE=$(date +%Y-%m-%d)
|
|
|
|
# Backup central DB
|
|
pg_dump -h localhost -U postgres horux360 | gzip > $BACKUP_DIR/horux360_$DATE.sql.gz
|
|
|
|
# Backup each tenant DB
|
|
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE active = true"); do
|
|
pg_dump -h localhost -U postgres $db | gzip > $BACKUP_DIR/${db}_${DATE}.sql.gz
|
|
done
|
|
|
|
# Remove backups older than 7 days
|
|
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
|
|
|
|
# Keep weekly backups (Sundays) for 4 weeks
|
|
# (daily cleanup skips files from Sundays in the last 28 days)
|
|
```
|
|
|
|
### Environment variables (production)
|
|
|
|
```
|
|
NODE_ENV=production
|
|
PORT=4000
|
|
DATABASE_URL=postgresql://postgres:<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
|
|
|
|
```typescript
|
|
// middleware: checkPlanLimits
|
|
async function checkPlanLimits(req, res, next) {
|
|
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
|
|
const subscription = await getActiveSubscription(tenant.id);
|
|
|
|
// Check subscription is active
|
|
if (!subscription || subscription.status !== 'authorized') {
|
|
// Allow read-only access
|
|
if (req.method !== 'GET') {
|
|
return res.status(403).json({
|
|
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
|
|
});
|
|
}
|
|
}
|
|
|
|
next();
|
|
}
|
|
```
|
|
|
|
### CFDI limit check
|
|
|
|
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
|
|
|
|
```typescript
|
|
async function checkCfdiLimit(req, res, next) {
|
|
const tenant = await getTenantWithCache(req.user.tenantId);
|
|
if (tenant.cfdiLimit === -1) return next(); // unlimited
|
|
|
|
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
|
|
const newCount = Array.isArray(req.body) ? req.body.length : 1;
|
|
|
|
if (currentCount + newCount > tenant.cfdiLimit) {
|
|
return res.status(403).json({
|
|
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
|
|
});
|
|
}
|
|
|
|
next();
|
|
}
|
|
```
|
|
|
|
### User limit check
|
|
|
|
Applied on `POST /api/usuarios/invite` (already partially exists):
|
|
|
|
```typescript
|
|
const userCount = await getUserCountForTenant(tenantId);
|
|
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
|
|
return res.status(403).json({
|
|
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
|
|
});
|
|
}
|
|
```
|
|
|
|
### Feature gating
|
|
|
|
Applied per route using the existing `hasFeature()` function from shared:
|
|
|
|
```typescript
|
|
function requireFeature(feature: string) {
|
|
return async (req, res, next) => {
|
|
const tenant = await getTenantWithCache(req.user.tenantId);
|
|
if (!hasFeature(tenant.plan, feature)) {
|
|
return res.status(403).json({
|
|
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Usage in routes:
|
|
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
|
|
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
|
|
```
|
|
|
|
### Feature matrix
|
|
|
|
| Feature key | Starter | Business | Professional | Enterprise |
|
|
|-------------|---------|----------|-------------|------------|
|
|
| dashboard | Yes | Yes | Yes | Yes |
|
|
| cfdi_basic | Yes | Yes | Yes | Yes |
|
|
| iva_isr | Yes | Yes | Yes | Yes |
|
|
| reportes | No | Yes | Yes | Yes |
|
|
| alertas | No | Yes | Yes | Yes |
|
|
| calendario | No | Yes | Yes | Yes |
|
|
| xml_sat | No | No | Yes | Yes |
|
|
| conciliacion | No | No | Yes | Yes |
|
|
| forecasting | No | No | Yes | Yes |
|
|
| multi_empresa | No | No | No | Yes |
|
|
| api_externa | No | No | No | Yes |
|
|
|
|
### Frontend feature gating
|
|
|
|
The sidebar/navigation hides menu items based on plan:
|
|
|
|
```typescript
|
|
const tenant = useTenantInfo(); // new hook
|
|
const menuItems = allMenuItems.filter(item =>
|
|
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
|
|
);
|
|
```
|
|
|
|
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
|
|
|
|
### Caching
|
|
|
|
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
|
|
|
|
---
|
|
|
|
## Architecture Diagram
|
|
|
|
```
|
|
┌─────────────────────┐
|
|
│ Nginx (443/80) │
|
|
│ SSL + Rate Limit │
|
|
└──────────┬──────────┘
|
|
│
|
|
┌──────────────┼──────────────┐
|
|
│ │ │
|
|
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
|
|
│ Next.js │ │ Express │ │ Webhook │
|
|
│ :3000 │ │ API x2 │ │ Handler │
|
|
│ (fork) │ │ :4000 │ │ (no auth) │
|
|
└───────────┘ │ (cluster)│ └──────┬──────┘
|
|
└────┬────┘ │
|
|
│ │
|
|
┌─────────▼──────────┐ │
|
|
│ TenantConnection │ │
|
|
│ Manager │ │
|
|
│ (pool per tenant) │ │
|
|
└─────────┬──────────┘ │
|
|
│ │
|
|
┌──────────────────┼──────┐ │
|
|
│ │ │ │
|
|
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
|
|
│ horux360 │ │horux_ │ │horux│ │
|
|
│ (central) │ │client1 │ │_... │ │
|
|
│ │ └────────┘ └─────┘ │
|
|
│ tenants │ │
|
|
│ users │◄────────────────────────┘
|
|
│ subs │ (webhook updates)
|
|
│ payments │
|
|
└───────────┘
|
|
|
|
┌───────────────┐ ┌─────────────┐
|
|
│ /var/horux/ │ │ Gmail SMTP │
|
|
│ fiel/<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
|