Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 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

View File

@@ -0,0 +1,166 @@
# Plan Custom — gratis, sin fecha fin, solo asignable por Admin Global
## Contexto
El owner pidió un plan "Custom" para casos donde quiere otorgar acceso al
sistema sin cobro y sin fecha de finalización (cortesía, beta tester, caso
especial). Solo el Admin Global puede asignarlo; los usuarios finales no
deben verlo en su catálogo de planes.
## Decisión clave — Reusar enum `custom`
El Plan enum de Prisma ya incluye `custom` (legacy: "precio variable por
tenant"). En dev hay **0 tenants** en ese plan, y la lógica antigua en
`subscription.service.ts` rechaza `custom` del flujo self-serve — patrón
que coincide con la nueva semántica. Reusar el enum evita migration y
mantiene compatibilidad.
## Reglas
- **Comportamiento**: idéntico a Mi Empresa (1 RFC, MANAGED, 50 timbres/mes,
features básicas, sin API ni Lolita).
- **Costo**: $0. No genera Subscription, no usa MercadoPago.
- **Vigencia**: indefinida. `tenant.trialEndsAt = null`. Sin
`currentPeriodEnd`. Ningún cron lo expira.
- **Visibilidad**: oculto del catálogo user-facing. Solo aparece como
opción en `/clientes` (admin global).
## Cambios — Catálogo
`packages/shared/src/constants/despacho-plans.ts`:
```ts
custom: {
name: 'Custom',
maxRfcs: 1,
maxUsers: 3,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 50,
dbMode: 'MANAGED' as const,
permiteServidorBackup: false,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
'calendario', 'conciliacion', 'documentos', 'facturacion',
'forecasting', 'xml_sat',
],
},
```
NO se agrega a `DESPACHO_PLAN_PRICES` (gratis). Helpers existentes:
- `permiteOverage('custom')``false` ✓ (ya retorna false porque solo
cubre business_control y business_cloud)
- `isDespachoPaidPlan('custom')``false` ✓ (idem)
- `permiteFrecuenciaMensual('custom')``false` ✓ (no está en
DESPACHO_PLAN_PRICES)
## Cambios — Frontend types
`apps/web/lib/api/tenants.ts`:
Extender el tipo del campo `plan` en `CreateTenantData` y `UpdateTenantData`:
```ts
type AdminAssignablePlan =
| 'starter' | 'business' | 'business_ia' | 'enterprise' // legacy Horux 360
| 'custom'; // nuevo
```
(Despacho paid plans NO se incluyen — esos van por self-serve del owner,
fuera de scope per acuerdo con owner.)
## Cambios — Página `/clientes` (admin)
`apps/web/app/(dashboard)/clientes/page.tsx`:
1. Reemplazar `PlanType` local por `AdminAssignablePlan` importado.
2. Eliminar el `planLabels` local (líneas 174-178) y `planColors` local
(líneas 180-184). Usar el `PLAN_LABELS` global que ya existe arriba
del archivo (cubre todo). Para colores, expandir el map global o
inline en el render.
3. Extender el `<Select>` del form para incluir `custom`:
```tsx
<SelectContent>
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitado</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
```
4. Cuando el plan seleccionado es `custom`, ocultar el campo `amount`
(no aplica) o forzarlo a 0.
## Cambios — Página `/configuracion/planes-despacho` (user)
`apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx`:
- Las cards visibles son `mi_empresa`, `mi_empresa_plus`,
`business_control`, `business_cloud`. `custom` NO aparece (no está en
ese array).
- Si `planInfo?.plan === 'custom'`: mostrar un banner read-only
prominente:
> "Estás en el plan **Custom** asignado por tu administrador. Contacta
> a soporte si necesitas cambiar."
Y NO renderizar las cards (o renderizarlas atenuadas con botones
deshabilitados).
## No-cambios
- Schema BD / migration — el enum `custom` ya existe.
- Backend `PUT /api/tenants/:id` — ya acepta cualquier valor del enum
Prisma (sin Zod gate). Cero cambios.
- `subscription.service.ts` — su lógica anti-`custom` existente sigue
vigente y coincide con el nuevo comportamiento (rechaza self-serve).
- `getMyPlan` en `despacho.controller.ts` — ya lee `tenant.plan`
directamente. Custom se reportará al frontend correctamente.
- Cron `applyPendingChanges` y `expireTrials` — Custom no tiene
Subscription ni trialEndsAt, no le afectan.
- Trial RFC limit (V.1.0.11) — Custom tiene `trialEndsAt=null`, así
que el limit de 5 no aplica. Aplica el límite duro del catálogo (1).
## Riesgos / limitaciones aceptadas
1. **Transición paid → custom**: si el admin cambia un tenant que tenía
suscripción MP activa a `custom`, el preapproval MP **sigue
cobrando** hasta que se cancele manualmente. Mitigación: el admin
debe cancelar la suscripción primero desde `/configuracion/suscripcion`
del tenant impersonado, luego asignar custom. Documentar en runbook.
2. **Transición custom → paid**: el admin NO puede asignar planes
despacho pagables desde `/clientes` (no incluidos en el dropdown).
El tenant debe pasar por self-serve normal en
`/configuracion/planes-despacho`. Esto evita el escenario de un
tenant en plan paid sin Subscription que sería inconsistente.
3. **Hard limit de 1 RFC en custom**: igual que Mi Empresa, el límite
de 1 RFC para custom es solo billing-only hoy (no enforced en
`contribuyente.controller.ts:create`). Si se quiere enforce duro,
replicar el patrón del trial limit. Out of scope.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. **Admin asigna custom**: desde `/clientes`, edit tenant, seleccionar
"Custom", guardar. Verificar `tenant.plan === 'custom'` en BD.
3. **Admin asigna custom a tenant en trial**: trialEndsAt debería
limpiarse (a través de la lógica del service). Si el service no lo
limpia, agregar.
4. **User en custom**: login como ese tenant, ir a
`/configuracion/planes-despacho` → ver banner "Estás en plan Custom".
5. **Admin asigna otro plan a tenant en custom**: dropdown muestra los
demás planes legacy. Asignación funciona.
6. **`getMyPlan` retorna custom**: `/api/despachos/me/plan` retorna
`{ plan: 'custom', isTrialActive: false, ... }`.
## Implementación
~30 líneas netas en 4 archivos:
- `despacho-plans.ts` — agregar entrada custom (~12 líneas).
- `tenants.ts` (api client) — extender tipos (~3 líneas).
- `clientes/page.tsx` — dropdown + cleanup (~10 líneas).
- `planes-despacho/page.tsx` — banner Custom (~10 líneas).
Cambio chico, hago directo sin subagents. Una commit en Downloads + V.1.0.14
en OneDrive.

View File

@@ -0,0 +1,108 @@
# Drill-down genérica — sort por nombre emisor/receptor
## Contexto
La tabla de la página `/drill-down` (apps/web/app/(dashboard)/drill-down/page.tsx)
actualmente permite ordenar por `Fecha`, `Total MXN`, `Monto Pago` e `IVA Trasl.`
mediante el hook `useTableSort` y el componente `SortableHeader` de
`@horux/shared-ui`. Las columnas `Nombre Emisor` y `Nombre Receptor` se
renderizan como `<th>` planos no ordenables.
## Objetivo
Permitir ordenar también por nombre del emisor y por nombre del receptor,
sin remover ninguna de las columnas ordenables existentes.
Alcance limitado a la drill-down genérica. Las 9 páginas de `/alertas/*` quedan
fuera de este cambio (decisión del owner — se evaluarán después).
## Cambios
Archivo único: `apps/web/app/(dashboard)/drill-down/page.tsx`.
1. Extender el segundo parámetro de tipo de `useTableSort` para incluir las
nuevas keys:
```ts
useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva' | 'emisor' | 'receptor'>
```
2. Agregar dos accesores al objeto pasado al hook:
```ts
emisor: (c) => c.nombreEmisor || '',
receptor: (c) => c.nombreReceptor || '',
```
`useTableSort` ya soporta accesores de tipo `string` — usa
`String.prototype.localeCompare` cuando ambos valores son strings, lo cual
maneja la collation del español correctamente.
3. Reemplazar los dos `<th>` planos por `SortableHeader`:
```tsx
// antes
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
// después
<SortableHeader label="Nombre Emisor"
active={getSortIndicator('emisor')}
onClick={() => toggleSort('emisor')} />
<SortableHeader label="Nombre Receptor"
active={getSortIndicator('receptor')}
onClick={() => toggleSort('receptor')} />
```
4. Mantener el `initialKey = 'fecha'` y `initialDir = 'desc'` (default actual).
## No-cambios
- No se tocan: `useTableSort`, `SortableHeader`, ni cualquier otro archivo en
`@horux/shared-ui`.
- No se tocan controllers ni services del API. El sort es 100% client-side.
- No se tocan las columnas RFC Emisor, RFC Receptor, UUID, Comp., M. Pago,
Reg. E ni Reg. R — siguen siendo `<th>` planos no ordenables.
- No se modifica el export a Excel: ya consume `sortedData`, así que el orden
vigente del usuario se respeta automáticamente.
## Comportamiento esperado
- Click sobre "Nombre Emisor": ordena ascendente por nombre. Re-click:
descendente. Cambia el sort activo (un solo sort a la vez, ya es el
contrato del hook).
- Click sobre "Nombre Receptor": idéntico, reemplaza al sort previo.
- Filas con `nombreEmisor` o `nombreReceptor` null/undefined: el accesor
retorna string vacío `''`, así que en `asc` aparecen primero. Es el
comportamiento estándar de `localeCompare` y se considera aceptable
(un CFDI sin nombre emisor/receptor es raro y debería ser visible al
ordenar por nombre).
## Riesgo
Mínimo:
- Cambio puramente client-side, una sola página, ~6 líneas netas.
- No introduce dependencias nuevas.
- `pnpm typecheck` debería seguir limpio (las nuevas keys están dentro del
union genérico, los accesores cumplen el contrato `(row: T) => number | string`).
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir en 0 errores.
2. Abrir `/drill-down` desde cualquier KPI del dashboard.
3. Click en "Nombre Emisor" → verificar orden alfabético ascendente y flecha
en el header. Re-click → descendente.
4. Click en "Nombre Receptor" → mismo comportamiento.
5. Click en "Fecha" / "Total MXN" → confirmar que los sorts pre-existentes
siguen funcionando.
6. Exportar a Excel después de ordenar por "Nombre Emisor" → confirmar que
el archivo descargado mantiene el mismo orden.
## Pendientes derivados
- Replicar el patrón en las 9 páginas de `/alertas/*` (cancelaciones,
cancelaciones-periodo-anterior, efectivo, tipo-relacion-sospechosa,
concentracion-clientes, concentracion-proveedores, discrepancia-regimen,
lista-negra-clientes, lista-negra-proveedores). Decisión del owner cuándo
abordarlas. Para `lista-negra-*` además habrá que introducir
`useTableSort` desde cero (hoy no lo usan).

View File

@@ -0,0 +1,347 @@
# Filtros "Considerar activos" y "Considerar NCs" en /impuestos — Fase 1
## Contexto
La pestaña ISR e IVA de `/impuestos` actualmente solo tiene un toggle de
"Conciliación" que cambia la semántica de fechas. El owner pidió dos toggles
adicionales:
1. **Considerar activos** — cuando ACTIVADO, incluye facturas tipo I con
`uso_cfdi` ∈ {I01, I02, I03, I04, I05, I06, I07, I08} (compras de activos
fijos / inversiones). Cuando DESACTIVADO, excluye esas facturas.
2. **Considerar NCs** — cuando ACTIVADO, incluye facturas tipo E con
`cfdi_tipo_relacion = '01'` (notas de crédito). Cuando DESACTIVADO, las
excluye.
### Decisión de defaults
**Default ambos toggles ON (incluir)** — revertido del default original OFF
por concerns de performance: con default OFF, el cache `metricas_mensuales`
quedaría siempre bypass-eado en `/impuestos` hasta Fase 2. Con default ON,
las cargas iniciales aprovechan el cache (comportamiento idéntico al de
versiones previas), y el contador opt-in al view filtrado cuando lo necesita.
Trade-off aceptado: el contador debe **desactivar manualmente** los toggles
cuando quiere ver números sin activos / sin NCs. La lógica fiscal de
"depreciación de activos" requiere consciencia del contador, no se aplica
silenciosamente.
Los filtros aplican **solo** en la pestaña Impuestos (IVA + ISR). Dashboard,
reportes, drill-downs, alertas y demás permanecen intactos.
## Justificación fiscal
- Los activos fijos (uso I01-I08) deben depreciarse, no deducirse en su mes
de adquisición. Excluirlos del cálculo provisional mensual evita inflar las
deducciones. La pestaña dedicada "Activos Fijos" (en `/impuestos`) es donde
se muestra y gestiona esa información.
- Las NCs tipoRel=01 son ajustes a documentos previos. El owner quiere ver
los números **brutos sin ajustes** por default y opt-in con el toggle. Asume
el riesgo de over-reporting si el contador olvida activarlo.
## Fases
- **Fase 1 (este spec):** UI + backend con live query. Sin cambios al cache
`metricas_mensuales`. Cuando los toggles están en su default (OFF), el cache
queda bypass-eado y todo es live query.
- **Fase 2 (spec posterior):** extender `metricas_mensuales` con columnas
base + 2 deltas para hacer el toggle instantáneo (computado por suma/resta).
## Cambios — Frontend
### `apps/web/app/(dashboard)/impuestos/page.tsx`
State nuevo (defaults `true` = filter active = incluir, cache-friendly):
```ts
const [considerarActivos, setConsiderarActivos] = useState(true);
const [considerarNCs, setConsiderarNCs] = useState(true);
```
Con defaults `true`, las cargas iniciales aprovechan el cache de
`metricas_mensuales`. El gate `!conciliacion && considerarActivos && considerarNCs`
queda en `true` por default y permite cache hit. El contador opt-in al view
filtrado desactivando los toggles cuando lo necesita.
UI: 2 toggle buttons en la misma fila que "Conciliación", mismo styling.
Orden recomendado: `Régimen | Conciliación | Considerar activos | Considerar NCs`.
```tsx
<button
onClick={() => setConsiderarActivos(!considerarActivos)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
considerarActivos
? 'bg-primary/10 text-primary border border-primary/30'
: 'hover:bg-accent'
)}
title="Si está inactivo, no se consideran facturas tipo I con uso de CFDI I01-I08 (compras de activos fijos)."
>
<CheckSquare className="h-4 w-4" />
Considerar activos
</button>
```
(Análogo para Considerar NCs con tooltip "...facturas tipo E con tipo de
relación 01 (notas de crédito).")
Pasar a todos los hooks consumidos en la pestaña ISR e IVA.
### `apps/web/lib/hooks/use-impuestos.ts`
Extender 5 hooks. Ejemplo:
```ts
export function useResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
) {
const tk = useTenantKey();
const { selectedContribuyenteId } = useContribuyenteStore();
return useQuery({
queryKey: ['isr-resumen-desglosado', tk, fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId],
queryFn: () => impuestosApi.getResumenIsrDesglosado(fechaFin, conciliacion, considerarActivos, considerarNCs, selectedContribuyenteId),
enabled: !!fechaFin,
});
}
```
Aplicar el mismo patrón a `useResumenIsr`, `useResumenIva`, `useIsrMensual`,
`useIvaMensual`.
### `apps/web/lib/api/impuestos.ts`
Extender funciones HTTP. Ejemplo:
```ts
export async function getResumenIsrDesglosado(
fechaFin: string,
conciliacion?: boolean,
considerarActivos?: boolean,
considerarNCs?: boolean,
contribuyenteId?: string | null,
): Promise<ResumenIsrDesglosado> {
const params = new URLSearchParams();
params.set('fechaFin', fechaFin);
if (conciliacion) params.set('conciliacion', 'true');
if (considerarActivos) params.set('considerarActivos', 'true');
if (considerarNCs) params.set('considerarNCs', 'true');
if (contribuyenteId) params.set('contribuyenteId', contribuyenteId);
const response = await apiClient.get<ResumenIsrDesglosado>(`/impuestos/isr/resumen-desglosado?${params}`);
return response.data;
}
```
(Análogo para `getResumenIsr`, `getResumenIva`, `getIsrMensual`, `getIvaMensual`.)
## Cambios — Backend
### Helper compartido en `apps/api/src/services/impuestos.service.ts`
```ts
/**
* Construye fragmentos AND adicionales para WHERE clauses según los toggles
* "Considerar activos" y "Considerar NCs" en la UI de impuestos.
*
* - considerarActivos === false → excluir facturas tipo I con uso de CFDI I01-I08.
* - considerarNCs === false → excluir facturas tipo E con cfdi_tipo_relacion = '01'.
*
* Cuando ambos son true (default backend), retorna string vacío. Esto preserva
* el comportamiento histórico para callers que no pasan los flags (ej. dashboard).
*/
function buildExtraFilters(considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (tipo_comprobante = 'E' AND COALESCE(cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
### Funciones modificadas
Agregar 2 parámetros booleanos opcionales con default `true` (= include
todo, comportamiento histórico). Forman el último par de la signature.
| Función | Archivo | Cambio |
|---|---|---|
| `calcularIngresosPorRegimen` | `dashboard.service.ts` | +`considerarActivos=true, considerarNCs=true`, concatenar `buildExtraFilters(...)` al WHERE |
| `calcularEgresosPorRegimen` | `dashboard.service.ts` | Idem |
| `getResumenIva` | `impuestos.service.ts` | Idem + propagar al cache gate (ver abajo) |
| `getIvaMensual` | `impuestos.service.ts` | Idem |
| `getResumenIsr` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getIsrMensual` | `impuestos.service.ts` | Idem + propagar a `calcular*PorRegimen` |
| `getResumenIsrDesglosado` | `impuestos.service.ts` | Idem + propagar a las 3 llamadas a `getResumenIsr` |
**Importante**: como `buildExtraFilters` está en `impuestos.service.ts` y
`calcular*PorRegimen` viven en `dashboard.service.ts`, hay que **mover el
helper a un módulo compartido** o duplicarlo. Recomendación: mover a un
nuevo `apps/api/src/services/_shared/cfdi-filters.ts` (módulo neutral
reutilizable). Ambos services lo importan.
### Aplicación del fragmento
Concatenar al WHERE de TODA query que escanee `cfdis` dentro de las funciones
afectadas. Buscar patrón `WHERE ${VIGENTE} AND ${FR}` y agregar
`${buildExtraFilters(...)}` al final del WHERE.
Ejemplo en una query existente:
```ts
const FR = getFR(conciliacion);
const extra = buildExtraFilters(considerarActivos, considerarNCs);
const { rows } = await pool.query(`
SELECT regimen_fiscal_emisor as regimen, ...
FROM cfdis
WHERE ${VIGENTE} AND ${FR}${extra}
AND ${ctx.esEmisor}
GROUP BY regimen_fiscal_emisor
`, [fechaInicio, fechaFin]);
```
`buildExtraFilters` ya retorna con leading space, así que se concatena directo.
**Subqueries con alias** (`SUM_E_REFERENCING_*`): el alias `e` para la tabla
externa requiere referenciar columnas como `e.tipo_comprobante`,
`e.uso_cfdi`, `e.cfdi_tipo_relacion`. Necesitamos una variante del helper que
acepte alias:
```ts
function buildExtraFiltersAlias(alias: string, considerarActivos: boolean, considerarNCs: boolean): string {
const parts: string[] = [];
if (!considerarActivos) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08'))`);
}
if (!considerarNCs) {
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E' AND COALESCE(${alias}.cfdi_tipo_relacion, '') = '01')`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
```
Y se usa donde aparezcan subqueries con alias `e` (ej. `SUM_E_REFERENCING_*`,
`HAS_E_REFERENCING_MISMO_MES`, `E_REFERENCIA_I_PPD_07_MISMO_MES` si existe).
### Controllers — `apps/api/src/controllers/impuestos.controller.ts`
Helper para parsear (junto a `parseConciliacion`):
```ts
function parseFlag(req: Request, key: string, defaultValue = true): boolean {
const v = req.query[key];
if (v === undefined || v === null) return defaultValue;
return v === 'true' || v === '1';
}
```
Cada handler relevante (`getResumenIva`, `getIvaMensual`, `getResumenIsr`,
`getIsrMensual`, `getResumenIsrDesglosado`) parsea los 2 nuevos flags con
**default `true`** y los pasa al service.
```ts
const considerarActivos = parseFlag(req, 'considerarActivos', true);
const considerarNCs = parseFlag(req, 'considerarNCs', true);
```
**Razón del default `true` en el controller**: si por algún motivo el query
param no llega (cliente legacy, prueba manual, otro consumer), comportamiento
es como antes (todo incluido). El frontend siempre manda el flag explícito,
así que en la práctica el default solo aplica al testing externo.
### Cache gate en `getResumenIva` (línea ~322)
Extender la condición:
```ts
if (
!conciliacion &&
considerarActivos && // new — cache solo aplica con backend default
considerarNCs && // new
contribuyenteId &&
...
) {
const cached = await readResumenIvaFromCache(...);
if (cached) return cached;
}
```
Con UI default (ambos toggles ON), `considerarActivos=true && considerarNCs=true`
→ cache hit (comportamiento idéntico a versiones previas). Cuando el contador
desactiva alguno → cache bypass → live query (~1-3s). Aceptable porque el
desactivado es action consciente, no la carga inicial. Fase 2 hará los toggles
instantáneos vía cache base+deltas.
## No-cambios
- **Schema BD**: ninguno. SQL puro.
- **Cache `metricas_mensuales`**: estructura intacta. Solo se actualiza el gate.
- **Dashboard, reportes, drill-downs, alertas**: comportamiento idéntico
(gracias a defaults `true` en `calcular*PorRegimen`).
- **Activos Fijos tab**: usa su propio `activos-fijos.service.ts`, no pasa
por las funciones filtradas. Verificar en el smoke.
- **`getRegimenesDelPeriodo`** y otros que NO calculan ingresos/deducciones
no se modifican. Los regímenes disponibles en el dropdown siguen siendo
los mismos (basados en presencia de CFDIs, no filtrados por estos toggles).
## Riesgos
1. **Tocar funciones compartidas con dashboard**: `calcular*PorRegimen` viven
en `dashboard.service.ts`. Default `true` debería preservar el dashboard,
pero hay que verificar manualmente post-deploy.
2. **Performance Fase 1**: con UI default ON (cache-friendly), las cargas
iniciales son rápidas. Solo cuando el contador desactiva un toggle hay
live query. Fase 2 elimina ese delay también.
3. **Subqueries con alias**: hay 5+ subqueries con alias `e` en
`impuestos.service.ts` (rama I PPD/07). Cada una necesita el helper alias.
Riesgo de olvidar una → resultados inconsistentes.
4. **NCs default OFF puede sobre-reportar ingresos**: el contador puede no
notar que las NCs están excluidas si no lee el tooltip. Mitigación:
tooltip claro y label "Considerar NCs" (lectura obvia).
## Plan de pruebas (smoke)
1. **Typecheck**: `pnpm --filter @horux/shared typecheck`,
`pnpm --filter @horux/api typecheck`. Ambos PASS sin errores.
2. **Dashboard regression**: abrir `/dashboard` → KPIs (ingresos, gastos,
utilidad) deben tener los mismos valores que antes del deploy.
3. **Activos Fijos tab**: abrir `/impuestos` → pestaña "Activos Fijos" → la
tabla debe seguir mostrando todas las facturas I con uso I01-I08.
4. **UI default (ambos toggles OFF)**: cargar `/impuestos` ISR. Verificar
que ingresos del periodo y deducciones son menores que antes (excluyen
activos + NCs tipoRel=01).
5. **Toggle "Considerar activos" ON**: deducciones suben con la suma de los
activos del periodo.
6. **Toggle "Considerar NCs" ON**: comportamiento depende del lado:
- Como receptor (NC recibida que cancela una factura PUE): deducciones
bajan (la NC resta).
- Como emisor (NC emitida que cancela una factura PUE propia): ingresos
bajan.
7. **Combinaciones de los 3 toggles** (Conciliación + Activos + NCs): ocho
combinaciones, números deben ser consistentes.
8. **IVA tab**: mismas pruebas (toggle on/off, comparar números).
9. **Tabla "Histórico ISR"**: debe respetar los 2 nuevos toggles también
(cada fila refleja los acumulados con los filtros activos).
10. **Sección "Cálculo de ISR del Periodo"**: las 3 ramas (`delPeriodo`,
`anteriores`, `total`) deben respetar los toggles consistentemente.
## Pendientes derivados
- **Fase 2**: extender `metricas_mensuales` con columnas
`*_activos`, `*_ncs_01` (×3 métricas IVA = 6 columnas nuevas).
Migration + recompute del cache + actualizar lectura del cache para hacer
suma/resta según toggles. Fase 2 entrega toggles instantáneos.
- **Tooltip + iconos**: si el owner quiere distinguir visualmente los 3
toggles (Conciliación con un check, Activos con un asset icon, NCs con un
document icon), aplicar después.
- **Persistencia de los toggles**: hoy el state vive en `useState`, se pierde
al recargar. Si se quiere persistir, considerar `localStorage` o agregar a
`tenant-view-store`. Out-of-scope para Fase 1.
- **Dashboard parity**: si en el futuro el owner quiere los mismos toggles en
`/dashboard`, ya está habilitado por la signature de `calcular*PorRegimen`
— solo falta UI + propagación. Out-of-scope.

View File

@@ -0,0 +1,298 @@
# ISR — Base gravable acumulada y desglose del periodo
## Contexto
En `/impuestos` (pestaña ISR) hay dos lugares donde la base gravable se calcula
mes a mes en lugar de acumulado, lo cual es fiscalmente incorrecto para pagos
provisionales mensuales:
1. **Tabla "Histórico ISR"** (`apps/web/app/(dashboard)/impuestos/page.tsx`,
líneas ~503-568): cada fila aplica `Math.max(0, ing_mes ded_mes)` por mes
independiente. Resultado: un mes con pérdida no reduce el acumulado.
2. **Sección "Cálculo de ISR Acumulado"** (mismas líneas ~371-432): muestra los
totales del rango filtrado en `resumenIsr`, sin distinguir lo que ya estaba
acumulado de meses previos del mismo año vs. el periodo actual.
El bug raíz vive en `getIsrMensual` (`apps/api/src/services/impuestos.service.ts`,
líneas 409-486): el query corre de `${año}-${mm}-01` a fin de mes, así que el
campo nombrado `ingresosAcumulados` en `IsrMensual` realmente trae solo el mes
(deuda heredada del refactor previo, el nombre miente).
## Objetivo
Mostrar la base gravable y los montos acumulados correctamente:
1. En la tabla, agregar columnas **Ingresos Acum.**, **Deducciones Acum.** y
**Base Gravable Acum.** (estas tres son running totals desde enero hasta el
mes de cada fila). La **BG mensual desaparece** del display — solo queda la
acumulada, que es la única fiscalmente válida.
2. En la sección de cálculo, presentar el desglose como aparece en el formato
14 (declaración provisional mensual del SAT):
```
Ingresos del periodo + Ingresos anteriores
Deducciones del periodo Deducciones anteriores
= Base gravable acumulada
```
Donde **"del periodo" = mes final del filtro** y **"anteriores" = enero
hasta el mes anterior al final**.
## Reglas fiscales
- **No se aplica `max(0, ...)` al display** de base gravable. Los déficits son
reales y se muestran negativos (en rojo). Si filtras febrero y enero tuvo
utilidad pero febrero pérdida grande, `BG_acum_feb` puede ser negativa.
- **`max(0, ...)` se aplica únicamente al pasar a ISR causado**: si
`BG_acum < 0`, ISR causado = 0. SAT hace lo mismo en el formato 14.
- **El año fiscal se resetea en enero**. "Anteriores" jamás cruza a años previos.
## Cambios — Backend
### `apps/api/src/services/impuestos.service.ts`
**`getIsrMensual` (líneas 409-486):**
Después del loop que llena `result[]` con datos mensuales, agregar un segundo
pase que computa los running totals:
```ts
let ingAcum = 0, dedAcum = 0;
for (const row of result) {
ingAcum += row.ingresosAcumulados; // (mensual, a pesar del nombre)
dedAcum += row.deducciones;
row.ingresosAcum = ingAcum;
row.deduccionesAcum = dedAcum;
row.baseGravableAcum = ingAcum - dedAcum; // sin clamp
}
```
Nota sobre naming: el campo existente `ingresosAcumulados` en `IsrMensual` se
mantiene por compat (es el mensual). Los nuevos campos son `ingresosAcum`,
`deduccionesAcum`, `baseGravableAcum`. En el spec del rename total al final
puede ocurrir, pero no es scope de este cambio.
**Nueva función exportada** `getResumenIsrDesglosado`:
```ts
export async function getResumenIsrDesglosado(
pool: Pool,
fechaFin: string,
tenantId: string,
conciliacion?: boolean,
contribuyenteId?: string | null,
): Promise<{
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}>
```
Lógica:
1. Derivar `año = fechaFin.year`, `mesFinal = fechaFin.month`.
2. Tres rangos:
- **delPeriodo**: `${año}-${mesFinal}-01` a fin de `mesFinal` (solo mes final)
- **anteriores**: `${año}-01-01` a `${año}-${mesFinal-1}-${ultDia}` (Ene a mesFinal-1; vacío si mesFinal=1)
- **total**: `${año}-01-01` a fin de `mesFinal` (Ene a mesFinal)
3. Llamar `getResumenIsr` 3 veces con esos rangos, retornar el objeto.
Caso `mesFinal=1`: retornar `anteriores` con todos los campos en cero (no se hace
query inútil).
### `apps/api/src/controllers/impuestos.controller.ts`
Agregar handler `getResumenIsrDesglosado`:
```ts
// GET /api/impuestos/resumen-isr-desglosado?fechaFin=...&conciliacion=...&contribuyenteId=...
```
El filtro por régimen no se pasa al endpoint — el frontend hace el lookup
contra `resumenIsr.baseGravablePorRegimen[]` igual que hoy con `useResumenIsr`,
para que la lógica de filtrado siga centralizada en un solo lugar.
### `apps/api/src/routes/impuestos.routes.ts`
Agregar la ruta `/resumen-isr-desglosado` con los mismos middlewares que
`/resumen-isr` (auth + tenant + plan limits).
## Cambios — Shared types
### `packages/shared/src/types/reportes.ts` (o donde viva `IsrMensual`)
Agregar campos al type:
```ts
export interface IsrMensual {
// ...campos existentes
ingresosAcum: number;
deduccionesAcum: number;
baseGravableAcum: number; // sin clamp, puede ser negativo
}
export interface ResumenIsrDesglosado {
delPeriodo: ResumenIsr;
anteriores: ResumenIsr;
total: ResumenIsr;
}
```
## Cambios — Frontend
### `apps/web/lib/api/impuestos.ts`
Agregar función `getResumenIsrDesglosado` (cliente HTTP) y hook
`useResumenIsrDesglosado` en `apps/web/lib/hooks/use-impuestos.ts`.
### `apps/web/app/(dashboard)/impuestos/page.tsx`
**Tabla "Histórico ISR" (líneas ~502-568):**
Headers (6 columnas):
```
Mes | Ingresos | Ingresos Acum. | Deducciones | Deducciones Acum. | Base Gravable Acum.
```
Body por fila:
```tsx
<td>{meses[row.mes - 1]}</td>
<td className="text-right">{formatCurrency(row.ingresosAcumulados)}</td> // mensual
<td className="text-right">{formatCurrency(row.ingresosAcum)}</td>
<td className="text-right">{formatCurrency(row.deducciones)}</td>
<td className="text-right">{formatCurrency(row.deduccionesAcum)}</td>
<td className={cn(
"text-right font-medium",
row.baseGravableAcum < 0 ? "text-destructive" : ""
)}>{formatCurrency(row.baseGravableAcum)}</td>
```
Fila Total: eliminar. La última fila (diciembre) ya es el total YTD, no hace
falta sumar acumulados (sería incorrecto). Si se quiere conservar, mostrar
solo los mensuales sumados (= total año) y el último valor acumulado de la
columna BG Acum.
**Decisión por defecto en este spec:** eliminar fila Total. Si el usuario
prefiere conservarla, lo discutimos al implementar.
Export Excel: 6 columnas alineadas con UI:
```ts
[
{ header: 'Mes', key: 'Mes' },
{ header: 'Ingresos', key: 'Ingresos' },
{ header: 'Ingresos Acumulados', key: 'IngresosAcum' },
{ header: 'Deducciones', key: 'Deducciones' },
{ header: 'Deducciones Acumuladas', key: 'DeduccionesAcum' },
{ header: 'Base Gravable Acumulada', key: 'BaseGravableAcum' },
]
```
**Sección "Cálculo de ISR del Periodo" (líneas ~371-432):**
1. Renombrar `<CardTitle>` de "Cálculo de ISR Acumulado" a "Cálculo de ISR
del Periodo".
2. Reemplazar el query `useResumenIsr(fechaInicio, fechaFin, conciliacion)` por
`useResumenIsrDesglosado(fechaFin, conciliacion, contribuyenteId)`. El
filtro por régimen se aplica del lado frontend contra
`total.baseGravablePorRegimen[]` (mismo patrón que hoy).
3. Layout nuevo del card content:
```tsx
<div className="space-y-2">
<FilaDesglose label={`Ingresos del periodo (${labelMesFinal})`} value={delPeriodo.ingresos} />
<FilaDesglose label={`(+) Ingresos acumulados anteriores ${labelAnteriores}`} value={anteriores.ingresos} />
<FilaDesglose label={`() Deducciones del periodo`} value={delPeriodo.deducciones} negative />
<FilaDesglose label={`() Deducciones acumuladas anteriores`} value={anteriores.deducciones} negative />
<Divider />
<FilaDesglose
label="(=) Base gravable acumulada"
value={total.baseGravable}
bold
danger={total.baseGravable < 0}
/>
<FilaDesglose label="ISR causado (acumulado)" value={total.isrCausado} />
<FilaDesglose label="() ISR retenido (acumulado)" value={total.isrRetenido} negative />
<Divider />
<FilaDesglose label="ISR a pagar" value={total.isrAPagar} bold large />
</div>
```
Etiquetas dinámicas:
- `labelMesFinal` = `"Mar 2026"` (mes y año de `fechaFin`)
- `labelAnteriores` = `"(Ene-Feb)"` o `"(sin meses anteriores)"` cuando
`mesFinal === 1`.
Si `mesFinal === 1`: las dos filas "anteriores" muestran `$0` con texto
discretamente atenuado y el label dice "(sin meses anteriores)".
`FilaDesglose` puede ser un componente local del archivo o sustituirse por el
mismo `<div className="flex justify-between py-2 border-b">` que ya se usa.
Decisión por defecto: inline (no extraer componente nuevo, mantener el patrón
existente).
## No-cambios
- `getResumenIsr` se mantiene tal cual — sigue usándose en KPIs y otros lugares.
- Los KPIs en la parte alta de la pestaña ISR (Ingresos, Deducciones, Base
Gravable, etc.) **siguen mostrando los valores del rango filtrado completo**.
El cambio aplica solo a la tabla histórica y a la sección de cálculo.
- `metricas_mensuales` (cache) sigue guardando valores mensuales puros — el
acumulado se computa al consumir el cache. Sin invalidaciones.
- IVA mensual (`getIvaMensual`) no se toca.
## Riesgos
- **BG mensual deja de aparecer en la tabla**: si algún usuario hacía export y
reportaba la BG mensual a contadores, esa columna ya no existe. Mitigación:
comunicar el cambio en el changelog/release notes.
- **Año cruzado**: si el usuario filtra `fechaFin = 2026-03-31` pero
`fechaInicio` es de 2025, "anteriores" sigue siendo solo Ene-Feb 2026, no
baja a 2025. Esperable porque ISR se acumula por año fiscal.
- **Performance**: 3 queries `getResumenIsr` por refresh de la sección de
cálculo. Cada uno hace ~10 queries internos (por régimen, retenciones, etc.).
En un mes promedio del año, son ~30 queries. Aceptable para un endpoint
on-demand. Si se vuelve cuello de botella, optimizar con un solo query
agregado.
## Plan de pruebas (smoke)
1. `pnpm typecheck` debe seguir limpio en `@horux/api` y `@horux/shared`.
2. Backend — abrir REPL/curl:
- `GET /api/impuestos/resumen-isr-desglosado?fechaFin=2026-03-31&...`:
- `delPeriodo` = solo Mar 2026
- `anteriores` = Ene-Feb 2026
- `total.ingresos === delPeriodo.ingresos + anteriores.ingresos`
- `total.baseGravable === total.ingresos total.deducciones` (sin clamp,
puede ser negativo)
- Mismo endpoint con `fechaFin=2026-01-31`:
- `anteriores.ingresos === 0`, `anteriores.deducciones === 0`, etc.
3. Frontend tabla:
- Tenant con datos en varios meses (p.ej. Patito): verificar que cada fila
muestre el running total correcto.
- Tenant con un mes negativo (Husberto Feb si hay datos): la BG Acum debe
aparecer en rojo y reducir el acumulado del mes siguiente.
4. Frontend sección:
- Filtrar `mes=marzo`: ver que los 4 renglones cuadren con la fórmula y la
línea BG sea la suma algebraica.
- Filtrar `mes=enero`: ver que las dos líneas "anteriores" digan "$0" con
etiqueta "(sin meses anteriores)".
- Filtrar `mes=diciembre`: ver acumulado anual completo, "anteriores" =
Ene-Nov, "del periodo" = Dic.
5. Validación cruzada con declaración SAT real (si owner tiene una a la mano):
confirmar que los números del desglose coincidan con la declaración formato 14.
## Pendientes derivados
- Considerar agregar **un endpoint `getIsrMensualConAcumulados`** que retorne
los acumulados pre-computados, en vez de exponerlos como campos extra del
endpoint actual. Reduciría payload si solo se necesita una vista.
- Si el cache de `metricas_mensuales` empieza a usarse para ISR (hoy solo
es para IVA), repetir la fix del acumulado al consumir el cache.
- **Recompute opcional**: el bug actual ya no es visible (eliminamos la BG
mensual) pero la fila de cálculo del periodo SÍ depende de queries en vivo.
No hay cache que invalidar — el fix es inmediato al deploy.

View File

@@ -0,0 +1,129 @@
# Límite de 5 RFCs durante trial gratuito
## Contexto
Despachos en periodo de prueba (30 días) pueden agregar RFCs sin restricción.
El owner pidió un límite duro de 5 RFCs durante trial — para forzar al
contador a contratar un plan si necesita gestionar más.
## Reglas
| Estado | Límite RFCs |
|---|---|
| Trial activo (`tenant.trialEndsAt > now`) | **5 contribuyentes activos** (boundary: 5 OK, 6 bloqueado) |
| Trial expirado | Aplica el límite del plan vigente; este spec no agrega nada nuevo |
| Plan pagado (sin trial activo) | Sin nuevo límite (los del plan ya existen y son out of scope) |
## Cambios — Backend
### `apps/api/src/controllers/contribuyente.controller.ts`
Constante local al archivo:
```ts
const TRIAL_MAX_CONTRIBUYENTES = 5;
```
En el handler `create`, antes del `createContribuyente`:
```ts
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { trialEndsAt: true },
});
const isTrialActive = tenant?.trialEndsAt ? tenant.trialEndsAt > new Date() : false;
if (isTrialActive) {
const activeCount = await countActiveContribuyentes(req.tenantPool!);
if (activeCount >= TRIAL_MAX_CONTRIBUYENTES) {
return next(new AppError(
403,
`Durante el periodo de prueba puedes gestionar hasta ${TRIAL_MAX_CONTRIBUYENTES} contribuyentes. Contrata un plan para agregar más.`,
));
}
}
```
Imports: agregar `prisma` desde `../config/database.js` (ya está disponible
en otros controllers).
## Cambios — Frontend
### `apps/web/app/(dashboard)/contribuyentes/page.tsx`
Fetch del plan info (sigue patrón existente en `planes-despacho/page.tsx`):
```ts
const { data: planInfo } = useQuery({
queryKey: ['my-plan-info'],
queryFn: () => apiClient.get<{ isTrialActive: boolean }>('/despachos/me/plan').then(r => r.data),
});
const isTrialActive = planInfo?.isTrialActive ?? false;
const activeCount = (contribuyentes ?? []).filter(c => c.active !== false).length;
const trialAtLimit = isTrialActive && activeCount >= 5;
```
Modificar los 2 botones "Agregar RFC" (línea 70 y 78) para reflejar el estado:
```tsx
<Button
onClick={() => { resetForm(); setShowDialog(true); }}
disabled={trialAtLimit}
title={trialAtLimit ? 'Límite de contribuyentes para la prueba gratuita, para continuar agregando contribuyentes, selecciona un plan.' : undefined}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" /> Agregar RFC
</Button>
```
(Mismo patrón en el botón "Agregar primer RFC" — aunque cuando `activeCount === 0`
el `trialAtLimit` es `false`, así que ese botón nunca se deshabilita. Aún así,
aplico el atributo `disabled={trialAtLimit}` por consistencia defensiva.)
Mensaje del tooltip (literal del owner):
"Límite de contribuyentes para la prueba gratuita, para continuar agregando
contribuyentes, selecciona un plan."
## No-cambios
- Schema BD.
- Cron de trial (`expireTrials`).
- Mi Empresa hard limit a 1 RFC (sigue siendo solo billing-only,
fuera de scope).
- `tenant.cfdiLimit`, `tenant.usersLimit` — no se tocan.
## Riesgos
- **Race condition**: si dos creaciones concurrentes ven `count=4` y ambas
pasan, podríamos terminar con 6. Improbable en flujo manual UI; no se
mitiga (costo > beneficio).
- **Trial → paid mid-creación**: si el contador paga mientras está en 5
RFCs, el `trialEndsAt` no se modifica (sigue en futuro), pero la
subscription ahora tiene status `authorized`. Per la lógica actual,
el trial sigue "activo" hasta que `trialEndsAt < now`. El usuario
pagado seguirá viendo el límite de 5 hasta que expire el trial. **Aceptable**:
el owner gana dinero adicional el día que el contador convierte, no antes.
Si se quiere lift inmediato, modificar la lógica de `isTrialActive`
para excluir trials pagados — out of scope para este spec.
## Plan de pruebas
1. `pnpm typecheck` shared + api + web targeted: PASS.
2. Tenant en trial con 4 contribuyentes activos:
- UI: botón "Agregar RFC" habilitado.
- API: `POST /api/contribuyentes` con datos válidos retorna 201.
3. Tenant en trial con 5 contribuyentes activos:
- UI: botón "Agregar RFC" deshabilitado, tooltip visible al hover.
- API: `POST /api/contribuyentes` retorna 403 con el mensaje del spec.
4. Tenant trial expirado con 5 contribuyentes:
- UI: botón habilitado.
- API: 201 (puede crear el 6º — sin límite trial).
5. Tenant pagado (Business Control) con 5 contribuyentes:
- UI: botón habilitado.
- API: 201.
## Implementación
~15 líneas backend + ~8 líneas frontend. Cambio chico, una commit en
Downloads + V.1.0.11 en OneDrive.