Update: nueva version Horux Despachos
This commit is contained in:
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal 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
|
||||
219
docs/superpowers/specs/2026-04-12-conciliacion-design.md
Normal file
219
docs/superpowers/specs/2026-04-12-conciliacion-design.md
Normal 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
|
||||
35
docs/superpowers/specs/2026-04-12-sat-sync-chunking.md
Normal file
35
docs/superpowers/specs/2026-04-12-sat-sync-chunking.md
Normal 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()`
|
||||
186
docs/superpowers/specs/2026-04-13-opinion-cumplimiento-design.md
Normal file
186
docs/superpowers/specs/2026-04-13-opinion-cumplimiento-design.md
Normal 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)
|
||||
106
docs/superpowers/specs/2026-04-13-tenant-migrations-design.md
Normal file
106
docs/superpowers/specs/2026-04-13-tenant-migrations-design.md
Normal 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 |
|
||||
1462
docs/superpowers/specs/2026-04-16-horux-despachos-design.md
Normal file
1462
docs/superpowers/specs/2026-04-16-horux-despachos-design.md
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal file
166
docs/superpowers/specs/2026-04-27-custom-plan-design.md
Normal 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
129
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
Normal file
129
docs/superpowers/specs/2026-04-27-trial-rfc-limit-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user