Initial commit: Horux Despachos project

This commit is contained in:
consultoria-as
2026-04-27 01:11:06 -06:00
commit 56a05ba767
604 changed files with 121723 additions and 0 deletions

View File

@@ -0,0 +1,797 @@
# 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` — flush all existing tokens at migration cutover (invalidate all sessions)
- `fiel_credentials` — no changes
New tables:
- `subscriptions` — MercadoPago subscription tracking
- `payments` — payment history
### Prisma schema migration
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
- Add `Subscription` and `Payment` models
- Run `prisma migrate dev` to generate and apply migration
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
### JWT payload migration
The current JWT payload embeds `schemaName`. This must change:
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
- Update `refreshTokens` function to embed `databaseName`
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
### Client DB naming
Formula: `horux_<rfc_normalized>`
```
RFC "HTS240708LJA" → 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`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
- `idleTimeoutMillis`: 300000 (5 min)
- `connectionTimeoutMillis`: 10000 (10 sec)
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
### 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.
### Admin impersonation (X-View-Tenant)
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
```typescript
// If admin is viewing another tenant
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
} else {
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
}
```
### 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
**Rollback on partial failure:** If any step 3-7 fails:
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
- Delete the `tenants` row
- Delete the `users` row if created
- Return error to admin with the specific step that failed
- The entire provisioning is wrapped in a try/catch with explicit cleanup
### 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/
├── HTS240708LJA/
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
│ ├── private_key.key.enc ← AES-256-GCM encrypted
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
└── ROEM691011EZ4/
├── certificate.cer.enc
├── private_key.key.enc
└── metadata.json.enc
```
**B. Central DB (`fiel_credentials` table):**
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
### Encryption
- Algorithm: AES-256-GCM
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
- Password is encrypted, never stored in plaintext
### Manual decryption CLI
```bash
node scripts/decrypt-fiel.js --rfc HTS240708LJA
```
- 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 also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
### 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 INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
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()
);
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
```
### 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.horux360.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
# Rate limiting zone definitions (in http block of nginx.conf)
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
server {
listen 80;
server_name horux360.horux360.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name horux360.horux360.com;
ssl_certificate /etc/letsencrypt/live/horux360.horux360.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/horux360.horux360.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;
# Health check (for monitoring)
location /api/health {
proxy_pass http://127.0.0.1:4000;
}
# 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 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
}
# 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;
}
}
```
### Health check endpoint
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
### SSL
Let's Encrypt with certbot. Auto-renewal via cron.
```bash
certbot --nginx -d horux360.horux360.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 **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
```bash
#!/bin/bash
# /var/horux/scripts/backup.sh
set -euo pipefail
BACKUP_DIR=/var/horux/backups
DATE=$(date +%Y-%m-%d)
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
DAILY_DIR=$BACKUP_DIR/daily
WEEKLY_DIR=$BACKUP_DIR/weekly
mkdir -p $DAILY_DIR $WEEKLY_DIR
# Backup central DB
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_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 database_name IS NOT NULL" horux360); do
db_trimmed=$(echo $db | xargs) # trim whitespace
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
done
# On Sundays, copy to weekly directory
if [ "$DOW" -eq 7 ]; then
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
fi
# Remove daily backups older than 7 days
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
# Remove weekly backups older than 28 days
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
# Verify backup files are not empty (catch silent pg_dump failures)
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
if [ ! -s "$f" ]; then
echo "WARNING: Empty backup file: $f" >&2
fi
done
```
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
### 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.horux360.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.horux360.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);
// Admin-impersonated requests bypass subscription check
// (admin needs to complete client setup regardless of payment status)
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
return next();
}
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
const allowedStatuses = ['authorized', 'pending'];
// Check subscription status
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow read-only access for cancelled/paused subscriptions
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
});
}
}
next();
}
```
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
### 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.
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
---
## 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

View File

@@ -0,0 +1,219 @@
# Modulo de Conciliacion — Spec
**Fecha:** 2026-04-12
**Estado:** Aprobado
---
## Objetivo
Permitir al usuario conciliar CFDIs emitidos y recibidos mes a mes, registrando fecha de pago y banco. Solo se permite conciliar del ano actual en adelante.
---
## Modelo de datos (BD tenant — raw SQL)
### Tabla `bancos` (nueva)
```sql
CREATE TABLE IF NOT EXISTS bancos (
id SERIAL PRIMARY KEY,
banco VARCHAR(100) NOT NULL,
terminacion_cuenta VARCHAR(4) NOT NULL,
creado_en TIMESTAMP DEFAULT NOW()
);
```
### Tabla `conciliaciones` (nueva)
```sql
CREATE TABLE IF NOT EXISTS conciliaciones (
id SERIAL PRIMARY KEY,
anio VARCHAR(4) NOT NULL,
mes VARCHAR(2) NOT NULL,
id_cfdi INTEGER NOT NULL UNIQUE REFERENCES cfdis(id),
fecha_de_pago DATE NOT NULL,
id_banco INTEGER NOT NULL REFERENCES bancos(id),
creado_en TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_anio_mes ON conciliaciones(anio, mes);
CREATE INDEX IF NOT EXISTS idx_conciliaciones_id_cfdi ON conciliaciones(id_cfdi);
```
### Columnas en `cfdis`
- `conciliado VARCHAR(50)` — ya existe. Se actualiza a `'true'` al conciliar, `NULL` al desconciliar.
- `id_conciliacion INTEGER REFERENCES conciliaciones(id)` — nueva. FK a la conciliacion asociada. NULL si no conciliado.
Al conciliar: se crean registros en `conciliaciones`, se actualiza `cfdis.conciliado = 'true'` y `cfdis.id_conciliacion = conciliaciones.id`.
Al desconciliar: se pone `cfdis.conciliado = NULL`, `cfdis.id_conciliacion = NULL`, y se elimina el registro de `conciliaciones`.
### DDL para tenants nuevos
Agregar `bancos`, `conciliaciones` en `database.ts` -> `createTables()` despues de `alertas`.
Agregar `id_conciliacion INTEGER REFERENCES conciliaciones(id)` en la tabla `cfdis`.
### Migracion para tenants existentes
`CREATE TABLE IF NOT EXISTS` para `bancos` y `conciliaciones`, luego `ALTER TABLE cfdis ADD COLUMN IF NOT EXISTS id_conciliacion INTEGER REFERENCES conciliaciones(id)`.
---
## Reglas de negocio
1. Solo se concilian CFDIs del **ano de alta del tenant en adelante** (se obtiene del `createdAt` del tenant en la BD central). Esto permite que una empresa registrada en 2025 pueda conciliar 2025, 2026, etc.
2. `anio` y `mes` de `conciliaciones` se derivan automaticamente de `fecha_de_pago`.
3. Un CFDI solo puede tener una conciliacion (`id_cfdi` es UNIQUE en conciliaciones, `id_conciliacion` en cfdis).
4. Solo CFDIs vigentes (`status NOT IN ('Cancelado', '0')`).
5. Al conciliar: INSERT en `conciliaciones` + UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <id>`.
6. Al desconciliar: UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` + DELETE de `conciliaciones`.
7. No se puede eliminar un banco que tenga conciliaciones asociadas.
---
## API endpoints
### Conciliacion
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/conciliacion` | Lista CFDIs con estado de conciliacion | JWT + Tenant |
| POST | `/conciliacion` | Conciliar CFDIs (batch) | JWT + Tenant + admin/contador |
| DELETE | `/conciliacion/:id` | Desconciliar un CFDI | JWT + Tenant + admin/contador |
#### `GET /conciliacion`
**Query params:**
- `tipo`: `EMITIDO` | `RECIBIDO` (requerido)
- `fechaInicio`, `fechaFin`: rango de fecha de emision
- `regimen`: clave de regimen fiscal (opcional)
- `estado`: `conciliado` | `pendiente` (opcional, default: todos)
**Response:** Array de CFDIs con campo adicional `conciliacion` (null si pendiente, objeto si conciliado):
```json
{
"id": 1,
"uuid": "...",
"rfcEmisor": "...",
"nombreEmisor": "...",
"total": 1000,
"totalMxn": 1000,
"fechaEmision": "...",
"conciliado": "true",
"idConciliacion": 5,
"conciliacion": {
"id": 5,
"fechaDePago": "2026-04-10",
"banco": "BBVA",
"terminacionCuenta": "1234"
}
}
```
#### `POST /conciliacion`
**Body:**
```json
{
"cfdiIds": [1, 2, 3],
"fechaDePago": "2026-04-10",
"idBanco": 1
}
```
**Logica:**
1. Validar que todos los CFDIs existen, estan vigentes, y no estan ya conciliados.
2. Validar que `fechaDePago` es del ano actual en adelante.
3. Derivar `anio` y `mes` de `fechaDePago`.
4. Para cada CFDI: INSERT en `conciliaciones`, UPDATE `cfdis` SET `conciliado = 'true'`, `id_conciliacion = <new id>`.
#### `DELETE /conciliacion/:id`
1. Buscar la conciliacion por id.
2. UPDATE `cfdis` SET `conciliado = NULL`, `id_conciliacion = NULL` WHERE `id_conciliacion = :id`.
3. DELETE FROM `conciliaciones` WHERE `id = :id`.
### Bancos
| Metodo | Ruta | Descripcion | Auth |
|--------|------|-------------|------|
| GET | `/bancos` | Listar bancos del tenant | JWT + Tenant |
| POST | `/bancos` | Crear banco | JWT + Tenant + admin |
| PUT | `/bancos/:id` | Editar banco | JWT + Tenant + admin |
| DELETE | `/bancos/:id` | Eliminar banco (si no tiene conciliaciones) | JWT + Tenant + admin |
---
## Frontend
### Pagina `/conciliacion`
**Acceso:** Feature-gated por `conciliacion` (Business, Enterprise). Roles: admin y contador (lectura+escritura), visor (solo lectura).
**Layout:**
```
[Header: "Conciliacion"]
[Filtros: PeriodSelector | RegimenSelector]
[Tabs: Emitidas | Recibidas]
[Seccion: "Por conciliar" — tabla con checkboxes]
[Barra de accion: Banco (dropdown) + Fecha de pago (date) + Boton "Conciliar"]
[Seccion: "Conciliadas" — tabla con info de conciliacion + boton desconciliar]
```
**Tabla "Por conciliar":**
- Checkbox (no visible para visor)
- UUID (corto), Fecha emision, RFC Emisor/Receptor, Nombre, Total MXN, Metodo Pago
- Boton "Ver factura" (CfdiViewerModal)
**Tabla "Conciliadas":**
- UUID, Fecha emision, RFC, Nombre, Total MXN
- Fecha de pago, Banco (nombre + terminacion)
- Boton "Desconciliar" (no visible para visor)
- Boton "Ver factura"
**Flujo de conciliacion:**
1. Usuario selecciona checkboxes en "Por conciliar"
2. Aparece barra de accion sticky en la parte inferior
3. Selecciona banco (dropdown de bancos del tenant) y fecha de pago
4. Click "Conciliar N facturas"
5. Confirmacion -> POST `/conciliacion` -> refresh datos
### Seccion de bancos en `/configuracion`
Solo visible para admin. Card con:
- Lista de bancos existentes: Nombre + terminacion + boton eliminar
- Formulario inline: Nombre banco + Terminacion (max 4 digitos) + boton agregar
### Navegacion
Agregar "Conciliacion" al sidebar con feature gate `conciliacion`, visible para admin, contador, visor. Ubicacion: despues de Reportes.
---
## Archivos a crear/modificar
### Backend (crear)
- `apps/api/src/services/conciliacion.service.ts`
- `apps/api/src/controllers/conciliacion.controller.ts`
- `apps/api/src/routes/conciliacion.routes.ts`
- `apps/api/src/services/bancos.service.ts`
- `apps/api/src/controllers/bancos.controller.ts`
- `apps/api/src/routes/bancos.routes.ts`
### Backend (modificar)
- `apps/api/src/app.ts` — registrar rutas de conciliacion y bancos
- `apps/api/src/config/database.ts` — agregar tablas `bancos` y `conciliaciones` en `createTables()`, agregar `id_conciliacion` en `cfdis`
### Frontend (crear)
- `apps/web/app/(dashboard)/conciliacion/page.tsx`
- `apps/web/lib/api/conciliacion.ts`
- `apps/web/lib/api/bancos.ts`
- `apps/web/lib/hooks/use-conciliacion.ts`
- `apps/web/lib/hooks/use-bancos.ts`
### Frontend (modificar)
- `apps/web/components/layouts/sidebar.tsx` (y variantes) — agregar nav item
- `apps/web/app/(dashboard)/configuracion/page.tsx` — agregar seccion de bancos
### Migracion
- Aplicar DDL a tenant existente (`horux_ede123456ab1`): crear tablas + agregar columna

View File

@@ -0,0 +1,35 @@
---
title: Segmentación inteligente de solicitudes SAT
status: implementado
created: 2026-04-12
---
# Segmentación Inteligente de Solicitudes SAT
## Problema actual
La sincronización segmenta mes por mes, generando 4 solicitudes SAT por mes (xml emitidos, xml recibidos, metadata emitidos, metadata recibidos). Para 6 años = 288 solicitudes, agotando la cuota diaria del SAT rápidamente.
## Lógica propuesta
1. **Primer paso: solicitud de metadata del rango completo** (una sola solicitud)
- Obtener el total de CFDIs reportados por el SAT
2. **Decidir tamaño de bloque según volumen:**
- `totalCfdis <= 15,000` → bloques de **6 meses**
- `totalCfdis > 15,000` → bloques de **2 meses**
3. **Por cada bloque:**
- Descargar XMLs vigentes (cfdi + DocumentStatus active)
- Descargar metadata de todos (vigentes + cancelados)
## Impacto en solicitudes
| Escenario | Actual (mes a mes) | Propuesto (6 meses) | Propuesto (2 meses) |
|-----------|-------------------|--------------------|--------------------|
| 6 años, pocos CFDIs | 288 solicitudes | 25 solicitudes | 73 solicitudes |
| 6 años, muchos CFDIs | 288 solicitudes | N/A | 73 solicitudes |
## Archivos a modificar
- `apps/api/src/services/sat/sat.service.ts``processInitialSync()`

View File

@@ -0,0 +1,186 @@
# Opinión de Cumplimiento — Integration Design
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 has no way to check or track a tenant's SAT compliance status (Opinión de Cumplimiento). This is a critical fiscal document that indicates whether a company is current on all tax obligations. Currently, users must manually download it from the SAT portal.
## Solution
Integrate the existing standalone Playwright-based prototype into Horux360 as a weekly automated process. Display results in a new "Documentos" page accessible to all roles (business+ plans). Store last 6 months in DB, show last 5 in UI. Alert if status is not Positiva.
## Source Prototype
Located at `C:\Users\chtr1\Downloads\sat-opinion-prototype`. Key files to adapt:
- `src/sat-login.ts` — Playwright navigation: public page → FIEL login → report
- `src/opinion-scraper.ts` — 4 strategies to extract PDF base64 from DOM
- `src/pdf-parser.ts` — Regex extraction of RFC, razón social, estatus, folio, cadena original
- `src/types.ts``OpinionCumplimiento`, `Obligacion` interfaces
## Architecture
### New Files
| File | Purpose |
|------|---------|
| `src/services/opinion-cumplimiento.service.ts` | Orchestration: decrypt FIEL → temp files → Playwright → parse → save to DB → cleanup |
| `src/services/sat/sat-opinion-login.ts` | Adapted sat-login.ts: works with temp file paths from decrypted FIEL Buffers |
| `src/services/sat/sat-opinion-scraper.ts` | Adapted opinion-scraper.ts: extracts PDF from SAT Angular SPA |
| `src/services/sat/sat-opinion-parser.ts` | Adapted pdf-parser.ts: regex extraction from PDF text |
| `src/controllers/documentos.controller.ts` | Endpoints: list opinions, download PDF, manual trigger |
| `src/routes/documentos.routes.ts` | Routes with tenantMiddleware + feature gate |
| `src/migrations/tenant/002_create_opiniones_cumplimiento.sql` | Tenant DB migration |
| `apps/web/app/(dashboard)/documentos/page.tsx` | Frontend: Documentos page with Opinión tab |
| `apps/web/lib/api/documentos.ts` | API client functions |
| `apps/web/lib/hooks/use-documentos.ts` | React Query hooks |
### Modified Files
| File | Change |
|------|--------|
| `src/jobs/sat-sync.job.ts` | Add weekly cron for opinion download |
| `src/services/alertas-auto.service.ts` | Add alert for non-Positiva status |
| `apps/web/components/layouts/sidebar.tsx` | Add Documentos nav item |
| `apps/api/package.json` | Add playwright, pdf-parse dependencies |
| `packages/shared/src/types/` | Add OpinionCumplimiento types |
## Database
### Table: `opiniones_cumplimiento` (per-tenant DB)
```sql
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
id SERIAL PRIMARY KEY,
rfc VARCHAR(14) NOT NULL,
razon_social VARCHAR(255),
estatus VARCHAR(50) NOT NULL,
folio VARCHAR(50),
cadena_original TEXT,
fecha_consulta TIMESTAMP NOT NULL,
pdf BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);
```
Migration file: `002_create_opiniones_cumplimiento.sql`
**Retention:** Records older than 6 months are deleted during the weekly cron run.
**UI display:** Only the last 5 records are shown via `ORDER BY fecha_consulta DESC LIMIT 5`.
## FIEL Security
The FIEL is stored encrypted (AES-256-GCM) in the central DB. For Playwright, which requires file paths:
1. `getDecryptedFiel(tenantId)` returns Buffers in memory
2. Write .cer and .key to `os.tmpdir()` with permissions `0o600`
3. Pass paths to Playwright `page.setInputFiles()`
4. Delete temp files in `finally` block (guaranteed cleanup even on error)
5. Password is only passed via `page.fill()` — never written to disk
Additional:
- Playwright runs headless in production (no `slowMo`)
- 3-minute timeout per tenant to prevent hanging processes
- Temp file names use `crypto.randomUUID()` to avoid collisions
## Cron Schedule
```
'0 4 * * 0' — Sundays 4:00 AM (America/Mexico_City)
```
Runs after the daily SAT sync (3:00 AM) to avoid overlap. Processes tenants sequentially (Playwright is heavy — no parallelism).
### Cron Flow
For each active tenant with FIEL configured:
1. Decrypt FIEL → write temp files
2. Launch Playwright headless → login → navigate to report
3. Extract PDF base64 from DOM → parse text
4. INSERT into `opiniones_cumplimiento`
5. DELETE records older than 6 months
6. Cleanup temp files
7. Close browser
Error handling: if one tenant fails, log error and continue to next. Don't stop the batch.
## API Endpoints
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/documentos/opiniones` | All roles | Last 5 opinions (metadata only, no PDF binary) |
| GET | `/api/documentos/opiniones/:id/pdf` | All roles | Download PDF as binary (Content-Type: application/pdf) |
| POST | `/api/documentos/opiniones/consultar` | Admin only | Trigger manual download for current tenant |
All routes use `tenantMiddleware` + `requireFeature('documentos')`.
### GET /api/documentos/opiniones response
```json
[
{
"id": 1,
"rfc": "HTS240708LJA",
"razonSocial": "HORUX 360 SA DE CV",
"estatus": "Positiva",
"folio": "26NC4144337",
"cadenaOriginal": "||HTS240708LJA|26NC4144337|...",
"fechaConsulta": "2026-04-13T20:59:00.000Z",
"createdAt": "2026-04-13T22:00:00.000Z"
}
]
```
## Auto-Alert
New alert in `alertas-auto.service.ts`:
```typescript
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null>
```
- Queries latest record from `opiniones_cumplimiento`
- If `estatus !== 'Positiva'` → returns alert with priority 'alta'
- Message: "Tu Opinión de Cumplimiento es {estatus}. Última consulta: {fecha}."
- No drill-down (Documentos page shows details)
- If no records exist → no alert
## Frontend
### Sidebar
```typescript
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }
```
Between Facturación and Usuarios in the navigation array. Visible to all roles.
### Page: `/documentos`
- Tab structure (future-proof for other document types): first tab "Opinión de Cumplimiento"
- Card/row per opinion showing:
- Fecha de consulta (formatted)
- Estatus badge (green=Positiva, red=Negativa, yellow=others)
- Folio
- Button to download PDF
- "Consultar ahora" button (admin only) triggers POST
- Empty state: "No hay opiniones registradas. La consulta automática se ejecuta cada semana."
- Loading/error states with React Query
### Dependencies
Add to `apps/api/package.json`:
- `playwright` (for Chromium automation)
- `pdf-parse` v2 (for PDF text extraction)
Post-install: `npx playwright install chromium` (needed on deploy)
## Scope Exclusions
- Parser for "Negativa" opinion obligations list (refine when sample PDF available)
- Email notifications on status change (only auto-alert for now)
- Multiple document types in the Documentos page (only Opinión de Cumplimiento in v1)
- PDF viewer in browser (download only)

View File

@@ -0,0 +1,106 @@
# Tenant Schema Migrations System
**Date:** 2026-04-13
**Status:** Approved
## Problem
Horux360 uses a database-per-tenant architecture. When schema changes are made to `createTables()` or `createIndexes()` in `TenantConnectionManager`, only newly provisioned tenants get the updated schema. Existing tenants' databases drift from the expected structure, requiring manual ALTER scripts.
## Solution
A numbered SQL migration system for tenant databases, with both eager (deploy-time) and lazy (on-connect) execution.
## Architecture
### Migration Files
```
apps/api/src/migrations/tenant/
001_initial_schema.sql # Current createTables() + createIndexes()
002_example_future.sql # Template for future changes
```
- Naming: `NNN_description.sql` (zero-padded 3 digits)
- Each file must be idempotent (use `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.)
- Files are read from disk at runtime, sorted by version number
### Schema Migrations Table (per tenant DB)
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT NOW()
);
```
Created automatically before running any migration.
### TenantMigrationRunner
New file: `apps/api/src/config/tenant-migrations.ts`
**Exported functions:**
- `getMigrationFiles()` — Reads and sorts SQL files from migrations directory
- `getPendingMigrations(pool)` — Compares files vs `schema_migrations` table, returns pending
- `migrate(pool, databaseName?)` — Applies pending migrations in order, each in its own transaction. Returns count of applied migrations.
- `migrateAll()` — Queries all active tenants from central DB, calls `migrate()` on each. Logs progress and errors per tenant. Does not stop on individual tenant failure.
### Integration Points
1. **`TenantConnectionManager.provisionDatabase()`** — Replace `createTables()` + `createIndexes()` calls with `migrate(pool)`. This applies all migrations (starting from 001) to new tenants.
2. **`TenantConnectionManager.getPool()`** — After creating or retrieving a pool, call `migrate(pool)` if not already verified this session. Uses `migratedPools: Set<string>` to cache which tenants have been checked. Cache clears on process restart.
3. **New Turborepo script `db:migrate-tenants`** — Runs `migrateAll()` for eager deployment. Added to `apps/api/package.json` and root `turbo.json`.
4. **`createTables()` and `createIndexes()`** — Removed from `TenantConnectionManager`. Their content moves to `001_initial_schema.sql`.
### Lazy Migration Cache
```typescript
// In TenantConnectionManager
private migratedPools: Set<string> = new Set();
```
- `getPool()` checks `migratedPools.has(tenantId)` before running migrations
- If not in set → run `migrate(pool)` → add to set
- Set clears on PM2 restart (new process = fresh set)
- `invalidatePool()` also removes from `migratedPools`
### Deploy Flow
```bash
git pull
pnpm install
pnpm build
pnpm db:migrate-tenants # Eager: apply to all tenants
pm2 restart all # Lazy: safety net on connect
```
### Adding Future Schema Changes
1. Create `NNN_description.sql` in `apps/api/src/migrations/tenant/`
2. Write idempotent SQL
3. Deploy — eager applies to all, lazy catches stragglers
## Scope Exclusions
- No rollback support
- No data migrations (DDL only; data scripts remain separate)
- No parallel execution (sequential per tenant)
- No distributed locking (single PM2 fork instance)
- No changes to Prisma/central DB migrations
## Files Changed
| File | Change |
|------|--------|
| `apps/api/src/config/tenant-migrations.ts` | NEW — TenantMigrationRunner |
| `apps/api/src/migrations/tenant/001_initial_schema.sql` | NEW — current createTables + createIndexes |
| `apps/api/src/config/database.ts` | MODIFY — remove createTables/createIndexes, add lazy migration in getPool, call migrate in provisionDatabase |
| `apps/api/src/scripts/migrate-tenants.ts` | NEW — eager migration CLI script |
| `apps/api/package.json` | MODIFY — add db:migrate-tenants script |
| `turbo.json` | MODIFY — add db:migrate-tenants task |

File diff suppressed because it is too large Load Diff