Compare commits

..

73 Commits

Author SHA1 Message Date
Consultoria AS
351b14a78c security: comprehensive security audit and remediation (20 fixes)
CRITICAL fixes:
- Restrict X-View-Tenant impersonation to global admin only (was any admin)
- Add authorization to subscription endpoints (was open to any user)
- Make webhook signature verification mandatory (was skippable)
- Remove databaseName from JWT payload (resolve server-side with cache)
- Reduce body size limit from 1GB to 10MB (50MB for bulk CFDI)
- Restrict .env file permissions to 600

HIGH fixes:
- Add authorization to SAT cron endpoints (global admin only)
- Add Content-Security-Policy and Permissions-Policy headers
- Centralize isGlobalAdmin() utility with caching
- Add rate limiting on auth endpoints (express-rate-limit)
- Require authentication on logout endpoint

MEDIUM fixes:
- Replace Math.random() with crypto.randomBytes for temp passwords
- Remove console.log of temporary passwords in production
- Remove DB credentials from admin notification email
- Add escapeHtml() to email templates (prevent HTML injection)
- Add file size validation on FIEL upload (50KB max)
- Require TLS for SMTP connections
- Normalize email to lowercase before uniqueness check
- Remove hardcoded default for FIEL_ENCRYPTION_KEY

Also includes:
- Complete production deployment documentation
- API reference documentation
- Security audit report with remediation details
- Updated README with v0.5.0 changelog
- New client admin email template
- Utility scripts (create-carlos, test-emails)
- PM2 ecosystem config updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:32:04 +00:00
Consultoria AS
38626bd3e6 feat: enhance subscription page with pay button, billing period, and alerts
- "Pagar ahora" button generates MercadoPago link and opens in new tab
- Billing period card shows start/end dates and days until next payment
- Warning banners: expired (red), expiring soon (yellow), pending payment
- Improved payment history with icons and translated payment methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:59:09 +00:00
Consultoria AS
d22e898909 feat: add subscription UI, plan-based nav gating, and client subscription page
- Add plan field to UserInfo shared type
- Subscription API client and React Query hooks
- Client subscription page with status + payment history
- Sidebar navigation filtered by tenant plan features
- Subscription link added to navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:48:23 +00:00
Consultoria AS
3ab6d8d3e9 feat: add production deployment configs
- PM2 cluster mode (2 API workers + 1 Next.js)
- Nginx reverse proxy with SSL, rate limiting, security headers
- Automated backup script with daily/weekly rotation
- PostgreSQL production tuning script (300 connections, 4GB shared_buffers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:44:20 +00:00
Consultoria AS
c351b5aeda feat: integrate email + subscription into tenant provisioning, FIEL notification
createTenant now: provisions DB, creates admin user with temp password,
creates initial subscription, and sends welcome email.
FIEL upload sends admin notification email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:42:55 +00:00
Consultoria AS
b977f92141 feat: add plan enforcement middleware (subscription, CFDI limits, feature gates)
- checkPlanLimits: blocks writes when subscription inactive
- checkCfdiLimit: enforces per-plan CFDI count limits
- requireFeature: gates reportes/alertas/calendario by plan tier
- All cached with 5-min TTL, invalidated via PM2 messaging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:41:20 +00:00
Consultoria AS
69d7590834 feat: add MercadoPago payments, subscription service, and webhooks
- MercadoPago PreApproval integration for recurring subscriptions
- Subscription service with caching, manual payment, payment history
- Webhook handler with HMAC-SHA256 signature verification
- Admin endpoints for subscription management and payment links
- Email notifications on payment success/failure/cancellation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:39:00 +00:00
Consultoria AS
6fc81b1c0d feat: add email service with Nodemailer and 6 HTML templates
EmailService with mock fallback when SMTP not configured.
Templates: welcome, fiel-notification, payment-confirmed,
payment-failed, subscription-expiring, subscription-cancelled.
Uses Google Workspace SMTP (STARTTLS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:34:49 +00:00
Consultoria AS
bcabbd4959 feat: add CLI script for emergency FIEL decryption from filesystem
Decrypts .cer and .key from FIEL_STORAGE_PATH/<RFC>/ to /tmp with
30-minute auto-cleanup for security.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:33:18 +00:00
Consultoria AS
12dda005af feat: add dual filesystem storage for FIEL credentials
Save encrypted .cer, .key, and metadata to FIEL_STORAGE_PATH alongside
the existing DB storage. Each file has separate .iv and .tag sidecar files.
Filesystem failure is non-blocking (logs warning, DB remains primary).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:32:42 +00:00
Consultoria AS
d8f9f92389 refactor: remove schema-manager and tenantSchema backward compat
Delete schema-manager.ts (replaced by TenantConnectionManager).
Remove deprecated tenantSchema from Express Request interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:31:06 +00:00
Consultoria AS
96e1ea554c feat: add graceful shutdown and PM2 cross-worker messaging
Close all tenant DB pools on SIGTERM/SIGINT for clean restarts.
Support PM2 cluster invalidate-tenant-cache messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:30:08 +00:00
Consultoria AS
b064f15404 refactor: migrate all tenant services and controllers to pool-based queries
Replace Prisma raw queries with pg.Pool for all tenant-scoped services:
cfdi, dashboard, impuestos, alertas, calendario, reportes, export, and SAT.
Controllers now pass req.tenantPool instead of req.tenantSchema.
Fixes SQL injection in calendario.service.ts (parameterized interval).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:29:20 +00:00
Consultoria AS
7eaeefa09d feat: rewrite tenants service to use TenantConnectionManager
- Replace inline schema SQL with tenantDb.provisionDatabase
- Delete now soft-deletes DB (rename) and invalidates pool
- Use PLANS config for default limits per plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:20:37 +00:00
Consultoria AS
2b5a856845 feat: update auth service to provision databases via TenantConnectionManager
- Replace createTenantSchema with tenantDb.provisionDatabase
- JWT payload now includes databaseName (already renamed from schemaName)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:19:58 +00:00
Consultoria AS
8bfb8912c1 feat: rewrite tenant middleware for pool-based tenant resolution
- Resolve tenant DB via TenantConnectionManager instead of SET search_path
- Add tenantPool to Express Request for direct pool queries
- Keep tenantSchema as backward compat until all services are migrated
- Support admin impersonation via X-View-Tenant header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:19:12 +00:00
Consultoria AS
d6b86dbbd3 feat: add TenantConnectionManager with dynamic pool management
- Adds pg dependency for direct PostgreSQL connections to tenant DBs
- TenantConnectionManager: singleton managing Map<tenantId, Pool>
- provisionDatabase: creates new DB with tables and indexes
- deprovisionDatabase: soft-deletes by renaming DB
- Automatic idle pool cleanup every 60s (5min threshold)
- Max 3 connections per pool (6/tenant with 2 PM2 workers)
- Graceful shutdown support for all pools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:17:54 +00:00
Consultoria AS
f96a9c55c5 feat(saas): update schema for db-per-tenant and per-component FIEL encryption
- Rename Tenant.schemaName to databaseName across all services
- Add Subscription and Payment models to Prisma schema
- Update FielCredential to per-component IV/tag encryption columns
- Switch FIEL encryption key from JWT_SECRET to FIEL_ENCRYPTION_KEY
- Add Subscription and Payment shared types
- Update JWTPayload to use databaseName

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:15:55 +00:00
Consultoria AS
0d17fe3494 feat: add env vars for FIEL encryption, MercadoPago, SMTP, and admin email
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:08:46 +00:00
Consultoria AS
22543589c3 docs: add SaaS transformation implementation plan
28 tasks across 8 chunks:
- Chunk 1: Core infrastructure (DB-per-tenant, env, JWT, pools)
- Chunk 2: FIEL dual storage + encryption fix
- Chunk 3: Email service (Nodemailer + Gmail SMTP)
- Chunk 4: MercadoPago payments (subscriptions, webhooks)
- Chunk 5: Plan enforcement (limits, feature gates)
- Chunk 6: Tenant provisioning integration
- Chunk 7: Production deployment (PM2, Nginx, SSL, backups)
- Chunk 8: Frontend updates (subscription UI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:04:48 +00:00
Consultoria AS
536a5abd33 docs: fix remaining warnings in SaaS design spec (round 2)
- Fix metadata.json shown as unencrypted in tree (now .enc)
- Fix admin bypass order in checkPlanLimits (moved before status check)
- Add PM2 cross-worker cache invalidation via process messaging
- Fix fiel_credentials "no changes" contradiction with per-component IV
- Backup all tenant DBs regardless of active status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:53:34 +00:00
Consultoria AS
3c9268ea30 docs: fix blockers and warnings in SaaS design spec
Fixes from spec review:
- BLOCKER: JWT payload migration (schemaName → databaseName)
- BLOCKER: FIEL encryption key separation from JWT_SECRET
- BLOCKER: PM2 cluster pool count (max:3 × 2 workers = 6/tenant)
- BLOCKER: Pending subscription grace period for new clients
- WARNING: Add indexes on subscriptions/payments tables
- WARNING: Fix Nginx rate limit zone definitions
- WARNING: Fix backup auth (.pgpass), retention, and schedule
- WARNING: Preserve admin X-View-Tenant impersonation
- WARNING: Encrypt metadata.json for NDA compliance
- SUGGESTION: Add health check, reduce upload limit, add rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:50:38 +00:00
Consultoria AS
c44e7cea34 docs: add SaaS transformation design spec
Complete design document covering:
- Database-per-tenant architecture (NDA compliance)
- FIEL dual storage (filesystem + DB, encrypted)
- MercadoPago subscription payments
- Transactional emails via Gmail SMTP
- Production deployment (Nginx, PM2, SSL, backups)
- Plan enforcement and feature gating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:46:12 +00:00
Consultoria AS
2994de4ce0 feat: add Excel export, keyboard shortcuts, and print view for CFDIs
- Add export to Excel button with xlsx library for filtered data
- Add keyboard shortcuts (Esc to close popovers/forms)
- Add print button to invoice viewer modal with optimized print styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 07:40:11 +00:00
Consultoria AS
562e23d8bf perf(cfdi): add skeleton loader for better perceived performance
- Replace "Cargando..." text with animated skeleton rows
- Mimics table structure while loading
- Improves perceived loading speed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 07:21:41 +00:00
Consultoria AS
08a7312761 perf(cfdi): optimize page performance
Database optimizations:
- Add indexes on fecha_emision, tipo, estado, rfc_emisor, rfc_receptor
- Add trigram indexes for fast ILIKE searches on nombre fields
- Combine COUNT with main query using window function (1 query instead of 2)

Frontend optimizations:
- Add 300ms debounce to autocomplete searches
- Add staleTime (30s) and gcTime (5min) to useCfdis hook
- Reduce unnecessary API calls on every keystroke

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 07:15:33 +00:00
Consultoria AS
0e49c0922d feat(cfdi): add autocomplete for emisor and receptor filters
- Add /cfdi/emisores and /cfdi/receptores API endpoints
- Search by RFC or nombre with ILIKE
- Show suggestions dropdown while typing (min 2 chars)
- Click suggestion to select and populate filter input
- Show loading state while searching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 07:07:01 +00:00
Consultoria AS
5c6367839f fix(cfdi): cast date filters to proper PostgreSQL date type
- Add ::date cast to fechaInicio filter
- Add ::date cast and +1 day interval to fechaFin to include full day
- Fixes "operator does not exist: timestamp >= text" error

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 07:03:49 +00:00
Consultoria AS
8ddb60d6c1 fix(cfdi): increase popover z-index to prevent overlap
- Set z-index to 9999 to ensure popover appears above all elements
- Add explicit white background for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 06:58:28 +00:00
Consultoria AS
e132c2ba14 feat(cfdi): add filter icons with popover dropdowns per column
- Create Popover component using Radix UI
- Add filter icon next to Fecha, Emisor, Receptor headers
- Each icon opens a popover with filter inputs
- Show active filters as badges in card header
- Filter icons highlight when filter is active
- Apply filters on Enter or click Apply button
- Remove filters individually with X on badge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 06:25:17 +00:00
Consultoria AS
29ac067a82 feat(cfdi): add inline column filters for date, emisor, receptor
- Add emisor and receptor filters to CfdiFilters type
- Update backend service to filter by emisor/receptor (RFC or nombre)
- Update controller and API client to pass new filters
- Add toggle button to show/hide column filters in table
- Add date range inputs for fecha filter
- Add text inputs for emisor and receptor filters
- Apply filters on Enter key or search button click
- Add clear filters button when filters are active

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 06:21:13 +00:00
Consultoria AS
8c3fb76406 feat(cfdi): redesign invoice viewer with professional layout
- Add gradient header with emisor info and prominent serie/folio
- Improve status badges with pill design
- Add receptor section with left accent border
- Show complete uso CFDI descriptions
- Create card grid for payment method, forma pago, moneda
- Improve conceptos table with zebra striping and SAT keys
- Add elegant totals box with blue footer
- Enhance timbre fiscal section with QR placeholder and SAT URL
- Add update-cfdi-xml.js script for bulk XML import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 06:17:29 +00:00
Consultoria AS
5ff5629cd8 fix(api): add UUID type cast in getCfdiById and getXmlById
PostgreSQL requires explicit type cast when comparing UUID columns
with text parameters in raw queries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:51:45 +00:00
Consultoria AS
2bbab12627 fix: resolve build errors and add dialog component
- Add Dialog UI component (shadcn/radix-ui)
- Fix html2pdf.js type annotations with const assertions
- Add @radix-ui/react-dialog dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:42:25 +00:00
Consultoria AS
cdb6f0c94e feat(web): integrate CFDI viewer modal into CFDI page
- Add Eye button to table rows to view invoice
- Add loading state while fetching CFDI details
- Integrate CfdiViewerModal component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:38:24 +00:00
Consultoria AS
3beee1c174 feat(web): add CfdiViewerModal with PDF and XML download
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:37:20 +00:00
Consultoria AS
837831ccd4 feat(web): add CfdiInvoice component for PDF-like rendering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:36:18 +00:00
Consultoria AS
f9d2161938 feat(web): add getCfdiXml API function
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:34:51 +00:00
Consultoria AS
427c94fb9d feat(api): add GET /cfdi/:id/xml endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:34:16 +00:00
Consultoria AS
266e547eb5 feat(api): add xmlOriginal to getCfdiById and add getXmlById
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:33:21 +00:00
Consultoria AS
ebd099f596 feat(types): add xmlOriginal field to Cfdi interface
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:32:34 +00:00
Consultoria AS
8c0bc799d3 chore: add html2pdf.js for PDF generation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:31:03 +00:00
Consultoria AS
6109294811 docs: add CFDI viewer implementation plan
Detailed step-by-step implementation plan for:
- PDF-like invoice visualization
- PDF download via html2pdf.js
- XML download endpoint
- Modal integration in CFDI page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:28:17 +00:00
Consultoria AS
67f74538b8 docs: add CFDI viewer design document
Design for PDF-like invoice visualization with:
- Modal viewer with invoice preview
- PDF download via html2pdf.js
- XML download from stored data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:26:19 +00:00
Consultoria AS
3466ec740e fix: resolve TypeScript compilation errors in API
- Add explicit IRouter type to all route files
- Add explicit Express type to app.ts
- Fix env.ts by moving getCorsOrigins after parsing
- Fix token.ts SignOptions type for expiresIn
- Cast req.params.id to String() in controllers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 06:48:26 +00:00
Consultoria AS
3098a40356 fix: add auth protection to onboarding and remove demo text
- Add authentication check using useAuthStore
- Redirect unauthenticated users to /login
- Show loading state while auth store hydrates
- Remove "Demo UI sin backend" text from production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:33:22 +00:00
Consultoria AS
34864742d8 Merge branch 'DevMarlene' into main
feat: add onboarding screen and redirect new users after login
2026-01-31 03:09:42 +00:00
Consultoria AS
1fe462764f fix: use transaction in refreshTokens to prevent race conditions
- Wrap token refresh logic in Prisma transaction
- Use deleteMany instead of delete to handle race conditions gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:09:32 +00:00
Consultoria AS
ba012254db docs: add current state and next steps for SAT sync
- Document current implementation status
- Add pending items to verify after SAT rate limit resets
- Include test tenant info and verification commands
- List known issues and workarounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 04:38:43 +00:00
Marlene-Angel
07fc9a8fe3 feat: add onboarding screen and redirect new users after login 2026-01-24 20:02:21 -08:00
Consultoria AS
dcc33af523 feat: SAT sync improvements and documentation
- Add custom date range support for SAT synchronization
- Fix UUID cast in SQL queries for sat_sync_job_id
- Fix processInitialSync to respect custom dateFrom/dateTo parameters
- Add date picker UI for custom period sync
- Add comprehensive documentation for SAT sync implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:01:27 +00:00
Consultoria AS
492cd62772 debug: add logging to verify SAT status 2026-01-25 02:20:29 +00:00
Consultoria AS
008f586b54 fix: reduce sync years to 6 (SAT maximum allowed)
SAT only allows downloading CFDIs from the last 6 years.
Reduced from 10 to avoid wasted requests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:17:33 +00:00
Consultoria AS
38466a2b23 fix: use isTypeOf for SAT status request checking
The StatusRequest class has an isTypeOf method that properly checks
the status. Using getValue() and comparing numbers was unreliable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:17:17 +00:00
Consultoria AS
98d704a549 feat: use @nodecfdi/sat-ws-descarga-masiva for SAT sync
Replace manual SOAP authentication with the official nodecfdi library
which properly handles WS-Security signatures for SAT web services.

- Add sat-client.service.ts using Fiel.create() for authentication
- Update sat.service.ts to use new client
- Update fiel.service.ts to return raw certificate data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 02:07:55 +00:00
Consultoria AS
c52548a2bb fix: remove double base64 encoding of certificate in SAT auth
The PEM certificate content is already base64 encoded after removing
headers and newlines. We should not re-encode it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:51:57 +00:00
Consultoria AS
121fe731d0 fix: use combined encryption for FIEL credentials
Each piece of data was being encrypted with a different IV, but only
the first IV was saved. Now using encryptFielCredentials/decryptFielCredentials
helper functions that encrypt all data together with a single IV/tag.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:50:15 +00:00
Consultoria AS
02ccfb41a0 fix: convert certificate dates to Date objects in fiel.service
The @nodecfdi/credentials library returns date values that aren't
JavaScript Date objects, causing getTime() to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:46:54 +00:00
Consultoria AS
75a9819c1e fix: use JWT tenantId instead of header in FIEL and SAT controllers
The controllers were looking for x-tenant-id header which the frontend
doesn't send. Now using req.user!.tenantId from the JWT token instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:45:42 +00:00
Consultoria AS
2dd22ec152 fix: correct global admin tenant RFC 2026-01-25 01:38:38 +00:00
Consultoria AS
69efb585d3 feat: add global user administration for admin users
Backend:
- Add getAllUsuarios() to get users from all tenants
- Add updateUsuarioGlobal() to edit users and change their tenant
- Add deleteUsuarioGlobal() for global user deletion
- Add global admin check based on tenant RFC
- Add new API routes: /usuarios/global/*

Frontend:
- Add UserListItem.tenantId and tenantName fields
- Add /admin/usuarios page with full user management
- Support filtering by tenant and search
- Inline editing for name, role, and tenant assignment
- Group users by company for better organization
- Add "Admin Usuarios" menu item for admin navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:22:34 +00:00
Consultoria AS
2655a51a99 feat(web): add SAT configuration link to settings page
Add a card linking to /configuracion/sat from the main settings page,
making the SAT sync feature discoverable from the navigation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:03:55 +00:00
Consultoria AS
31c66f2823 feat(sat): add frontend components for SAT configuration (Phase 8)
- Add FielUploadModal component for FIEL credential upload
- Add SyncStatus component showing current sync progress
- Add SyncHistory component with pagination and retry
- Add SAT configuration page at /configuracion/sat
- Add API client functions for FIEL and SAT endpoints

Features:
- File upload with Base64 encoding
- Real-time sync progress tracking
- Manual sync trigger (initial/daily)
- Sync history with retry capability
- FIEL status display with expiration warning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:00:08 +00:00
Consultoria AS
e50e7100f1 feat(sat): add API endpoints for FIEL and SAT sync (Phase 7)
- Add FIEL controller with upload, status, and delete endpoints
- Add SAT controller with sync start, status, history, and retry
- Add admin endpoints for cron job info and manual execution
- Register new routes in app.ts
- All endpoints protected with authentication middleware

Endpoints added:
- POST /api/fiel/upload
- GET /api/fiel/status
- DELETE /api/fiel
- POST /api/sat/sync
- GET /api/sat/sync/status
- GET /api/sat/sync/history
- GET /api/sat/sync/:id
- POST /api/sat/sync/:id/retry
- GET /api/sat/cron
- POST /api/sat/cron/run

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:56:47 +00:00
Consultoria AS
0a65c60570 feat(sat): add scheduled cron job for daily sync (Phase 6)
- Add sat-sync.job.ts with scheduled daily sync at 3:00 AM
- Automatic detection of tenants with active FIEL
- Initial sync (10 years) for new tenants, daily for existing
- Concurrent processing with configurable batch size
- Integration with app startup for production environment
- Install node-cron dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:53:54 +00:00
Consultoria AS
473912bfd7 feat(sat): add main sync orchestrator service (Phase 5)
- Add sat.service.ts as the main orchestrator that coordinates:
  - FIEL credential retrieval and token management
  - SAT download request workflow
  - Package processing and CFDI storage
  - Progress tracking and job management
- Support for initial sync (10 years history) and daily sync
- Automatic token refresh during long-running syncs
- Month-by-month processing to avoid SAT limits
- Raw SQL queries for multi-tenant schema isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:52:18 +00:00
Consultoria AS
09684f77b9 feat(sat): add CFDI XML parser service (Phase 4)
- Add sat-parser.service.ts for processing SAT packages:
  - Extract XML files from ZIP packages
  - Parse CFDI 4.0 XML structure with proper namespace handling
  - Extract fiscal data: UUID, amounts, taxes, dates, RFC info
  - Map SAT types (I/E/T/P/N) to application types
  - Handle IVA and ISR retention calculations
- Install @nodecfdi/cfdi-core dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:50:11 +00:00
Consultoria AS
56e6e27ab3 feat(sat): add SAT authentication and download services (Phase 3)
- Add sat-auth.service.ts for SAML token authentication with SAT
  using FIEL credentials and SOAP protocol
- Add sat-download.service.ts with full download workflow:
  - Request CFDI download (emitted/received)
  - Verify request status with polling support
  - Download ZIP packages when ready
  - Helper functions for status checking
- Install fast-xml-parser and adm-zip dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:49:02 +00:00
Consultoria AS
a64aa11548 feat(sat): add FIEL management and encryption services (Phase 2)
- Add sat-crypto.service.ts with AES-256-GCM encryption for secure
  credential storage using JWT_SECRET as key derivation source
- Add fiel.service.ts with complete FIEL lifecycle management:
  - Upload and validate FIEL credentials (.cer/.key files)
  - Verify certificate is FIEL (not CSD) and not expired
  - Store encrypted credentials in database
  - Retrieve and decrypt credentials for SAT sync operations
- Install @nodecfdi/credentials for FIEL/CSD handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:44:04 +00:00
Consultoria AS
787aac9a4c feat(sat): add database models for SAT sync
Phase 1 - Database models:
- Add FielCredential model for encrypted FIEL storage
- Add SatSyncJob model for sync job tracking
- Add SAT-related enums (SatSyncType, SatSyncStatus, CfdiSyncType)
- Add TypeScript types in shared package
- Relations: Tenant -> FielCredential (1:1), Tenant -> SatSyncJobs (1:N)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:38:51 +00:00
Consultoria AS
3763014eca docs: add SAT sync implementation plan
Detailed implementation plan with 9 phases:
1. Database models and migrations
2. Cryptography and FIEL services
3. SAT communication services
4. CFDI XML parser
5. Main orchestrator service
6. Scheduled cron job
7. API endpoints
8. Frontend components
9. Testing and validation

Includes:
- 16 new files to create
- 5 files to modify
- Dependencies list
- Implementation order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:36:53 +00:00
Consultoria AS
b49902bcff docs: add SAT sync feature design
Design document for automatic CFDI synchronization with SAT:
- FIEL (e.firma) authentication
- Download emitted and received CFDIs
- Daily automated sync at 3:00 AM
- Initial extraction of last 10 years
- Encrypted credential storage (AES-256-GCM)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:35:12 +00:00
Consultoria AS
519de61c6f chore: add systemd service files for auto-start
Add systemd unit files for automatic service startup:
- horux-api.service: API server on port 4000
- horux-web.service: Web frontend on port 3000

Services are configured to:
- Start automatically on boot
- Restart on failure
- Depend on PostgreSQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 00:17:47 +00:00
125 changed files with 14898 additions and 732 deletions

171
README.md
View File

@@ -4,40 +4,80 @@ Plataforma de análisis financiero y gestión fiscal para empresas mexicanas.
## Descripción
Horux360 es una aplicación SaaS que permite a las empresas mexicanas:
Horux360 es una aplicación SaaS multi-tenant que permite a las empresas mexicanas:
- Gestionar sus CFDI (facturas electrónicas)
- Gestionar sus CFDI (facturas electrónicas) con carga masiva de XML
- Controlar IVA e ISR automáticamente
- Sincronizar CFDIs directamente con el SAT usando FIEL
- Visualizar dashboards financieros en tiempo real
- Realizar conciliación bancaria
- Recibir alertas fiscales proactivas
- Generar reportes y proyecciones financieras
- Calendario de obligaciones fiscales
## Stack Tecnológico
- **Frontend:** Next.js 14 + TypeScript + Tailwind CSS
- **Backend:** Node.js + Express + TypeScript
- **Base de datos:** PostgreSQL (multi-tenant por schema)
- **Autenticación:** JWT personalizado
- **Estado:** Zustand con persistencia
| Capa | Tecnología |
|------|-----------|
| **Frontend** | Next.js 14 + TypeScript + Tailwind CSS + shadcn/ui |
| **Backend** | Node.js + Express + TypeScript + tsx |
| **Base de datos** | PostgreSQL 16 (database-per-tenant) |
| **ORM** | Prisma (central DB) + pg (tenant DBs con raw SQL) |
| **Autenticación** | JWT (access 15min + refresh 7d) |
| **Estado** | Zustand con persistencia |
| **Proceso** | PM2 (fork mode) |
| **Proxy** | Nginx con SSL (Let's Encrypt) |
| **Email** | Nodemailer + Gmail Workspace (STARTTLS) |
| **Pagos** | MercadoPago (suscripciones) |
## Estructura del Proyecto
```
horux360/
├── apps/
│ ├── web/ # Frontend Next.js
── api/ # Backend Express
│ ├── web/ # Frontend Next.js 14
│ ├── app/ # Pages (App Router)
│ │ ├── components/ # Componentes UI
│ │ ├── lib/api/ # Cliente API
│ │ └── stores/ # Zustand stores
│ └── api/ # Backend Express
│ ├── src/
│ │ ├── config/ # ENV, database connections
│ │ ├── controllers/ # Request handlers
│ │ ├── middlewares/ # Auth, tenant, rate-limit, plan-limits
│ │ ├── routes/ # Express routes
│ │ ├── services/ # Business logic
│ │ │ ├── email/ # Templates + Nodemailer
│ │ │ ├── payment/ # MercadoPago
│ │ │ └── sat/ # SAT sync + FIEL crypto
│ │ ├── utils/ # Helpers (token, password, global-admin)
│ │ └── jobs/ # SAT sync cron job
│ └── prisma/ # Schema + migrations
├── packages/
│ └── shared/ # Tipos y utilidades compartidas
│ └── shared/ # Tipos y constantes compartidas
├── deploy/
│ └── nginx/ # Configuración de Nginx
├── scripts/
│ └── backup.sh # Script de backup PostgreSQL
├── docs/
── plans/ # Documentación de diseño
└── docker-compose.yml
── architecture/ # Docs técnicos
│ ├── security/ # Auditorías de seguridad
│ └── plans/ # Documentación de diseño
└── ecosystem.config.js # PM2 config
```
## Documentación
## Arquitectura Multi-Tenant
- [Documento de Diseño](docs/plans/2026-01-22-horux360-saas-design.md)
Cada cliente tiene su propia base de datos PostgreSQL, asegurando aislamiento completo de datos:
```
horux360 (central) ← Tenants, Users, Subscriptions, RefreshTokens
horux_<rfc_cliente_1> ← CFDIs, Alertas, Calendario, IVA del cliente 1
horux_<rfc_cliente_2> ← CFDIs, Alertas, Calendario, IVA del cliente 2
...
```
El middleware de tenant resuelve la base de datos del cliente desde el `tenantId` del JWT, usando un caché de 5 minutos.
## Planes
@@ -45,50 +85,113 @@ horux360/
|------|----------|----------|-----------------|
| Starter | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
| Business | 500 | 3 | + Reportes, Alertas, Calendario |
| Professional | 2,000 | 10 | + Conciliación, Forecasting |
| Professional | 2,000 | 10 | + Conciliación, Forecasting, SAT Sync |
| Enterprise | Ilimitado | Ilimitado | + API, Multi-empresa |
## Características Destacadas
## Seguridad
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
- **Responsive:** Funciona en desktop y móvil
- **Tiempo real:** Dashboards actualizados al instante
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
- **Selector de período:** Navegación por mes/año en todos los dashboards
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
- JWT con access token (15min) y refresh token rotation (7d)
- bcrypt con 12 salt rounds para passwords
- Rate limiting en auth (10 login/15min, 3 register/hora)
- FIEL encriptada con AES-256-GCM
- CSP, HSTS, y security headers vía Nginx + Helmet
- Admin global verificado por RFC (no solo por rol)
- Webhooks de MercadoPago con verificación HMAC-SHA256
- Body limits diferenciados (10MB general, 50MB bulk CFDI)
- TLS obligatorio para SMTP
## Configuración
Ver [Auditoría de Seguridad](docs/security/2026-03-18-security-audit-remediation.md) para detalles completos.
### Variables de entorno (API)
## Documentación
| Documento | Descripción |
|-----------|-------------|
| [Diseño SaaS](docs/plans/2026-01-22-horux360-saas-design.md) | Arquitectura original y decisiones de diseño |
| [Deployment](docs/architecture/deployment.md) | Guía completa de despliegue en producción |
| [API Reference](docs/architecture/api-reference.md) | Referencia de todos los endpoints |
| [Security Audit](docs/security/2026-03-18-security-audit-remediation.md) | Auditoría de seguridad y remediaciones |
| [SAT Sync](docs/SAT-SYNC-IMPLEMENTATION.md) | Implementación de sincronización con el SAT |
## Configuración Local
### Requisitos
- Node.js 20+
- pnpm 9+
- PostgreSQL 16
### Setup
```bash
# Instalar dependencias
pnpm install
# Configurar variables de entorno
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env.local
# Ejecutar migraciones
cd apps/api && pnpm prisma migrate dev
# Desarrollo
pnpm dev
```
### Variables de Entorno (API)
```env
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
JWT_SECRET=your-secret-key
JWT_SECRET=<min-32-chars>
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
FRONTEND_URL=http://localhost:3000
FIEL_ENCRYPTION_KEY=<min-32-chars>
FIEL_STORAGE_PATH=/var/horux/fiel
```
### Variables de entorno (Web)
### Variables de Entorno (Web)
```env
NEXT_PUBLIC_API_URL=http://localhost:4000/api
```
## Demo
## Roles
Credenciales de demo:
- **Admin:** admin@demo.com / demo123
- **Contador:** contador@demo.com / demo123
- **Visor:** visor@demo.com / demo123
| Rol | Acceso |
|-----|--------|
| **admin** | Todo dentro de su tenant + invitar usuarios |
| **contador** | CFDI, impuestos, reportes, dashboard |
| **visor** | Solo lectura |
| **admin global** | Admin del tenant CAS2408138W2 — gestión de clientes, suscripciones, SAT cron |
## Producción
- **URL:** https://horuxfin.com
- **Hosting:** Servidor dedicado
- **SSL:** Let's Encrypt (auto-renewal)
- **Process:** PM2 con auto-restart
- **Backups:** Diarios a las 01:00 AM
Ver [Guía de Deployment](docs/architecture/deployment.md) para instrucciones completas.
## Changelog
### v0.5.0 (2026-03-18)
- Auditoría de seguridad completa y remediación de 20 vulnerabilidades
- Rate limiting en endpoints de autenticación
- Content Security Policy (CSP) y headers de seguridad mejorados
- `databaseName` removido del JWT (resolución server-side)
- Restricción de impersonación a admin global únicamente
- Autorización en endpoints de suscripción y SAT cron
- Verificación obligatoria de firma en webhooks
- Body limits reducidos (10MB default, 50MB bulk)
- Passwords temporales criptográficamente seguros
- Validación de tamaño en upload de FIEL
- SMTP con TLS obligatorio
- Documentación completa de producción
### v0.4.0 (2026-01-22)
- Carga masiva de XML CFDI (hasta 300MB)
- Carga masiva de XML CFDI (hasta 50MB)
- Selector de período mes/año en dashboards
- Fix: Persistencia de sesión en refresh de página
- Fix: Clasificación ingreso/egreso basada en RFC

View File

@@ -15,22 +15,38 @@
},
"dependencies": {
"@horux/shared": "workspace:*",
"@nodecfdi/cfdi-core": "^1.0.1",
"@nodecfdi/credentials": "^3.2.0",
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pg": "^8.18.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"express-rate-limit": "^8.3.1",
"prisma": "^5.22.0",
"tsx": "^4.19.0",
"typescript": "^5.3.0"

View File

@@ -8,18 +8,22 @@ datasource db {
}
model Tenant {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
schemaName String @unique @map("schema_name")
cfdiLimit Int @map("cfdi_limit")
usersLimit Int @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
users User[]
users User[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
@@map("tenants")
}
@@ -62,3 +66,119 @@ enum Role {
contador
visor
}
// ============================================
// SAT Sync Models
// ============================================
model FielCredential {
id String @id @default(uuid())
tenantId String @unique @map("tenant_id")
rfc String @db.VarChar(13)
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
cerIv Bytes @map("cer_iv")
cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("fiel_credentials")
}
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
@@index([tenantId])
@@index([status])
@@map("subscriptions")
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")
type SatSyncType
status SatSyncStatus @default(pending)
dateFrom DateTime @map("date_from") @db.Date
dateTo DateTime @map("date_to") @db.Date
cfdiType CfdiSyncType? @map("cfdi_type")
satRequestId String? @map("sat_request_id") @db.VarChar(50)
satPackageIds String[] @map("sat_package_ids")
cfdisFound Int @default(0) @map("cfdis_found")
cfdisDownloaded Int @default(0) @map("cfdis_downloaded")
cfdisInserted Int @default(0) @map("cfdis_inserted")
cfdisUpdated Int @default(0) @map("cfdis_updated")
progressPercent Int @default(0) @map("progress_percent")
errorMessage String? @map("error_message")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
retryCount Int @default(0) @map("retry_count")
nextRetryAt DateTime? @map("next_retry_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@index([status, nextRetryAt])
@@map("sat_sync_jobs")
}
enum SatSyncType {
initial
daily
}
enum SatSyncStatus {
pending
running
completed
failed
}
enum CfdiSyncType {
emitidos
recibidos
}

View File

@@ -0,0 +1,26 @@
import { prisma } from '../src/config/database.js';
import { hashPassword } from '../src/utils/password.js';
async function main() {
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
const hash = await hashPassword('Aasi940812');
const carlos = await prisma.user.create({
data: {
tenantId: ivan.tenantId,
email: 'carlos@horuxfin.com',
passwordHash: hash,
nombre: 'Carlos Horux',
role: 'admin',
}
});
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,82 @@
/**
* CLI script to decrypt FIEL credentials from filesystem backup.
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
*
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
*/
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { createDecipheriv, createHash } from 'crypto';
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
const rfc = process.argv[2];
if (!rfc) {
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
process.exit(1);
}
if (!FIEL_KEY) {
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
process.exit(1);
}
function deriveKey(): Buffer {
return createHash('sha256').update(FIEL_KEY!).digest();
}
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
async function main() {
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
console.log(`Reading encrypted FIEL from: ${fielDir}`);
// Read encrypted certificate
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
// Read encrypted private key
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
// Read and decrypt metadata
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
// Decrypt all
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
// Write decrypted files
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
console.log(`\nDecrypted files written to: ${outputDir}`);
console.log('Metadata:', metadata);
console.log('\nFiles will be auto-deleted in 30 minutes.');
// Auto-delete after 30 minutes
setTimeout(async () => {
await rm(outputDir, { recursive: true, force: true });
console.log(`Cleaned up ${outputDir}`);
process.exit(0);
}, 30 * 60 * 1000);
}
main().catch((err) => {
console.error('Failed to decrypt FIEL:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
import { emailService } from '../src/services/email/email.service.js';
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
async function sendAllSamples() {
for (const to of recipients) {
console.log(`\n=== Enviando a ${to} ===`);
// 1. Welcome
console.log('1/6 Bienvenida...');
await emailService.sendWelcome(to, {
nombre: 'Ivan Alcaraz',
email: 'ivan@horuxfin.com',
tempPassword: 'TempPass123!',
});
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
console.log('2/6 Notificación FIEL...');
// Send directly since sendFielNotification goes to admin
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
const { createTransport } = await import('nodemailer');
const { env } = await import('../src/config/env.js');
const transport = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
const fielHtml = fielNotificationEmail({
clienteNombre: 'Consultoria Alcaraz Salazar',
clienteRfc: 'CAS200101XXX',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: '[Consultoria Alcaraz Salazar] subió su FIEL (MUESTRA)',
html: fielHtml,
});
// 3. Payment confirmed
console.log('3/6 Pago confirmado...');
await emailService.sendPaymentConfirmed(to, {
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
date: '16 de marzo de 2026',
});
// 4. Payment failed
console.log('4/6 Pago fallido...');
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
const failedHtml = paymentFailedEmail({
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
html: failedHtml,
});
// 5. Subscription expiring
console.log('5/6 Suscripción por vencer...');
await emailService.sendSubscriptionExpiring(to, {
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
expiresAt: '21 de marzo de 2026',
});
// 6. Subscription cancelled
console.log('6/6 Suscripción cancelada...');
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
const cancelledHtml = subscriptionCancelledEmail({
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
html: cancelledHtml,
});
console.log(`Listo: 6 correos enviados a ${to}`);
}
console.log('\n=== Todos los correos enviados ===');
process.exit(0);
}
sendAllSamples().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -1,4 +1,4 @@
import express from 'express';
import express, { type Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env, getCorsOrigins } from './config/env.js';
@@ -13,8 +13,12 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
import { reportesRoutes } from './routes/reportes.routes.js';
import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
import { webhookRoutes } from './routes/webhook.routes.js';
import { subscriptionRoutes } from './routes/subscription.routes.js';
const app = express();
const app: Express = express();
// Security
app.use(helmet());
@@ -23,9 +27,9 @@ app.use(cors({
credentials: true,
}));
// Body parsing - increased limit for bulk XML uploads (1GB)
app.use(express.json({ limit: '1gb' }));
app.use(express.urlencoded({ extended: true, limit: '1gb' }));
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check
app.get('/health', (req, res) => {
@@ -43,6 +47,10 @@ app.use('/api/calendario', calendarioRoutes);
app.use('/api/reportes', reportesRoutes);
app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
// Error handling
app.use(errorMiddleware);

View File

@@ -1,4 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolConfig } from 'pg';
import { env } from './env.js';
// ===========================================
// Prisma Client (central database: horux360)
// ===========================================
declare global {
var prisma: PrismaClient | undefined;
@@ -11,3 +17,303 @@ export const prisma = globalThis.prisma || new PrismaClient({
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
// ===========================================
// TenantConnectionManager (per-tenant DBs)
// ===========================================
interface PoolEntry {
pool: Pool;
lastAccess: Date;
}
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
class TenantConnectionManager {
private pools: Map<string, PoolEntry> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private dbConfig: { host: string; port: number; user: string; password: string };
constructor() {
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
}
/**
* Get or create a connection pool for a tenant's database.
*/
getPool(tenantId: string, databaseName: string): Pool {
const entry = this.pools.get(tenantId);
if (entry) {
entry.lastAccess = new Date();
return entry.pool;
}
const poolConfig: PoolConfig = {
host: this.dbConfig.host,
port: this.dbConfig.port,
user: this.dbConfig.user,
password: this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
const pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
return pool;
}
/**
* Create a new database for a tenant with all required tables and indexes.
*/
async provisionDatabase(rfc: string): Promise<string> {
const databaseName = `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length > 0) {
throw new Error(`Database ${databaseName} already exists`);
}
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
const tenantPool = new Pool({
...this.dbConfig,
database: databaseName,
max: 1,
});
try {
await this.createTables(tenantPool);
await this.createIndexes(tenantPool);
} finally {
await tenantPool.end();
}
return databaseName;
} finally {
await adminPool.end();
}
}
/**
* Soft-delete: rename database so it can be recovered.
*/
async deprovisionDatabase(databaseName: string): Promise<void> {
// Close any active pool for this tenant
for (const [tenantId, entry] of this.pools.entries()) {
// We check pool config to match the database
if ((entry.pool as any).options?.database === databaseName) {
await entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
const timestamp = Date.now();
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
await adminPool.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
`, [databaseName]);
await adminPool.query(
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
);
} finally {
await adminPool.end();
}
}
/**
* Invalidate (close and remove) a specific tenant's pool.
*/
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
/**
* Remove idle pools (not accessed in last 5 minutes).
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {
entry.pool.end().catch((err) =>
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
);
this.pools.delete(tenantId);
}
}
}
/**
* Graceful shutdown: close all pools.
*/
async shutdown(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
const closePromises = Array.from(this.pools.values()).map((entry) =>
entry.pool.end()
);
await Promise.all(closePromises);
this.pools.clear();
}
/**
* Get stats about active pools.
*/
getStats(): { activePools: number; tenantIds: string[] } {
return {
activePools: this.pools.size,
tenantIds: Array.from(this.pools.keys()),
};
}
private async createTables(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS cfdis (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL DEFAULT 'ingreso',
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) DEFAULT 0,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) DEFAULT 0,
moneda VARCHAR(10) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(10),
forma_pago VARCHAR(10),
uso_cfdi VARCHAR(10),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
xml_original TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
last_sat_sync TIMESTAMP,
sat_sync_job_id UUID,
source VARCHAR(20) DEFAULT 'manual'
);
CREATE TABLE IF NOT EXISTS iva_mensual (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
iva_trasladado DECIMAL(18,2) DEFAULT 0,
iva_acreditable DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) DEFAULT 0,
acumulado DECIMAL(18,2) DEFAULT 0,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
);
CREATE TABLE IF NOT EXISTS isr_mensual (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
ingresos_acumulados DECIMAL(18,2) DEFAULT 0,
deducciones DECIMAL(18,2) DEFAULT 0,
base_gravable DECIMAL(18,2) DEFAULT 0,
isr_causado DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
isr_a_pagar DECIMAL(18,2) DEFAULT 0,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
);
CREATE TABLE IF NOT EXISTS alertas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS calendario_fiscal (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(50) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'unica',
completado BOOLEAN DEFAULT FALSE,
notas TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
}
private async createIndexes(pool: Pool): Promise<void> {
await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
CREATE INDEX IF NOT EXISTS idx_cfdis_estado ON cfdis(estado);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
`);
}
}
// Singleton instance
export const tenantDb = new TenantConnectionManager();

View File

@@ -13,12 +13,29 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
});
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
}
// Frontend URL (for MercadoPago back_url, emails, etc.)
FRONTEND_URL: z.string().default('https://horuxfin.com'),
// FIEL encryption (separate from JWT to allow independent rotation)
FIEL_ENCRYPTION_KEY: z.string().min(32),
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
// MercadoPago
MP_ACCESS_TOKEN: z.string().optional(),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// SMTP (Gmail Workspace)
SMTP_HOST: z.string().default('smtp.gmail.com'),
SMTP_PORT: z.string().default('587'),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
// Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
});
const parsed = envSchema.safeParse(process.env);
@@ -28,3 +45,8 @@ if (!parsed.success) {
}
export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

View File

@@ -1,10 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as alertasService from '../services/alertas.service.js';
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
const { leida, resuelta, prioridad } = req.query;
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
const alertas = await alertasService.getAlertas(req.tenantPool!, {
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
prioridad: prioridad as string,
@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id));
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' });
}
@@ -29,7 +29,7 @@ export async function getAlerta(req: Request, res: Response, next: NextFunction)
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
const alerta = await alertasService.createAlerta(req.tenantPool!, req.body);
res.status(201).json(alerta);
} catch (error) {
next(error);
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body);
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), req.body);
res.json(alerta);
} catch (error) {
next(error);
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);
@@ -56,7 +56,7 @@ export async function deleteAlerta(req: Request, res: Response, next: NextFuncti
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
const stats = await alertasService.getStats(req.tenantSchema!);
const stats = await alertasService.getStats(req.tenantPool!);
res.json(stats);
} catch (error) {
next(error);
@@ -65,7 +65,7 @@ export async function getStats(req: Request, res: Response, next: NextFunction)
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.markAllAsRead(req.tenantSchema!);
await alertasService.markAllAsRead(req.tenantPool!);
res.json({ success: true });
} catch (error) {
next(error);

View File

@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as calendarioService from '../services/calendario.service.js';
export async function getEventos(req: Request, res: Response, next: NextFunction) {
@@ -7,7 +7,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
const añoNum = parseInt(año as string) || new Date().getFullYear();
const mesNum = mes ? parseInt(mes as string) : undefined;
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
const eventos = await calendarioService.getEventos(req.tenantPool!, añoNum, mesNum);
res.json(eventos);
} catch (error) {
next(error);
@@ -17,7 +17,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
export async function getProximos(req: Request, res: Response, next: NextFunction) {
try {
const dias = parseInt(req.query.dias as string) || 30;
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
const eventos = await calendarioService.getProximosEventos(req.tenantPool!, dias);
res.json(eventos);
} catch (error) {
next(error);
@@ -26,7 +26,7 @@ export async function getProximos(req: Request, res: Response, next: NextFunctio
export async function createEvento(req: Request, res: Response, next: NextFunction) {
try {
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
const evento = await calendarioService.createEvento(req.tenantPool!, req.body);
res.status(201).json(evento);
} catch (error) {
next(error);
@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
try {
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body);
const evento = await calendarioService.updateEvento(req.tenantPool!, parseInt(String(req.params.id)), req.body);
res.json(evento);
} catch (error) {
next(error);
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
try {
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
await calendarioService.deleteEvento(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -5,8 +5,8 @@ import type { CfdiFilters } from '@horux/shared';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
@@ -15,12 +15,14 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
fechaInicio: req.query.fechaInicio as string,
fechaFin: req.query.fechaFin as string,
rfc: req.query.rfc as string,
emisor: req.query.emisor as string,
receptor: req.query.receptor as string,
search: req.query.search as string,
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getCfdis(req.tenantSchema, filters);
const result = await cfdiService.getCfdis(req.tenantPool, filters);
res.json(result);
} catch (error) {
next(error);
@@ -29,11 +31,11 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id);
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado'));
@@ -45,16 +47,72 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
}
}
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const emisores = await cfdiService.getEmisores(req.tenantPool, search);
res.json(emisores);
} catch (error) {
next(error);
}
}
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
if (search.length < 2) {
return res.json([]);
}
const receptores = await cfdiService.getReceptores(req.tenantPool, search);
res.json(receptores);
} catch (error) {
next(error);
}
}
export async function getResumen(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -63,16 +121,15 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
// Only admin and contador can create CFDIs
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
res.status(201).json(cfdi);
} catch (error: any) {
if (error.message?.includes('duplicate')) {
@@ -84,8 +141,8 @@ export async function createCfdi(req: Request, res: Response, next: NextFunction
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
@@ -102,9 +159,9 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
totalFiles: req.body.totalFiles || req.body.cfdis.length
};
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis);
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
res.status(201).json({
message: `Lote ${batchInfo.batchNumber} procesado`,
@@ -113,7 +170,7 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
inserted: result.inserted,
duplicates: result.duplicates,
errors: result.errors,
errorMessages: result.errorMessages.slice(0, 5) // Limit error messages
errorMessages: result.errorMessages.slice(0, 5)
});
} catch (error: any) {
console.error('[CFDI Bulk Error]', error.message, error.stack);
@@ -123,15 +180,15 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
}
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -4,14 +4,14 @@ import { AppError } from '../middlewares/error.middleware.js';
export async function getKpis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
const kpis = await dashboardService.getKpis(req.tenantPool, año, mes);
res.json(kpis);
} catch (error) {
next(error);
@@ -20,13 +20,13 @@ export async function getKpis(req: Request, res: Response, next: NextFunction) {
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -35,14 +35,14 @@ export async function getIngresosEgresos(req: Request, res: Response, next: Next
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
const resumen = await dashboardService.getResumenFiscal(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -51,13 +51,13 @@ export async function getResumenFiscal(req: Request, res: Response, next: NextFu
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const limit = parseInt(req.query.limit as string) || 5;
const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
const alertas = await dashboardService.getAlertas(req.tenantPool, limit);
res.json(alertas);
} catch (error) {
next(error);

View File

@@ -1,10 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as exportService from '../services/export.service.js';
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, estado, fechaInicio, fechaFin } = req.query;
const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, {
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
tipo: tipo as string,
estado: estado as string,
fechaInicio: fechaInicio as string,
@@ -27,7 +27,7 @@ export async function exportReporte(req: Request, res: Response, next: NextFunct
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const buffer = await exportService.exportReporteToExcel(
req.tenantSchema!,
req.tenantPool!,
tipo as 'estado-resultados' | 'flujo-efectivo',
inicio,
fin

View File

@@ -0,0 +1,80 @@
import type { Request, Response } from 'express';
import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js';
import type { FielUploadRequest } from '@horux/shared';
/**
* Sube y configura las credenciales FIEL
*/
export async function upload(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { cerFile, keyFile, password } = req.body as FielUploadRequest;
if (!cerFile || !keyFile || !password) {
res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' });
return;
}
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
return;
}
if (password.length > 256) {
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
return;
}
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
if (!result.success) {
res.status(400).json({ error: result.message });
return;
}
res.json({
message: result.message,
status: result.status,
});
} catch (error: any) {
console.error('[FIEL Controller] Error en upload:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado de la FIEL configurada
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const fielStatus = await getFielStatus(tenantId);
res.json(fielStatus);
} catch (error: any) {
console.error('[FIEL Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Elimina las credenciales FIEL
*/
export async function remove(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const deleted = await deleteFiel(tenantId);
if (!deleted) {
res.status(404).json({ error: 'No hay FIEL configurada' });
return;
}
res.json({ message: 'FIEL eliminada correctamente' });
} catch (error: any) {
console.error('[FIEL Controller] Error en remove:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -4,12 +4,12 @@ import { AppError } from '../middlewares/error.middleware.js';
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
const data = await impuestosService.getIvaMensual(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -18,14 +18,14 @@ export async function getIvaMensual(req: Request, res: Response, next: NextFunct
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
const resumen = await impuestosService.getResumenIva(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -34,12 +34,12 @@ export async function getResumenIva(req: Request, res: Response, next: NextFunct
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
const data = await impuestosService.getIsrMensual(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -48,14 +48,14 @@ export async function getIsrMensual(req: Request, res: Response, next: NextFunct
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
const resumen = await impuestosService.getResumenIsr(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);

View File

@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as reportesService from '../services/reportes.service.js';
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
@@ -8,8 +8,7 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin);
res.json(data);
} catch (error) {
console.error('[reportes] Error en getEstadoResultados:', error);
@@ -24,7 +23,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin);
res.json(data);
} catch (error) {
next(error);
@@ -34,7 +33,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
try {
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await reportesService.getComparativo(req.tenantSchema!, año);
const data = await reportesService.getComparativo(req.tenantPool!, año);
res.json(data);
} catch (error) {
next(error);
@@ -49,7 +48,7 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc);
res.json(data);
} catch (error) {
next(error);

View File

@@ -0,0 +1,160 @@
import type { Request, Response } from 'express';
import {
startSync,
getSyncStatus,
getSyncHistory,
retryJob,
} from '../services/sat/sat.service.js';
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
import type { StartSyncRequest } from '@horux/shared';
import { isGlobalAdmin } from '../utils/global-admin.js';
/**
* Inicia una sincronización manual
*/
export async function start(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
const jobId = await startSync(
tenantId,
type || 'daily',
dateFrom ? new Date(dateFrom) : undefined,
dateTo ? new Date(dateTo) : undefined
);
res.json({
jobId,
message: 'Sincronización iniciada',
});
} catch (error: any) {
console.error('[SAT Controller] Error en start:', error);
if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el estado actual de sincronización
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const syncStatus = await getSyncStatus(tenantId);
res.json(syncStatus);
} catch (error: any) {
console.error('[SAT Controller] Error en status:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function history(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const result = await getSyncHistory(tenantId, page, limit);
res.json({
...result,
page,
limit,
});
} catch (error: any) {
console.error('[SAT Controller] Error en history:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene detalle de un job específico
*/
export async function jobDetail(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const { id } = req.params;
const { jobs } = await getSyncHistory(tenantId, 1, 100);
const job = jobs.find(j => j.id === id);
if (!job) {
res.status(404).json({ error: 'Job no encontrado' });
return;
}
res.json(job);
} catch (error: any) {
console.error('[SAT Controller] Error en jobDetail:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Reintenta un job fallido
*/
export async function retry(req: Request, res: Response): Promise<void> {
try {
const id = req.params.id as string;
const newJobId = await retryJob(id);
res.json({
jobId: newJobId,
message: 'Job reintentado',
});
} catch (error: any) {
console.error('[SAT Controller] Error en retry:', error);
if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Obtiene información del job programado (solo admin global)
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
const info = getJobInfo();
res.json(info);
} catch (error: any) {
console.error('[SAT Controller] Error en cronInfo:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}
/**
* Ejecuta el job de sincronización manualmente (solo admin global)
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}
// Ejecutar en background
runSatSyncJobManually().catch(err =>
console.error('[SAT Controller] Error ejecutando cron manual:', err)
);
res.json({ message: 'Job de sincronización iniciado' });
} catch (error: any) {
console.error('[SAT Controller] Error en runCron:', error);
res.status(500).json({ error: 'Error interno del servidor' });
}
}

View File

@@ -0,0 +1,68 @@
import type { Request, Response, NextFunction } from 'express';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
return isAdmin;
}
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const subscription = await subscriptionService.getActiveSubscription(tenantId);
if (!subscription) {
return res.status(404).json({ message: 'No se encontró suscripción' });
}
res.json(subscription);
} catch (error) {
next(error);
}
}
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const result = await subscriptionService.generatePaymentLink(tenantId);
res.json(result);
} catch (error) {
next(error);
}
}
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const { amount } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({ message: 'Monto inválido' });
}
const payment = await subscriptionService.markAsPaidManually(tenantId, amount);
res.json(payment);
} catch (error) {
next(error);
}
}
export async function getPayments(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const payments = await subscriptionService.getPaymentHistory(tenantId);
res.json(payments);
} catch (error) {
next(error);
}
}

View File

@@ -22,7 +22,7 @@ export async function getTenant(req: Request, res: Response, next: NextFunction)
throw new AppError(403, 'Solo administradores pueden ver detalles de clientes');
}
const tenant = await tenantsService.getTenantById(req.params.id);
const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) {
throw new AppError(404, 'Cliente no encontrado');
}
@@ -39,21 +39,24 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden crear clientes');
}
const { nombre, rfc, plan, cfdiLimit, usersLimit } = req.body;
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
if (!nombre || !rfc) {
throw new AppError(400, 'Nombre y RFC son requeridos');
if (!nombre || !rfc || !adminEmail || !adminNombre) {
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
}
const tenant = await tenantsService.createTenant({
const result = await tenantsService.createTenant({
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
adminEmail,
adminNombre,
amount: amount || 0,
});
res.status(201).json(tenant);
res.status(201).json(result);
} catch (error) {
next(error);
}
@@ -65,7 +68,7 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden editar clientes');
}
const { id } = req.params;
const id = String(req.params.id);
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const tenant = await tenantsService.updateTenant(id, {
@@ -89,7 +92,7 @@ export async function deleteTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
}
await tenantsService.deleteTenant(req.params.id);
await tenantsService.deleteTenant(String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -1,6 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../utils/errors.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
async function isGlobalAdmin(req: Request): Promise<boolean> {
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
try {
@@ -11,6 +16,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio
}
}
/**
* Obtiene todos los usuarios de todas las empresas (solo admin global)
*/
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
}
const usuarios = await usuariosService.getAllUsuarios();
res.json(usuarios);
} catch (error) {
next(error);
}
}
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') {
@@ -28,7 +48,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
}
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
const userId = req.params.id as string;
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body);
res.json(usuario);
} catch (error) {
next(error);
@@ -40,10 +61,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
}
if (req.params.id === req.user!.id) {
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* Actualiza un usuario globalmente (puede cambiar de empresa)
*/
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
}
const userId = req.params.id as string;
if (userId === req.user!.userId && req.body.tenantId) {
throw new AppError(400, 'No puedes cambiar tu propia empresa');
}
const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body);
res.json(usuario);
} catch (error) {
next(error);
}
}
/**
* Elimina un usuario globalmente
*/
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
}
const userId = req.params.id as string;
if (userId === req.user!.userId) {
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
}
await usuariosService.deleteUsuarioGlobal(userId);
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -0,0 +1,98 @@
import type { Request, Response, NextFunction } from 'express';
import * as mpService from '../services/payment/mercadopago.service.js';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { prisma } from '../config/database.js';
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try {
const { type, data } = req.body;
const xSignature = req.headers['x-signature'] as string;
const xRequestId = req.headers['x-request-id'] as string;
// Verify webhook signature (mandatory)
if (!xSignature || !xRequestId || !data?.id) {
console.warn('[WEBHOOK] Missing signature headers');
return res.status(401).json({ message: 'Missing signature headers' });
}
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
if (type === 'payment') {
await handlePaymentNotification(String(data.id));
} else if (type === 'subscription_preapproval') {
await handlePreapprovalNotification(String(data.id));
}
// Always respond 200 to acknowledge receipt
res.status(200).json({ received: true });
} catch (error) {
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
// Still respond 200 to prevent retries for processing errors
res.status(200).json({ received: true, error: 'processing_error' });
}
}
async function handlePaymentNotification(paymentId: string) {
const payment = await mpService.getPaymentDetails(paymentId);
if (!payment.externalReference) {
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
return;
}
const tenantId = payment.externalReference;
// Find the subscription for this tenant
const subscription = await prisma.subscription.findFirst({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
if (!subscription) {
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
return;
}
await subscriptionService.recordPayment({
tenantId,
subscriptionId: subscription.id,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
// If payment approved, ensure subscription is active
if (payment.status === 'approved') {
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
});
subscriptionService.invalidateSubscriptionCache(tenantId);
}
// Broadcast cache invalidation to PM2 cluster workers
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
}
async function handlePreapprovalNotification(preapprovalId: string) {
const preapproval = await mpService.getPreapproval(preapprovalId);
if (preapproval.status) {
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
}
// Broadcast cache invalidation
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId: preapprovalId },
});
if (subscription && typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
}
}

View File

@@ -1,9 +1,38 @@
import { app } from './app.js';
import { env } from './config/env.js';
import { tenantDb } from './config/database.js';
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
import { startSatSyncJob } from './jobs/sat-sync.job.js';
const PORT = parseInt(env.PORT, 10);
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`);
console.log(`📊 Environment: ${env.NODE_ENV}`);
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`API Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
// Iniciar job de sincronización SAT
if (env.NODE_ENV === 'production') {
startSatSyncJob();
}
});
// Graceful shutdown — close all tenant DB pools before exiting
const gracefulShutdown = async (signal: string) => {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
});
await tenantDb.shutdown();
process.exit(0);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// PM2 cluster: cross-worker cache invalidation
process.on('message', (msg: any) => {
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
tenantDb.invalidatePool(msg.tenantId);
invalidateTenantCache(msg.tenantId);
}
});

View File

@@ -0,0 +1,162 @@
import cron from 'node-cron';
import { prisma } from '../config/database.js';
import { startSync, getSyncStatus } from '../services/sat/sat.service.js';
import { hasFielConfigured } from '../services/fiel.service.js';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
let isRunning = false;
/**
* Obtiene los tenants que tienen FIEL configurada y activa
*/
async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true },
});
const tenantsWithFiel: string[] = [];
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (hasFiel) {
tenantsWithFiel.push(tenant.id);
}
}
return tenantsWithFiel;
}
/**
* Verifica si un tenant necesita sincronización inicial
*/
async function needsInitialSync(tenantId: string): Promise<boolean> {
const completedSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
type: 'initial',
status: 'completed',
},
});
return !completedSync;
}
/**
* Ejecuta sincronización para un tenant
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Verificar si hay sync activo
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message);
}
}
/**
* Ejecuta el job de sincronización para todos los tenants
*/
async function runSyncJob(): Promise<void> {
if (isRunning) {
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
return;
}
isRunning = true;
console.log('[SAT Cron] Iniciando job de sincronización diaria');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`);
if (tenantIds.length === 0) {
console.log('[SAT Cron] No hay tenants para sincronizar');
return;
}
// Procesar en lotes para no saturar
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
await Promise.all(batch.map(syncTenant));
// Pequeña pausa entre lotes
if (i + CONCURRENT_SYNCS < tenantIds.length) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
console.log('[SAT Cron] Job de sincronización completado');
} catch (error: any) {
console.error('[SAT Cron] Error en job:', error.message);
} finally {
isRunning = false;
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
/**
* Inicia el job programado
*/
export function startSatSyncJob(): void {
if (scheduledTask) {
console.log('[SAT Cron] Job ya está programado');
return;
}
// Validar expresión cron
if (!cron.validate(SYNC_CRON_SCHEDULE)) {
console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE);
return;
}
scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
}
/**
* Detiene el job programado
*/
export function stopSatSyncJob(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
console.log('[SAT Cron] Job detenido');
}
}
/**
* Ejecuta el job manualmente (para testing o ejecución forzada)
*/
export async function runSatSyncJobManually(): Promise<void> {
await runSyncJob();
}
/**
* Obtiene información del próximo job programado
*/
export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } {
return {
scheduled: scheduledTask !== null,
expression: SYNC_CRON_SCHEDULE,
timezone: 'America/Mexico_City',
};
}

View File

@@ -0,0 +1,37 @@
import type { Request, Response, NextFunction } from 'express';
import { hasFeature, type Plan } from '@horux/shared';
import { prisma } from '../config/database.js';
const planCache = new Map<string, { plan: string; expires: number }>();
/**
* Middleware factory that gates routes based on tenant plan features.
* Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature.
*/
export function requireFeature(feature: string) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ message: 'No autenticado' });
let plan: string;
const cached = planCache.get(req.user.tenantId);
if (cached && cached.expires > Date.now()) {
plan = cached.plan;
} else {
const tenant = await prisma.tenant.findUnique({
where: { id: req.user.tenantId },
select: { plan: true },
});
if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' });
plan = tenant.plan;
planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 });
}
if (!hasFeature(plan as Plan, feature)) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.',
});
}
next();
};
}

View File

@@ -0,0 +1,90 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
// Simple in-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) return entry.data;
const data = await fetcher();
cache.set(key, { data, expires: Date.now() + ttlMs });
return data;
}
export function invalidateTenantCache(tenantId: string) {
for (const key of cache.keys()) {
if (key.includes(tenantId)) cache.delete(key);
}
}
/**
* Checks if tenant has an active subscription before allowing write operations
*/
export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
if (!req.user) return next();
// Global admin impersonation bypasses subscription check
if (req.headers['x-view-tenant'] && await isGlobalAdmin(req.user.tenantId, req.user.role)) {
return next();
}
const subscription = await getCached(
`sub:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.subscription.findFirst({
where: { tenantId: req.user!.tenantId },
orderBy: { createdAt: 'desc' },
})
);
const allowedStatuses = ['authorized', 'pending'];
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow GET requests even with inactive subscription (read-only access)
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.',
});
}
}
next();
}
/**
* Checks if tenant has room for more CFDIs before allowing CFDI creation
*/
export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) {
if (!req.user || !req.tenantPool) return next();
const tenant = await getCached(
`tenant:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { cfdiLimit: true },
})
);
if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited
const cfdiCount = await getCached(
`cfdi-count:${req.user.tenantId}`,
5 * 60 * 1000,
async () => {
const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis');
return parseInt(result.rows[0].count);
}
);
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (cfdiCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${cfdiCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`,
});
}
next();
}

View File

@@ -1,48 +1,91 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from './error.middleware.js';
import type { Pool } from 'pg';
import { prisma, tenantDb } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
declare global {
namespace Express {
interface Request {
tenantSchema?: string;
tenantPool?: Pool;
viewingTenantId?: string;
}
}
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return next(new AppError(401, 'No autenticado'));
// Cache: tenantId -> { databaseName, expires }
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async function getTenantDatabaseName(tenantId: string): Promise<string | null> {
const cached = tenantDbCache.get(tenantId);
if (cached && cached.expires > Date.now()) return cached.databaseName;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (tenant) {
tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL });
}
return tenant?.databaseName ?? null;
}
export function invalidateTenantDbCache(tenantId: string) {
tenantDbCache.delete(tenantId);
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
try {
// Check if admin is viewing a different tenant
const viewTenantId = req.headers['x-view-tenant'] as string | undefined;
if (!req.user) {
return res.status(401).json({ message: 'No autenticado' });
}
let tenantId = req.user.tenantId;
// Only admins can view other tenants
if (viewTenantId && req.user.role === 'admin') {
tenantId = viewTenantId;
req.viewingTenantId = viewTenantId;
// Admin impersonation via X-View-Tenant header (global admin only)
const viewTenantHeader = req.headers['x-view-tenant'] as string;
if (viewTenantHeader) {
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
if (!globalAdmin) {
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
}
const viewedTenant = await prisma.tenant.findFirst({
where: {
OR: [
{ id: viewTenantHeader },
{ rfc: viewTenantHeader },
],
},
select: { id: true, databaseName: true, active: true },
});
if (!viewedTenant) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
if (!viewedTenant.active) {
return res.status(403).json({ message: 'Tenant inactivo' });
}
tenantId = viewedTenant.id;
req.viewingTenantId = viewedTenant.id;
req.tenantPool = tenantDb.getPool(tenantId, viewedTenant.databaseName);
return next();
}
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { schemaName: true, active: true },
});
if (!tenant || !tenant.active) {
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
// Normal flow: look up databaseName server-side (not from JWT)
const databaseName = await getTenantDatabaseName(tenantId);
if (!databaseName) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
req.tenantSchema = tenant.schemaName;
// Set search_path for this request
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
req.tenantPool = tenantDb.getPool(tenantId, databaseName);
next();
} catch (error) {
next(new AppError(500, 'Error al configurar tenant'));
console.error('[TenantMiddleware] Error:', error);
return res.status(500).json({ message: 'Error al resolver tenant' });
}
}

View File

@@ -1,12 +1,16 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as alertasController from '../controllers/alertas.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('alertas'));
router.get('/', alertasController.getAlertas);
router.get('/stats', alertasController.getStats);

View File

@@ -1,13 +1,41 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import rateLimit from 'express-rate-limit';
import * as authController from '../controllers/auth.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router();
const router: IRouter = Router();
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
// Rate limiting: 10 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting: 3 registrations per hour per IP
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: { message: 'Demasiados registros. Intenta de nuevo en 1 hora.' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting: 20 refresh attempts per 15 minutes per IP
const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { message: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
standardHeaders: true,
legacyHeaders: false,
});
router.post('/register', registerLimiter, authController.register);
router.post('/login', loginLimiter, authController.login);
router.post('/refresh', refreshLimiter, authController.refresh);
router.post('/logout', authenticate, authController.logout);
router.get('/me', authenticate, authController.me);
export { router as authRoutes };

View File

@@ -1,12 +1,16 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as calendarioController from '../controllers/calendario.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('calendario'));
router.get('/', calendarioController.getEventos);
router.get('/proximos', calendarioController.getProximos);

View File

@@ -1,18 +1,24 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import express from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.get('/', cfdiController.getCfdis);
router.get('/resumen', cfdiController.getResumen);
router.get('/emisores', cfdiController.getEmisores);
router.get('/receptores', cfdiController.getReceptores);
router.get('/:id', cfdiController.getCfdiById);
router.post('/', cfdiController.createCfdi);
router.post('/bulk', cfdiController.createManyCfdis);
router.get('/:id/xml', cfdiController.getXml);
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
router.post('/bulk', express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
router.delete('/:id', cfdiController.deleteCfdi);
export { router as cfdiRoutes };

View File

@@ -1,12 +1,14 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.get('/kpis', dashboardController.getKpis);
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as exportController from '../controllers/export.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);

View File

@@ -0,0 +1,19 @@
import { Router, type IRouter } from 'express';
import * as fielController from '../controllers/fiel.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router: IRouter = Router();
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/fiel/upload - Subir credenciales FIEL
router.post('/upload', fielController.upload);
// GET /api/fiel/status - Obtener estado de la FIEL
router.get('/status', fielController.status);
// DELETE /api/fiel - Eliminar credenciales FIEL
router.delete('/', fielController.remove);
export default router;

View File

@@ -1,9 +1,9 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as impuestosController from '../controllers/impuestos.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);

View File

@@ -1,12 +1,16 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as reportesController from '../controllers/reportes.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('reportes'));
router.get('/estado-resultados', reportesController.getEstadoResultados);
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);

View File

@@ -0,0 +1,29 @@
import { Router, type IRouter } from 'express';
import * as satController from '../controllers/sat.controller.js';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
const router: IRouter = Router();
// Todas las rutas requieren autenticación
router.use(authenticate);
// POST /api/sat/sync - Iniciar sincronización manual
router.post('/sync', satController.start);
// GET /api/sat/sync/status - Estado actual de sincronización
router.get('/sync/status', satController.status);
// GET /api/sat/sync/history - Historial de sincronizaciones
router.get('/sync/history', satController.history);
// GET /api/sat/sync/:id - Detalle de un job
router.get('/sync/:id', satController.jobDetail);
// POST /api/sat/sync/:id/retry - Reintentar job fallido
router.post('/sync/:id/retry', satController.retry);
// Admin-only cron endpoints (global admin verified in controller)
router.get('/cron', authorize('admin'), satController.cronInfo);
router.post('/cron/run', authorize('admin'), satController.runCron);
export default router;

View File

@@ -0,0 +1,17 @@
import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import * as subscriptionController from '../controllers/subscription.controller.js';
const router: IRouter = Router();
// All endpoints require authentication + admin role
router.use(authenticate);
router.use(authorize('admin'));
// Admin subscription management (global admin verified in controller)
router.get('/:tenantId', subscriptionController.getSubscription);
router.post('/:tenantId/generate-link', subscriptionController.generatePaymentLink);
router.post('/:tenantId/mark-paid', subscriptionController.markAsPaid);
router.get('/:tenantId/payments', subscriptionController.getPayments);
export { router as subscriptionRoutes };

View File

@@ -1,8 +1,8 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as tenantsController from '../controllers/tenants.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);

View File

@@ -1,14 +1,20 @@
import { Router } from 'express';
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as usuariosController from '../controllers/usuarios.controller.js';
const router = Router();
const router: IRouter = Router();
router.use(authenticate);
// Rutas por tenant
router.get('/', usuariosController.getUsuarios);
router.post('/invite', usuariosController.inviteUsuario);
router.patch('/:id', usuariosController.updateUsuario);
router.delete('/:id', usuariosController.deleteUsuario);
// Rutas globales (solo admin global)
router.get('/global/all', usuariosController.getAllUsuarios);
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
export { router as usuariosRoutes };

View File

@@ -0,0 +1,9 @@
import { Router, type IRouter } from 'express';
import { handleMercadoPagoWebhook } from '../controllers/webhook.controller.js';
const router: IRouter = Router();
// Public endpoint — no auth middleware
router.post('/mercadopago', handleMercadoPagoWebhook);
export { router as webhookRoutes };

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
export async function getAlertas(
schema: string,
pool: Pool,
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
): Promise<AlertaFull[]> {
let whereClause = 'WHERE 1=1';
@@ -22,43 +22,43 @@ export async function getAlertas(
params.push(filters.prioridad);
}
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
${whereClause}
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
`, ...params);
`, params);
return alertas;
return rows;
}
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
WHERE id = $1
`, id);
return alerta || null;
`, [id]);
return rows[0] || null;
}
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
const { rows } = await pool.query(`
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
return alerta;
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
return rows[0];
}
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise<AlertaFull> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -74,35 +74,35 @@ export async function updateAlerta(schema: string, id: number, data: AlertaUpdat
params.push(id);
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
UPDATE "${schema}".alertas
const { rows } = await pool.query(`
UPDATE alertas
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, ...params);
`, params);
return alerta;
return rows[0];
}
export async function deleteAlerta(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
}
export async function getStats(schema: string): Promise<AlertasStats> {
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
export async function getStats(pool: Pool): Promise<AlertasStats> {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
FROM "${schema}".alertas
FROM alertas
`);
return stats;
}
export async function markAllAsRead(schema: string): Promise<void> {
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
export async function markAllAsRead(pool: Pool): Promise<void> {
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
}

View File

@@ -1,14 +1,13 @@
import { prisma } from '../config/database.js';
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword, verifyPassword } from '../utils/password.js';
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
import { createTenantSchema } from '../utils/schema-manager.js';
import { AppError } from '../middlewares/error.middleware.js';
import { PLANS } from '@horux/shared';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
export async function register(data: RegisterRequest): Promise<LoginResponse> {
const existingUser = await prisma.user.findUnique({
where: { email: data.usuario.email },
where: { email: data.usuario.email.toLowerCase() },
});
if (existingUser) {
@@ -23,21 +22,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
throw new AppError(400, 'El RFC ya está registrado');
}
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
// Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
const tenant = await prisma.tenant.create({
data: {
nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(),
plan: 'starter',
schemaName,
databaseName,
cfdiLimit: PLANS.starter.cfdiLimit,
usersLimit: PLANS.starter.usersLimit,
},
});
await createTenantSchema(schemaName);
const passwordHash = await hashPassword(data.usuario.password);
const user = await prisma.user.create({
data: {
@@ -54,7 +52,6 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: tenant.id,
schemaName: tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -79,6 +76,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
tenantId: tenant.id,
tenantName: tenant.nombre,
tenantRfc: tenant.rfc,
plan: tenant.plan,
},
};
}
@@ -117,7 +115,6 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -142,57 +139,61 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
tenantRfc: user.tenant.rfc,
plan: user.tenant.plan,
},
};
}
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
const storedToken = await prisma.refreshToken.findUnique({
where: { token },
});
// Use a transaction to prevent race conditions
return await prisma.$transaction(async (tx) => {
const storedToken = await tx.refreshToken.findUnique({
where: { token },
});
if (!storedToken) {
throw new AppError(401, 'Token inválido');
}
if (!storedToken) {
throw new AppError(401, 'Token inválido');
}
if (storedToken.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado');
}
if (storedToken.expiresAt < new Date()) {
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado');
}
const payload = verifyToken(token);
const payload = verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: payload.userId },
include: { tenant: true },
});
const user = await tx.user.findUnique({
where: { id: payload.userId },
include: { tenant: true },
});
if (!user || !user.active) {
throw new AppError(401, 'Usuario no encontrado o desactivado');
}
if (!user || !user.active) {
throw new AppError(401, 'Usuario no encontrado o desactivado');
}
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
// Use deleteMany to avoid error if already deleted (race condition)
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
const newTokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
await prisma.refreshToken.create({
data: {
const newTokenPayload = {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
email: user.email,
role: user.role,
tenantId: user.tenantId,
};
return { accessToken, refreshToken };
const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
await tx.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
});
}
export async function logout(token: string): Promise<void> {

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export async function getEventos(
schema: string,
pool: Pool,
año: number,
mes?: number
): Promise<EventoFiscal[]> {
@@ -14,49 +14,49 @@ export async function getEventos(
params.push(mes);
}
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
const { rows } = await pool.query(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
FROM calendario_fiscal
${whereClause}
ORDER BY fecha_limite ASC
`, ...params);
`, params);
return eventos;
return rows;
}
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
export async function getProximosEventos(pool: Pool, dias = 30): Promise<EventoFiscal[]> {
const { rows } = await pool.query(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
FROM calendario_fiscal
WHERE completado = false
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + $1 * INTERVAL '1 day'
ORDER BY fecha_limite ASC
`);
`, [dias]);
return eventos;
return rows;
}
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
INSERT INTO "${schema}".calendario_fiscal
export async function createEvento(pool: Pool, data: EventoCreate): Promise<EventoFiscal> {
const { rows } = await pool.query(`
INSERT INTO calendario_fiscal
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
`, [data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null]);
return evento;
return rows[0];
}
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
export async function updateEvento(pool: Pool, id: number, data: EventoUpdate): Promise<EventoFiscal> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -84,19 +84,19 @@ export async function updateEvento(schema: string, id: number, data: EventoUpdat
params.push(id);
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
UPDATE "${schema}".calendario_fiscal
const { rows } = await pool.query(`
UPDATE calendario_fiscal
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, ...params);
`, params);
return evento;
return rows[0];
}
export async function deleteEvento(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
export async function deleteEvento(pool: Pool, id: number): Promise<void> {
await pool.query(`DELETE FROM calendario_fiscal WHERE id = $1`, [id]);
}

View File

@@ -1,7 +1,7 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiListResponse> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const offset = (page - 1) * limit;
@@ -21,12 +21,12 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
@@ -35,19 +35,23 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
params.push(`%${filters.rfc}%`);
}
if (filters.emisor) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
params.push(`%${filters.emisor}%`);
}
if (filters.receptor) {
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.receptor}%`);
}
if (filters.search) {
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
`, ...params);
const total = Number(countResult[0]?.count || 0);
params.push(limit, offset);
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
const { rows: dataWithCount } = await pool.query(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
@@ -58,12 +62,16 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
FROM "${schema}".cfdis
created_at as "createdAt",
COUNT(*) OVER() as total_count
FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, ...params);
`, params);
const total = Number(dataWithCount[0]?.total_count || 0);
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
return {
data,
@@ -74,8 +82,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
};
}
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
const { rows } = await pool.query(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
@@ -86,12 +94,21 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
xml_original as "xmlOriginal",
created_at as "createdAt"
FROM "${schema}".cfdis
WHERE id = $1
`, id);
FROM cfdis
WHERE id = $1::uuid
`, [id]);
return result[0] || null;
return rows[0] || null;
}
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
const { rows } = await pool.query(`
SELECT xml_original FROM cfdis WHERE id = $1::uuid
`, [id]);
return rows[0]?.xml_original || null;
}
export interface CreateCfdiData {
@@ -121,18 +138,15 @@ export interface CreateCfdiData {
pdfUrl?: string;
}
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
// Validate required fields
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
// Parse dates safely - handle YYYY-MM-DD format explicitly
let fechaEmision: Date;
let fechaTimbrado: Date;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaEmision}T12:00:00`
: data.fechaEmision;
@@ -155,8 +169,8 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
}
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
INSERT INTO "${schema}".cfdis (
const { rows } = await pool.query(`
INSERT INTO cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -173,7 +187,7 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
`,
`, [
data.uuidFiscal,
data.tipo || 'ingreso',
data.serie || null,
@@ -198,9 +212,9 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
data.estado || 'vigente',
data.xmlUrl || null,
data.pdfUrl || null
);
]);
return result[0];
return rows[0];
}
export interface BatchInsertResult {
@@ -210,14 +224,12 @@ export interface BatchInsertResult {
errorMessages: string[];
}
// Optimized batch insert using multi-row INSERT
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
const result = await createManyCfdisBatch(schema, cfdis);
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
const result = await createManyCfdisBatch(pool, cfdis);
return result.inserted;
}
// New optimized batch insert with detailed results
export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
const result: BatchInsertResult = {
inserted: 0,
duplicates: 0,
@@ -227,19 +239,17 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
if (cfdis.length === 0) return result;
// Process in batches of 500 for optimal performance
const BATCH_SIZE = 500;
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
try {
const batchResult = await insertBatch(schema, batch);
const batchResult = await insertBatch(pool, batch);
result.inserted += batchResult.inserted;
result.duplicates += batchResult.duplicates;
} catch (error: any) {
// If batch fails, try individual inserts for this batch
const individualResult = await insertIndividually(schema, batch);
const individualResult = await insertIndividually(pool, batch);
result.inserted += individualResult.inserted;
result.duplicates += individualResult.duplicates;
result.errors += individualResult.errors;
@@ -250,17 +260,14 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
return result;
}
// Insert a batch using multi-row INSERT with ON CONFLICT
async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
async function insertBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
// Build the VALUES part of the query
const values: any[] = [];
const valuePlaceholders: string[] = [];
let paramIndex = 1;
for (const cfdi of cfdis) {
// Parse dates
const fechaEmision = parseDate(cfdi.fechaEmision);
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
@@ -304,9 +311,8 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
return { inserted: 0, duplicates: 0 };
}
// Use ON CONFLICT to handle duplicates gracefully
const query = `
INSERT INTO "${schema}".cfdis (
INSERT INTO cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -315,15 +321,12 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
ON CONFLICT (uuid_fiscal) DO NOTHING
`;
await prisma.$executeRawUnsafe(query, ...values);
await pool.query(query, values);
// We can't know exactly how many were inserted vs duplicates with DO NOTHING
// Return optimistic count, duplicates will be 0 (they're silently skipped)
return { inserted: valuePlaceholders.length, duplicates: 0 };
}
// Fallback: insert individually when batch fails
async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
async function insertIndividually(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
const result: BatchInsertResult = {
inserted: 0,
duplicates: 0,
@@ -333,7 +336,7 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
for (const cfdi of cfdis) {
try {
await createCfdi(schema, cfdi);
await createCfdi(pool, cfdi);
result.inserted++;
} catch (error: any) {
const errorMsg = error.message || 'Error desconocido';
@@ -351,11 +354,9 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
return result;
}
// Helper to parse dates safely
function parseDate(dateStr: string): Date | null {
if (!dateStr) return null;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/)
? `${dateStr}T12:00:00`
: dateStr;
@@ -364,19 +365,34 @@ function parseDate(dateStr: string): Date | null {
return isNaN(date.getTime()) ? null : date;
}
export async function deleteCfdi(schema: string, id: string): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
}
export async function getResumenCfdis(schema: string, año: number, mes: number) {
const result = await prisma.$queryRawUnsafe<[{
total_ingresos: number;
total_egresos: number;
count_ingresos: number;
count_egresos: number;
iva_trasladado: number;
iva_acreditable: number;
}]>(`
export async function getEmisores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const { rows } = await pool.query(`
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
FROM cfdis
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
ORDER BY nombre_emisor
LIMIT $2
`, [`%${search}%`, limit]);
return rows;
}
export async function getReceptores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const { rows } = await pool.query(`
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
FROM cfdis
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
ORDER BY nombre_receptor
LIMIT $2
`, [`%${search}%`, limit]);
return rows;
}
export async function getResumenCfdis(pool: Pool, año: number, mes: number) {
const { rows } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
@@ -384,13 +400,13 @@ export async function getResumenCfdis(schema: string, año: number, mes: number)
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const r = result[0];
const r = rows[0];
return {
totalIngresos: Number(r?.total_ingresos || 0),
totalEgresos: Number(r?.total_egresos || 0),

View File

@@ -1,44 +1,44 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
export async function getKpis(pool: Pool, año: number, mes: number): Promise<KpiData> {
const { rows: [ingresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [egresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
const { rows: [ivaData] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
const { rows: [counts] } = await pool.query(`
SELECT
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const ingresosVal = Number(ingresos?.total || 0);
const egresosVal = Number(egresos?.total || 0);
@@ -57,23 +57,23 @@ export async function getKpis(schema: string, año: number, mes: number): Promis
};
}
export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
export async function getIngresosEgresos(pool: Pool, año: number): Promise<IngresosEgresosData[]> {
const { rows: data } = await pool.query(`
SELECT
EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY EXTRACT(MONTH FROM fecha_emision)
ORDER BY mes
`, año);
`, [año]);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
return meses.map((mes, index) => {
const found = data.find(d => d.mes === index + 1);
const found = data.find((d: any) => d.mes === index + 1);
return {
mes,
ingresos: Number(found?.ingresos || 0),
@@ -82,16 +82,17 @@ export async function getIngresosEgresos(schema: string, año: number): Promise<
});
}
export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
SELECT resultado, acumulado FROM "${schema}".iva_mensual
export async function getResumenFiscal(pool: Pool, año: number, mes: number): Promise<ResumenFiscal> {
const { rows: ivaRows } = await pool.query(`
SELECT resultado, acumulado FROM iva_mensual
WHERE año = $1 AND mes = $2
`, año, mes) || [{ resultado: 0, acumulado: 0 }];
`, [año, mes]);
const ivaResult = ivaRows[0] || { resultado: 0, acumulado: 0 };
const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
const { rows: [pendientes] } = await pool.query(`
SELECT COUNT(*) as count FROM iva_mensual
WHERE año = $1 AND estado = 'pendiente'
`, año);
`, [año]);
const resultado = Number(ivaResult?.resultado || 0);
const acumulado = Number(ivaResult?.acumulado || 0);
@@ -108,19 +109,19 @@ export async function getResumenFiscal(schema: string, año: number, mes: number
};
}
export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
export async function getAlertas(pool: Pool, limit = 5): Promise<Alerta[]> {
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta,
created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
WHERE resuelta = false
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
LIMIT $1
`, limit);
`, [limit]);
return alertas;
return rows;
}

View File

@@ -0,0 +1,93 @@
import { createTransport, type Transporter } from 'nodemailer';
import { env } from '../../config/env.js';
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (!transporter) {
if (!env.SMTP_USER || !env.SMTP_PASS) {
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
return {
sendMail: async (opts: any) => {
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
return { messageId: 'mock' };
},
} as any;
}
transporter = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false, // Upgrade to TLS via STARTTLS
requireTLS: true, // Reject if STARTTLS is not available
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
}
return transporter;
}
async function sendEmail(to: string, subject: string, html: string) {
const transport = getTransporter();
try {
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject,
html,
text: html.replace(/<[^>]*>/g, ''),
});
} catch (error) {
console.error('[EMAIL] Error sending email:', error);
// Don't throw — email failure shouldn't break the main flow
}
}
export const emailService = {
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
const { welcomeEmail } = await import('./templates/welcome.js');
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
},
sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
const { fielNotificationEmail } = await import('./templates/fiel-notification.js');
await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
},
sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js');
await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
},
sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
const { paymentFailedEmail } = await import('./templates/payment-failed.js');
await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
},
sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js');
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
},
sendNewClientAdmin: async (data: {
clienteNombre: string;
clienteRfc: string;
adminEmail: string;
adminNombre: string;
tempPassword: string;
databaseName: string;
plan: string;
}) => {
const { newClientAdminEmail } = await import('./templates/new-client-admin.js');
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
},
};

View File

@@ -0,0 +1,35 @@
export function baseTemplate(content: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background-color:#1e293b;padding:24px 32px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;">Horux360</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
${content}
</td>
</tr>
<tr>
<td style="background-color:#f8fafc;padding:16px 32px;text-align:center;font-size:12px;color:#94a3b8;">
<p style="margin:0;">&copy; ${new Date().getFullYear()} Horux360 - Plataforma Fiscal Inteligente</p>
<p style="margin:4px 0 0;">Consultoria Alcaraz Salazar</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">FIEL Subida</h2>
<p style="color:#475569;line-height:1.6;">El cliente <strong>${data.clienteNombre}</strong> ha subido su e.firma (FIEL).</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Empresa:</strong> ${data.clienteNombre}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>RFC:</strong> ${data.clienteRfc}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${new Date().toLocaleString('es-MX')}</p>
</div>
<p style="color:#475569;line-height:1.6;">Ya puedes iniciar la sincronización de CFDIs para este cliente.</p>
`);
}

View File

@@ -0,0 +1,68 @@
import { baseTemplate } from './base.js';
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function newClientAdminEmail(data: {
clienteNombre: string;
clienteRfc: string;
adminEmail: string;
adminNombre: string;
tempPassword: string;
databaseName: string;
plan: string;
}): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Nuevo Cliente Registrado</h2>
<p style="color:#475569;line-height:1.6;margin:0 0 24px;">
Se ha dado de alta un nuevo cliente en Horux360. A continuación los detalles:
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td colspan="2" style="background-color:#1e293b;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
Datos del Cliente
</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Empresa</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteNombre)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">RFC</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteRfc)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Plan</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.plan)}</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td colspan="2" style="background-color:#3b82f6;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
Credenciales del Usuario
</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Nombre</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminNombre)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Email</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminEmail)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Contraseña temporal</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;">
<code style="background-color:#f1f5f9;padding:4px 8px;border-radius:4px;font-size:14px;color:#dc2626;">${escapeHtml(data.tempPassword)}</code>
</td>
</tr>
</table>
<p style="color:#94a3b8;font-size:12px;margin:0;">
Este correo contiene información confidencial. No lo reenvíes ni lo compartas.
</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Pago Confirmado</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Hemos recibido tu pago correctamente.</p>
<div style="background-color:#f0fdf4;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #22c55e;">
<p style="margin:0;color:#334155;"><strong>Monto:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${data.date}</p>
</div>
<p style="color:#475569;line-height:1.6;">Tu suscripción está activa. Gracias por confiar en Horux360.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Problema con tu Pago</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">No pudimos procesar tu pago. Por favor verifica tu método de pago.</p>
<div style="background-color:#fef2f2;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #ef4444;">
<p style="margin:0;color:#334155;"><strong>Monto pendiente:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
</div>
<p style="color:#475569;line-height:1.6;">Si necesitas ayuda, contacta a soporte respondiendo a este correo.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Suscripción Cancelada</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> ha sido cancelada.</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;">Tu acceso continuará activo hasta el final del período actual de facturación.</p>
<p style="margin:8px 0 0;color:#334155;">Después de eso, solo tendrás acceso de lectura a tus datos.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si deseas reactivar tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,13 @@
import { baseTemplate } from './base.js';
export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Tu Suscripción Vence Pronto</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> vence el <strong>${data.expiresAt}</strong>.</p>
<div style="background-color:#fffbeb;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #f59e0b;">
<p style="margin:0;color:#334155;">Para evitar interrupciones en el servicio, asegúrate de que tu método de pago esté actualizado.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si tienes alguna pregunta sobre tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Bienvenido a Horux360</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Email:</strong> ${data.email}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Contraseña temporal:</strong> ${data.tempPassword}</p>
</div>
<p style="color:#475569;line-height:1.6;">Te recomendamos cambiar tu contraseña después de iniciar sesión.</p>
<a href="https://horux360.consultoria-as.com/login" style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:16px;">Iniciar sesión</a>
`);
}

View File

@@ -1,8 +1,8 @@
import ExcelJS from 'exceljs';
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
export async function exportCfdisToExcel(
schema: string,
pool: Pool,
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
): Promise<Buffer> {
let whereClause = 'WHERE 1=1';
@@ -26,15 +26,15 @@ export async function exportCfdisToExcel(
params.push(filters.fechaFin);
}
const cfdis = await prisma.$queryRawUnsafe<any[]>(`
const { rows: cfdis } = await pool.query(`
SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, metodo_pago, forma_pago, uso_cfdi, estado
FROM "${schema}".cfdis
FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
`, ...params);
`, params);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('CFDIs');
@@ -63,7 +63,7 @@ export async function exportCfdisToExcel(
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
cfdis.forEach(cfdi => {
cfdis.forEach((cfdi: any) => {
sheet.addRow({
...cfdi,
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
@@ -78,7 +78,7 @@ export async function exportCfdisToExcel(
}
export async function exportReporteToExcel(
schema: string,
pool: Pool,
tipo: 'estado-resultados' | 'flujo-efectivo',
fechaInicio: string,
fechaFin: string
@@ -87,13 +87,13 @@ export async function exportReporteToExcel(
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
if (tipo === 'estado-resultados') {
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(`
const { rows: [totales] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
sheet.columns = [
{ header: 'Concepto', key: 'concepto', width: 40 },

View File

@@ -0,0 +1,291 @@
import { Credential } from '@nodecfdi/credentials/node';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { prisma } from '../config/database.js';
import { env } from '../config/env.js';
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
import { emailService } from './email/email.service.js';
import type { FielStatus } from '@horux/shared';
/**
* Sube y valida credenciales FIEL
*/
export async function uploadFiel(
tenantId: string,
cerBase64: string,
keyBase64: string,
password: string
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
try {
// Decodificar archivos de Base64
const cerData = Buffer.from(cerBase64, 'base64');
const keyData = Buffer.from(keyBase64, 'base64');
// Validar que los archivos sean válidos y coincidan
let credential: Credential;
try {
credential = Credential.create(
cerData.toString('binary'),
keyData.toString('binary'),
password
);
} catch (error: any) {
return {
success: false,
message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta',
};
}
// Verificar que sea una FIEL (no CSD)
if (!credential.isFiel()) {
return {
success: false,
message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.',
};
}
// Obtener información del certificado
const certificate = credential.certificate();
const rfc = certificate.rfc();
const serialNumber = certificate.serialNumber().bytes();
// validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime
const validFromRaw = certificate.validFromDateTime();
const validUntilRaw = certificate.validToDateTime();
const validFrom = new Date(String(validFromRaw));
const validUntil = new Date(String(validUntilRaw));
// Verificar que no esté vencida
if (new Date() > validUntil) {
return {
success: false,
message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(),
};
}
// Encriptar credenciales (per-component IV/tag)
const {
encryptedCer,
encryptedKey,
encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
} = encryptFielCredentials(cerData, keyData, password);
// Guardar o actualizar en BD
await prisma.fielCredential.upsert({
where: { tenantId },
create: {
tenantId,
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
isActive: true,
},
update: {
rfc,
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
isActive: true,
updatedAt: new Date(),
},
});
// Save encrypted files to filesystem (dual storage)
try {
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
await mkdir(fielDir, { recursive: true, mode: 0o700 });
// Re-encrypt for filesystem (independent keys from DB)
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
// Encrypt and store metadata
const metadata = JSON.stringify({
serial: serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
uploadedAt: new Date().toISOString(),
rfc: rfc.toUpperCase(),
});
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
} catch (fsError) {
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
}
// Notify admin that client uploaded FIEL
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
if (tenant) {
emailService.sendFielNotification({
clienteNombre: tenant.nombre,
clienteRfc: tenant.rfc,
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
}
const daysUntilExpiration = Math.ceil(
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return {
success: true,
message: 'FIEL configurada correctamente',
status: {
configured: true,
rfc,
serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
isExpired: false,
daysUntilExpiration,
},
};
} catch (error: any) {
console.error('[FIEL Upload Error]', error);
return {
success: false,
message: error.message || 'Error al procesar la FIEL',
};
}
}
/**
* Obtiene el estado de la FIEL de un tenant
*/
export async function getFielStatus(tenantId: string): Promise<FielStatus> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
select: {
rfc: true,
serialNumber: true,
validFrom: true,
validUntil: true,
isActive: true,
},
});
if (!fiel || !fiel.isActive) {
return { configured: false };
}
const now = new Date();
const isExpired = now > fiel.validUntil;
const daysUntilExpiration = Math.ceil(
(fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
return {
configured: true,
rfc: fiel.rfc,
serialNumber: fiel.serialNumber || undefined,
validFrom: fiel.validFrom.toISOString(),
validUntil: fiel.validUntil.toISOString(),
isExpired,
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
};
}
/**
* Elimina la FIEL de un tenant
*/
export async function deleteFiel(tenantId: string): Promise<boolean> {
try {
await prisma.fielCredential.delete({
where: { tenantId },
});
return true;
} catch {
return false;
}
}
/**
* Obtiene las credenciales desencriptadas para usar en sincronización
* Solo debe usarse internamente por el servicio de SAT
*/
export async function getDecryptedFiel(tenantId: string): Promise<{
cerContent: string;
keyContent: string;
password: string;
rfc: string;
} | null> {
const fiel = await prisma.fielCredential.findUnique({
where: { tenantId },
});
if (!fiel || !fiel.isActive) {
return null;
}
// Verificar que no esté vencida
if (new Date() > fiel.validUntil) {
return null;
}
try {
// Desencriptar credenciales (per-component IV/tag)
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cerData),
Buffer.from(fiel.keyData),
Buffer.from(fiel.keyPasswordEncrypted),
Buffer.from(fiel.cerIv),
Buffer.from(fiel.cerTag),
Buffer.from(fiel.keyIv),
Buffer.from(fiel.keyTag),
Buffer.from(fiel.passwordIv),
Buffer.from(fiel.passwordTag)
);
return {
cerContent: cerData.toString('binary'),
keyContent: keyData.toString('binary'),
password,
rfc: fiel.rfc,
};
} catch (error) {
console.error('[FIEL Decrypt Error]', error);
return null;
}
}
/**
* Verifica si un tenant tiene FIEL configurada y válida
*/
export async function hasFielConfigured(tenantId: string): Promise<boolean> {
const status = await getFielStatus(tenantId);
return status.configured && !status.isExpired;
}

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
export async function getIvaMensual(pool: Pool, año: number): Promise<IvaMensual[]> {
const { rows: data } = await pool.query(`
SELECT
id, año, mes,
iva_trasladado as "ivaTrasladado",
@@ -10,12 +10,12 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
COALESCE(iva_retenido, 0) as "ivaRetenido",
resultado, acumulado, estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".iva_mensual
FROM iva_mensual
WHERE año = $1
ORDER BY mes
`, año);
`, [año]);
return data.map(row => ({
return data.map((row: any) => ({
...row,
ivaTrasladado: Number(row.ivaTrasladado),
ivaAcreditable: Number(row.ivaAcreditable),
@@ -25,19 +25,18 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
}));
}
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
// Get from iva_mensual if exists
const existing = await prisma.$queryRawUnsafe<any[]>(`
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
`, año, mes);
export async function getResumenIva(pool: Pool, año: number, mes: number): Promise<ResumenIva> {
const { rows: existing } = await pool.query(`
SELECT * FROM iva_mensual WHERE año = $1 AND mes = $2
`, [año, mes]);
if (existing && existing.length > 0) {
const record = existing[0];
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [acumuladoResult] } = await pool.query(`
SELECT COALESCE(SUM(resultado), 0) as total
FROM "${schema}".iva_mensual
FROM iva_mensual
WHERE año = $1 AND mes <= $2
`, año, mes);
`, [año, mes]);
return {
trasladado: Number(record.iva_trasladado || 0),
@@ -48,21 +47,16 @@ export async function getResumenIva(schema: string, año: number, mes: number):
};
}
// Calculate from CFDIs if no iva_mensual record
const [calcResult] = await prisma.$queryRawUnsafe<[{
trasladado: number;
acreditable: number;
retenido: number;
}]>(`
const { rows: [calcResult] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
COALESCE(SUM(iva_retenido), 0) as retenido
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const trasladado = Number(calcResult?.trasladado || 0);
const acreditable = Number(calcResult?.acreditable || 0);
@@ -78,10 +72,9 @@ export async function getResumenIva(schema: string, año: number, mes: number):
};
}
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
// Check if isr_mensual table exists
export async function getIsrMensual(pool: Pool, año: number): Promise<IsrMensual[]> {
try {
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
const { rows: data } = await pool.query(`
SELECT
id, año, mes,
ingresos_acumulados as "ingresosAcumulados",
@@ -92,12 +85,12 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
isr_a_pagar as "isrAPagar",
estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".isr_mensual
FROM isr_mensual
WHERE año = $1
ORDER BY mes
`, año);
`, [año]);
return data.map(row => ({
return data.map((row: any) => ({
...row,
ingresosAcumulados: Number(row.ingresosAcumulados),
deducciones: Number(row.deducciones),
@@ -107,43 +100,40 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
isrAPagar: Number(row.isrAPagar),
}));
} catch {
// Table doesn't exist, return empty array
return [];
}
}
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
// Calculate from CFDIs
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
export async function getResumenIsr(pool: Pool, año: number, mes: number): Promise<ResumenIsr> {
const { rows: [ingresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [egresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [retenido] } = await pool.query(`
SELECT COALESCE(SUM(isr_retenido), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const ingresosAcumulados = Number(ingresos?.total || 0);
const deducciones = Number(egresos?.total || 0);
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
// Simplified ISR calculation (actual calculation would use SAT tables)
const isrCausado = baseGravable * 0.30; // 30% simplified rate
const isrCausado = baseGravable * 0.30;
const isrRetenido = Number(retenido?.total || 0);
const isrAPagar = Math.max(0, isrCausado - isrRetenido);

View File

@@ -0,0 +1,106 @@
import { MercadoPagoConfig, PreApproval, Payment as MPPayment } from 'mercadopago';
import { env } from '../../config/env.js';
import { createHmac } from 'crypto';
const config = new MercadoPagoConfig({
accessToken: env.MP_ACCESS_TOKEN || '',
});
const preApprovalClient = new PreApproval(config);
const paymentClient = new MPPayment(config);
/**
* Creates a recurring subscription (preapproval) in MercadoPago
*/
export async function createPreapproval(params: {
tenantId: string;
reason: string;
amount: number;
payerEmail: string;
}) {
const response = await preApprovalClient.create({
body: {
reason: params.reason,
external_reference: params.tenantId,
payer_email: params.payerEmail,
auto_recurring: {
frequency: 1,
frequency_type: 'months',
transaction_amount: params.amount,
currency_id: 'MXN',
},
back_url: `${env.FRONTEND_URL}/configuracion/suscripcion`,
},
});
return {
preapprovalId: response.id!,
initPoint: response.init_point!,
status: response.status!,
};
}
/**
* Gets subscription (preapproval) status from MercadoPago
*/
export async function getPreapproval(preapprovalId: string) {
const response = await preApprovalClient.get({ id: preapprovalId });
return {
id: response.id,
status: response.status,
payerEmail: response.payer_email,
nextPaymentDate: response.next_payment_date,
autoRecurring: response.auto_recurring,
};
}
/**
* Gets payment details from MercadoPago
*/
export async function getPaymentDetails(paymentId: string) {
const response = await paymentClient.get({ id: paymentId });
return {
id: response.id,
status: response.status,
statusDetail: response.status_detail,
transactionAmount: response.transaction_amount,
currencyId: response.currency_id,
payerEmail: response.payer?.email,
dateApproved: response.date_approved,
paymentMethodId: response.payment_method_id,
externalReference: response.external_reference,
};
}
/**
* Verifies MercadoPago webhook signature (HMAC-SHA256)
*/
export function verifyWebhookSignature(
xSignature: string,
xRequestId: string,
dataId: string
): boolean {
if (!env.MP_WEBHOOK_SECRET) {
console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook');
return false;
}
// Parse x-signature header: "ts=...,v1=..."
const parts: Record<string, string> = {};
for (const part of xSignature.split(',')) {
const [key, value] = part.split('=');
parts[key.trim()] = value.trim();
}
const ts = parts['ts'];
const v1 = parts['v1'];
if (!ts || !v1) return false;
// Build the manifest string
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET)
.update(manifest)
.digest('hex');
return hmac === v1;
}

View File

@@ -0,0 +1,232 @@
import { prisma } from '../../config/database.js';
import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js';
// Simple in-memory cache with TTL
const subscriptionCache = new Map<string, { data: any; expires: number }>();
export function invalidateSubscriptionCache(tenantId: string) {
subscriptionCache.delete(`sub:${tenantId}`);
}
/**
* Creates a subscription record in DB and a MercadoPago preapproval
*/
export async function createSubscription(params: {
tenantId: string;
plan: string;
amount: number;
payerEmail: string;
}) {
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
});
if (!tenant) throw new Error('Tenant no encontrado');
// Create MercadoPago preapproval
const mp = await mpService.createPreapproval({
tenantId: params.tenantId,
reason: `Horux360 - Plan ${params.plan} - ${tenant.nombre}`,
amount: params.amount,
payerEmail: params.payerEmail,
});
// Create DB record
const subscription = await prisma.subscription.create({
data: {
tenantId: params.tenantId,
plan: params.plan as any,
status: mp.status || 'pending',
amount: params.amount,
frequency: 'monthly',
mpPreapprovalId: mp.preapprovalId,
},
});
invalidateSubscriptionCache(params.tenantId);
return {
subscription,
paymentUrl: mp.initPoint,
};
}
/**
* Gets active subscription for a tenant (cached 5 min)
*/
export async function getActiveSubscription(tenantId: string) {
const cached = subscriptionCache.get(`sub:${tenantId}`);
if (cached && cached.expires > Date.now()) return cached.data;
const subscription = await prisma.subscription.findFirst({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
subscriptionCache.set(`sub:${tenantId}`, {
data: subscription,
expires: Date.now() + 5 * 60 * 1000,
});
return subscription;
}
/**
* Updates subscription status from webhook notification
*/
export async function updateSubscriptionStatus(mpPreapprovalId: string, status: string) {
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId },
});
if (!subscription) return null;
const updated = await prisma.subscription.update({
where: { id: subscription.id },
data: { status },
});
invalidateSubscriptionCache(subscription.tenantId);
// Handle cancellation
if (status === 'cancelled') {
const tenant = await prisma.tenant.findUnique({
where: { id: subscription.tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (tenant && tenant.users[0]) {
emailService.sendSubscriptionCancelled(tenant.users[0].email, {
nombre: tenant.nombre,
plan: subscription.plan,
}).catch(err => console.error('[EMAIL] Subscription cancelled notification failed:', err));
}
}
return updated;
}
/**
* Records a payment from MercadoPago webhook
*/
export async function recordPayment(params: {
tenantId: string;
subscriptionId: string;
mpPaymentId: string;
amount: number;
status: string;
paymentMethod: string;
}) {
const payment = await prisma.payment.create({
data: {
tenantId: params.tenantId,
subscriptionId: params.subscriptionId,
mpPaymentId: params.mpPaymentId,
amount: params.amount,
status: params.status,
paymentMethod: params.paymentMethod,
},
});
// Send email notifications based on payment status
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (tenant && tenant.users[0]) {
const subscription = await prisma.subscription.findUnique({
where: { id: params.subscriptionId },
});
if (params.status === 'approved') {
emailService.sendPaymentConfirmed(tenant.users[0].email, {
nombre: tenant.nombre,
amount: params.amount,
plan: subscription?.plan || 'N/A',
date: new Date().toLocaleDateString('es-MX'),
}).catch(err => console.error('[EMAIL] Payment confirmed notification failed:', err));
} else if (params.status === 'rejected') {
emailService.sendPaymentFailed(tenant.users[0].email, {
nombre: tenant.nombre,
amount: params.amount,
plan: subscription?.plan || 'N/A',
}).catch(err => console.error('[EMAIL] Payment failed notification failed:', err));
}
}
return payment;
}
/**
* Manually marks a subscription as paid (for bank transfers)
*/
export async function markAsPaidManually(tenantId: string, amount: number) {
const subscription = await getActiveSubscription(tenantId);
if (!subscription) throw new Error('No hay suscripción activa');
// Update subscription status
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
});
// Record the manual payment
const payment = await prisma.payment.create({
data: {
tenantId,
subscriptionId: subscription.id,
mpPaymentId: `manual-${Date.now()}`,
amount,
status: 'approved',
paymentMethod: 'bank_transfer',
},
});
invalidateSubscriptionCache(tenantId);
return payment;
}
/**
* Generates a payment link for a tenant
*/
export async function generatePaymentLink(tenantId: string) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (!tenant) throw new Error('Tenant no encontrado');
if (!tenant.users[0]) throw new Error('No admin user found');
const subscription = await getActiveSubscription(tenantId);
const plan = subscription?.plan || tenant.plan;
const amount = subscription?.amount || 0;
if (!amount) throw new Error('No se encontró monto de suscripción');
const mp = await mpService.createPreapproval({
tenantId,
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
amount,
payerEmail: tenant.users[0].email,
});
// Update subscription with new MP preapproval ID
if (subscription) {
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreapprovalId: mp.preapprovalId },
});
}
return { paymentUrl: mp.initPoint };
}
/**
* Gets payment history for a tenant
*/
export async function getPaymentHistory(tenantId: string) {
return prisma.payment.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}

View File

@@ -1,7 +1,6 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
// Helper to convert Prisma Decimal/BigInt to number
function toNumber(value: unknown): number {
if (value === null || value === undefined) return 0;
if (typeof value === 'number') return value;
@@ -14,37 +13,37 @@ function toNumber(value: unknown): number {
}
export async function getEstadoResultados(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string
): Promise<EstadoResultados> {
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
const { rows: ingresos } = await pool.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
const { rows: egresos } = await pool.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
const { rows: totalesResult } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totales = totalesResult[0];
const totalIngresos = toNumber(totales?.ingresos);
@@ -54,8 +53,8 @@ export async function getEstadoResultados(
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
ingresos: ingresos.map((i: any) => ({ concepto: i.nombre, monto: toNumber(i.total) })),
egresos: egresos.map((e: any) => ({ concepto: e.nombre, monto: toNumber(e.total) })),
totalIngresos,
totalEgresos,
utilidadBruta,
@@ -65,36 +64,36 @@ export async function getEstadoResultados(
}
export async function getFlujoEfectivo(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string
): Promise<FlujoEfectivo> {
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
const { rows: entradas } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
const { rows: salidas } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
const totalEntradas = entradas.reduce((sum: number, e: any) => sum + toNumber(e.total), 0);
const totalSalidas = salidas.reduce((sum: number, s: any) => sum + toNumber(s.total), 0);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
saldoInicial: 0,
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
entradas: entradas.map((e: any) => ({ concepto: e.mes, monto: toNumber(e.total) })),
salidas: salidas.map((s: any) => ({ concepto: s.mes, monto: toNumber(s.total) })),
totalEntradas,
totalSalidas,
flujoNeto: totalEntradas - totalSalidas,
@@ -103,36 +102,36 @@ export async function getFlujoEfectivo(
}
export async function getComparativo(
schema: string,
pool: Pool,
año: number
): Promise<ComparativoPeriodos> {
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
const { rows: actual } = await pool.query(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año);
`, [año]);
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
const { rows: anterior } = await pool.query(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año - 1);
`, [año - 1]);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
const ingresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.ingresos));
const egresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.egresos));
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
const totalAnteriorIng = anterior.reduce((a: number, b: any) => a + toNumber(b.ingresos), 0);
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
const totalAnteriorEgr = anterior.reduce((a: number, b: any) => a + toNumber(b.egresos), 0);
return {
periodos: meses,
@@ -146,25 +145,25 @@ export async function getComparativo(
}
export async function getConcentradoRfc(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string,
tipo: 'cliente' | 'proveedor'
): Promise<ConcentradoRfc[]> {
if (tipo === 'cliente') {
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
const { rows: data } = await pool.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
'cliente' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({
`, [fechaInicio, fechaFin]);
return data.map((d: any) => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'cliente' as const,
@@ -173,19 +172,19 @@ export async function getConcentradoRfc(
cantidadCfdis: d.cantidadCfdis
}));
} else {
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
const { rows: data } = await pool.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
'proveedor' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({
`, [fechaInicio, fechaFin]);
return data.map((d: any) => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'proveedor' as const,

View File

@@ -0,0 +1,160 @@
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import { createHash, randomUUID } from 'crypto';
import type { Credential } from '@nodecfdi/credentials/node';
const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc';
interface SatToken {
token: string;
expiresAt: Date;
}
/**
* Genera el timestamp para la solicitud SOAP
*/
function createTimestamp(): { created: string; expires: string } {
const now = new Date();
const created = now.toISOString();
const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
return { created, expires };
}
/**
* Construye el XML de solicitud de autenticación
*/
function buildAuthRequest(credential: Credential): string {
const timestamp = createTimestamp();
const uuid = randomUUID();
const certificate = credential.certificate();
// El PEM ya contiene el certificado en base64, solo quitamos headers y newlines
const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, '');
// Canonicalizar y firmar
const toDigestXml = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
`<u:Created>${timestamp.created}</u:Created>` +
`<u:Expires>${timestamp.expires}</u:Expires>` +
`</u:Timestamp>`;
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="#_0">` +
`<Transforms>` +
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`</Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
// Firmar con la llave privada (sign retorna binary string, convertir a base64)
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
<s:Header>
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
<u:Timestamp u:Id="_0">
<u:Created>${timestamp.created}</u:Created>
<u:Expires>${timestamp.expires}</u:Expires>
</u:Timestamp>
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<o:SecurityTokenReference>
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
</o:SecurityTokenReference>
</KeyInfo>
</Signature>
</o:Security>
</s:Header>
<s:Body>
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
</s:Body>
</s:Envelope>`;
return soapEnvelope;
}
/**
* Extrae el token de la respuesta SOAP
*/
function parseAuthResponse(responseXml: string): SatToken {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
});
const result = parser.parse(responseXml);
// Navegar la estructura de respuesta SOAP
const envelope = result.Envelope || result['s:Envelope'];
if (!envelope) {
throw new Error('Respuesta SOAP inválida');
}
const body = envelope.Body || envelope['s:Body'];
if (!body) {
throw new Error('No se encontró el cuerpo de la respuesta');
}
const autenticaResponse = body.AutenticaResponse;
if (!autenticaResponse) {
throw new Error('No se encontró AutenticaResponse');
}
const autenticaResult = autenticaResponse.AutenticaResult;
if (!autenticaResult) {
throw new Error('No se obtuvo token de autenticación');
}
// El token es un SAML assertion en base64
const token = autenticaResult;
// El token expira en 5 minutos según documentación SAT
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
return { token, expiresAt };
}
/**
* Autentica con el SAT usando la FIEL y obtiene un token
*/
export async function authenticate(credential: Credential): Promise<SatToken> {
const soapRequest = buildAuthRequest(credential);
try {
const response = await fetch(SAT_AUTH_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseAuthResponse(responseXml);
} catch (error: any) {
console.error('[SAT Auth Error]', error);
throw new Error(`Error al autenticar con el SAT: ${error.message}`);
}
}
/**
* Verifica si un token está vigente
*/
export function isTokenValid(token: SatToken): boolean {
return new Date() < token.expiresAt;
}

View File

@@ -0,0 +1,210 @@
import {
Fiel,
HttpsWebClient,
FielRequestBuilder,
Service,
QueryParameters,
DateTimePeriod,
DownloadType,
RequestType,
ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva';
export interface FielData {
cerContent: string;
keyContent: string;
password: string;
}
/**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/
export function createSatService(fielData: FielData): Service {
// Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
// Verificar que la FIEL sea válida
if (!fiel.isValid()) {
throw new Error('La FIEL no es válida o está vencida');
}
// Crear cliente HTTP
const webClient = new HttpsWebClient();
// Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
}
export interface QueryResult {
success: boolean;
requestId?: string;
message: string;
statusCode?: string;
}
export interface VerifyResult {
success: boolean;
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
packageIds: string[];
totalCfdis: number;
message: string;
statusCode?: string;
}
export interface DownloadResult {
success: boolean;
packageContent: string; // Base64 encoded ZIP
message: string;
}
/**
* Realiza una consulta al SAT para solicitar CFDIs
*/
export async function querySat(
service: Service,
fechaInicio: Date,
fechaFin: Date,
tipo: 'emitidos' | 'recibidos',
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
try {
const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio),
formatDateForSat(fechaFin)
);
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
const parameters = QueryParameters.create(period, downloadType, reqType);
const result = await service.query(parameters);
if (!result.getStatus().isAccepted()) {
return {
success: false,
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
}
return {
success: true,
requestId: result.getRequestId(),
message: 'Solicitud aceptada',
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Query Error]', error);
return {
success: false,
message: error.message || 'Error al realizar consulta',
};
}
}
/**
* Verifica el estado de una solicitud
*/
export async function verifySatRequest(
service: Service,
requestId: string
): Promise<VerifyResult> {
try {
const result = await service.verify(requestId);
const statusRequest = result.getStatusRequest();
// Debug logging
console.log('[SAT Verify Debug]', {
statusRequestValue: statusRequest.getValue(),
statusRequestEntryId: statusRequest.getEntryId(),
cfdis: result.getNumberCfdis(),
packages: result.getPackageIds(),
statusCode: result.getStatus().getCode(),
statusMsg: result.getStatus().getMessage(),
});
// Usar isTypeOf para determinar el estado
let status: VerifyResult['status'];
if (statusRequest.isTypeOf('Finished')) {
status = 'ready';
} else if (statusRequest.isTypeOf('InProgress')) {
status = 'processing';
} else if (statusRequest.isTypeOf('Accepted')) {
status = 'pending';
} else if (statusRequest.isTypeOf('Failure')) {
status = 'failed';
} else if (statusRequest.isTypeOf('Rejected')) {
status = 'rejected';
} else {
// Default: check by entryId
const entryId = statusRequest.getEntryId();
if (entryId === 'Finished') status = 'ready';
else if (entryId === 'InProgress') status = 'processing';
else if (entryId === 'Accepted') status = 'pending';
else status = 'pending';
}
return {
success: result.getStatus().isAccepted(),
status,
packageIds: result.getPackageIds(),
totalCfdis: result.getNumberCfdis(),
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Verify Error]', error);
return {
success: false,
status: 'failed',
packageIds: [],
totalCfdis: 0,
message: error.message || 'Error al verificar solicitud',
};
}
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadSatPackage(
service: Service,
packageId: string
): Promise<DownloadResult> {
try {
const result = await service.download(packageId);
if (!result.getStatus().isAccepted()) {
return {
success: false,
packageContent: '',
message: result.getStatus().getMessage(),
};
}
return {
success: true,
packageContent: result.getPackageContent(),
message: 'Paquete descargado',
};
} catch (error: any) {
console.error('[SAT Download Error]', error);
return {
success: false,
packageContent: '',
message: error.message || 'Error al descargar paquete',
};
}
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}

View File

@@ -0,0 +1,112 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
import { env } from '../../config/env.js';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const TAG_LENGTH = 16;
/**
* Deriva una clave de 256 bits del FIEL_ENCRYPTION_KEY
*/
function deriveKey(): Buffer {
return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
}
/**
* Encripta datos usando AES-256-GCM
*/
export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
const iv = randomBytes(IV_LENGTH);
const key = deriveKey();
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
return { encrypted, iv, tag };
}
/**
* Desencripta datos usando AES-256-GCM
*/
export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
/**
* Encripta un string y retorna los componentes
*/
export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
return encrypt(Buffer.from(text, 'utf-8'));
}
/**
* Desencripta a string
*/
export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string {
return decrypt(encrypted, iv, tag).toString('utf-8');
}
/**
* Encripta credenciales FIEL con IV/tag independiente por componente
*/
export function encryptFielCredentials(
cerData: Buffer,
keyData: Buffer,
password: string
): {
encryptedCer: Buffer;
encryptedKey: Buffer;
encryptedPassword: Buffer;
cerIv: Buffer;
cerTag: Buffer;
keyIv: Buffer;
keyTag: Buffer;
passwordIv: Buffer;
passwordTag: Buffer;
} {
const cer = encrypt(cerData);
const key = encrypt(keyData);
const pwd = encrypt(Buffer.from(password, 'utf-8'));
return {
encryptedCer: cer.encrypted,
encryptedKey: key.encrypted,
encryptedPassword: pwd.encrypted,
cerIv: cer.iv,
cerTag: cer.tag,
keyIv: key.iv,
keyTag: key.tag,
passwordIv: pwd.iv,
passwordTag: pwd.tag,
};
}
/**
* Desencripta credenciales FIEL (per-component IV/tag)
*/
export function decryptFielCredentials(
encryptedCer: Buffer,
encryptedKey: Buffer,
encryptedPassword: Buffer,
cerIv: Buffer,
cerTag: Buffer,
keyIv: Buffer,
keyTag: Buffer,
passwordIv: Buffer,
passwordTag: Buffer
): {
cerData: Buffer;
keyData: Buffer;
password: string;
} {
const cerData = decrypt(encryptedCer, cerIv, cerTag);
const keyData = decrypt(encryptedKey, keyIv, keyTag);
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
return { cerData, keyData, password };
}

View File

@@ -0,0 +1,408 @@
import { XMLParser } from 'fast-xml-parser';
import { createHash, randomUUID } from 'crypto';
import type { Credential } from '@nodecfdi/credentials/node';
import type {
SatDownloadRequestResponse,
SatVerifyResponse,
SatPackageResponse,
CfdiSyncType
} from '@horux/shared';
const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc';
const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc';
const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc';
type TipoSolicitud = 'CFDI' | 'Metadata';
interface RequestDownloadParams {
credential: Credential;
token: string;
rfc: string;
fechaInicio: Date;
fechaFin: Date;
tipoSolicitud: TipoSolicitud;
tipoCfdi: CfdiSyncType;
}
/**
* Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS)
*/
function formatSatDate(date: Date): string {
return date.toISOString().slice(0, 19);
}
/**
* Construye el XML de solicitud de descarga
*/
function buildDownloadRequest(params: RequestDownloadParams): string {
const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params;
const uuid = randomUUID();
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
// Construir el elemento de solicitud
const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined;
const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined;
const solicitudContent = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms>` +
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`</Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
return `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
<s:Header/>
<s:Body>
<des:SolicitaDescarga>
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:SolicitaDescarga>
</s:Body>
</s:Envelope>`;
}
/**
* Solicita la descarga de CFDIs al SAT
*/
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
const soapRequest = buildDownloadRequest(params);
try {
const response = await fetch(SAT_SOLICITUD_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
'Authorization': `WRAP access_token="${params.token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseDownloadRequestResponse(responseXml);
} catch (error: any) {
console.error('[SAT Download Request Error]', error);
throw new Error(`Error al solicitar descarga: ${error.message}`);
}
}
/**
* Parsea la respuesta de solicitud de descarga
*/
function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult;
if (!respuesta) {
throw new Error('Respuesta inválida del SAT');
}
return {
idSolicitud: respuesta['@_IdSolicitud'] || '',
codEstatus: respuesta['@_CodEstatus'] || '',
mensaje: respuesta['@_Mensaje'] || '',
};
}
/**
* Verifica el estado de una solicitud de descarga
*/
export async function verifyRequest(
credential: Credential,
token: string,
rfc: string,
idSolicitud: string
): Promise<SatVerifyResponse> {
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
<s:Header/>
<s:Body>
<des:VerificaSolicitudDescarga>
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:solicitud>
</des:VerificaSolicitudDescarga>
</s:Body>
</s:Envelope>`;
try {
const response = await fetch(SAT_VERIFICA_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
'Authorization': `WRAP access_token="${token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseVerifyResponse(responseXml);
} catch (error: any) {
console.error('[SAT Verify Error]', error);
throw new Error(`Error al verificar solicitud: ${error.message}`);
}
}
/**
* Parsea la respuesta de verificación
*/
function parseVerifyResponse(responseXml: string): SatVerifyResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult;
if (!respuesta) {
throw new Error('Respuesta de verificación inválida');
}
// Extraer paquetes
let paquetes: string[] = [];
const paquetesNode = respuesta.IdsPaquetes;
if (paquetesNode) {
if (Array.isArray(paquetesNode)) {
paquetes = paquetesNode;
} else if (typeof paquetesNode === 'string') {
paquetes = [paquetesNode];
}
}
return {
codEstatus: respuesta['@_CodEstatus'] || '',
estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10),
codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '',
numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10),
mensaje: respuesta['@_Mensaje'] || '',
paquetes,
};
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadPackage(
credential: Credential,
token: string,
rfc: string,
idPaquete: string
): Promise<SatPackageResponse> {
const certificate = credential.certificate();
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
`<Reference URI="">` +
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
`<DigestValue>${digestValue}</DigestValue>` +
`</Reference>` +
`</SignedInfo>`;
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
<s:Header/>
<s:Body>
<des:PeticionDescargaMasivaTercerosEntrada>
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
${signedInfoXml}
<SignatureValue>${signatureValue}</SignatureValue>
<KeyInfo>
<X509Data>
<X509IssuerSerial>
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
</X509IssuerSerial>
<X509Certificate>${cerB64}</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
</des:peticionDescarga>
</des:PeticionDescargaMasivaTercerosEntrada>
</s:Body>
</s:Envelope>`;
try {
const response = await fetch(SAT_DESCARGA_URL, {
method: 'POST',
headers: {
'Content-Type': 'text/xml;charset=UTF-8',
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
'Authorization': `WRAP access_token="${token}"`,
},
body: soapRequest,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
}
const responseXml = await response.text();
return parseDownloadResponse(responseXml);
} catch (error: any) {
console.error('[SAT Download Package Error]', error);
throw new Error(`Error al descargar paquete: ${error.message}`);
}
}
/**
* Parsea la respuesta de descarga de paquete
*/
function parseDownloadResponse(responseXml: string): SatPackageResponse {
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true,
attributeNamePrefix: '@_',
});
const result = parser.parse(responseXml);
const envelope = result.Envelope || result['s:Envelope'];
const body = envelope?.Body || envelope?.['s:Body'];
const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete;
if (!respuesta) {
throw new Error('No se pudo obtener el paquete');
}
return {
paquete: respuesta,
};
}
/**
* Estados de solicitud del SAT
*/
export const SAT_REQUEST_STATES = {
ACCEPTED: 1,
IN_PROGRESS: 2,
COMPLETED: 3,
ERROR: 4,
REJECTED: 5,
EXPIRED: 6,
} as const;
/**
* Verifica si la solicitud está completa
*/
export function isRequestComplete(estadoSolicitud: number): boolean {
return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED;
}
/**
* Verifica si la solicitud falló
*/
export function isRequestFailed(estadoSolicitud: number): boolean {
return (
estadoSolicitud === SAT_REQUEST_STATES.ERROR ||
estadoSolicitud === SAT_REQUEST_STATES.REJECTED ||
estadoSolicitud === SAT_REQUEST_STATES.EXPIRED
);
}
/**
* Verifica si la solicitud está en progreso
*/
export function isRequestInProgress(estadoSolicitud: number): boolean {
return (
estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED ||
estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS
);
}

View File

@@ -0,0 +1,244 @@
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
interface CfdiParsed {
uuidFiscal: string;
tipo: TipoCfdi;
serie: string | null;
folio: string | null;
fechaEmision: Date;
fechaTimbrado: Date;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
descuento: number;
iva: number;
isrRetenido: number;
ivaRetenido: number;
total: number;
moneda: string;
tipoCambio: number;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
estado: EstadoCfdi;
xmlOriginal: string;
}
interface ExtractedXml {
filename: string;
content: string;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
});
/**
* Extrae archivos XML de un paquete ZIP en base64
*/
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
const zipBuffer = Buffer.from(zipBase64, 'base64');
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
const xmlFiles: ExtractedXml[] = [];
for (const entry of entries) {
if (entry.entryName.toLowerCase().endsWith('.xml')) {
const content = entry.getData().toString('utf-8');
xmlFiles.push({
filename: entry.entryName,
content,
});
}
}
return xmlFiles;
}
/**
* Mapea el tipo de comprobante SAT a nuestro tipo
*/
function mapTipoCfdi(tipoComprobante: string): TipoCfdi {
const mapping: Record<string, TipoCfdi> = {
'I': 'ingreso',
'E': 'egreso',
'T': 'traslado',
'P': 'pago',
'N': 'nomina',
};
return mapping[tipoComprobante] || 'ingreso';
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function extractUuid(comprobante: any): string {
const complemento = comprobante.Complemento;
if (!complemento) return '';
const timbre = complemento.TimbreFiscalDigital;
if (!timbre) return '';
return timbre['@_UUID'] || '';
}
/**
* Extrae la fecha de timbrado
*/
function extractFechaTimbrado(comprobante: any): Date {
const complemento = comprobante.Complemento;
if (!complemento) return new Date();
const timbre = complemento.TimbreFiscalDigital;
if (!timbre) return new Date();
return new Date(timbre['@_FechaTimbrado']);
}
/**
* Extrae los impuestos trasladados (IVA)
*/
function extractIva(comprobante: any): number {
const impuestos = comprobante.Impuestos;
if (!impuestos) return 0;
const traslados = impuestos.Traslados?.Traslado;
if (!traslados) return 0;
const trasladoArray = Array.isArray(traslados) ? traslados : [traslados];
let totalIva = 0;
for (const traslado of trasladoArray) {
if (traslado['@_Impuesto'] === '002') { // 002 = IVA
totalIva += parseFloat(traslado['@_Importe'] || '0');
}
}
return totalIva;
}
/**
* Extrae los impuestos retenidos
*/
function extractRetenciones(comprobante: any): { isr: number; iva: number } {
const impuestos = comprobante.Impuestos;
if (!impuestos) return { isr: 0, iva: 0 };
const retenciones = impuestos.Retenciones?.Retencion;
if (!retenciones) return { isr: 0, iva: 0 };
const retencionArray = Array.isArray(retenciones) ? retenciones : [retenciones];
let isr = 0;
let iva = 0;
for (const retencion of retencionArray) {
const importe = parseFloat(retencion['@_Importe'] || '0');
if (retencion['@_Impuesto'] === '001') { // 001 = ISR
isr += importe;
} else if (retencion['@_Impuesto'] === '002') { // 002 = IVA
iva += importe;
}
}
return { isr, iva };
}
/**
* Parsea un XML de CFDI y extrae los datos relevantes
*/
export function parseXml(xmlContent: string): CfdiParsed | null {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) {
console.error('[Parser] No se encontró el nodo Comprobante');
return null;
}
const emisor = comprobante.Emisor || {};
const receptor = comprobante.Receptor || {};
const retenciones = extractRetenciones(comprobante);
const cfdi: CfdiParsed = {
uuidFiscal: extractUuid(comprobante),
tipo: mapTipoCfdi(comprobante['@_TipoDeComprobante']),
serie: comprobante['@_Serie'] || null,
folio: comprobante['@_Folio'] || null,
fechaEmision: new Date(comprobante['@_Fecha']),
fechaTimbrado: extractFechaTimbrado(comprobante),
rfcEmisor: emisor['@_Rfc'] || '',
nombreEmisor: emisor['@_Nombre'] || '',
rfcReceptor: receptor['@_Rfc'] || '',
nombreReceptor: receptor['@_Nombre'] || '',
subtotal: parseFloat(comprobante['@_SubTotal'] || '0'),
descuento: parseFloat(comprobante['@_Descuento'] || '0'),
iva: extractIva(comprobante),
isrRetenido: retenciones.isr,
ivaRetenido: retenciones.iva,
total: parseFloat(comprobante['@_Total'] || '0'),
moneda: comprobante['@_Moneda'] || 'MXN',
tipoCambio: parseFloat(comprobante['@_TipoCambio'] || '1'),
metodoPago: comprobante['@_MetodoPago'] || null,
formaPago: comprobante['@_FormaPago'] || null,
usoCfdi: receptor['@_UsoCFDI'] || null,
estado: 'vigente',
xmlOriginal: xmlContent,
};
if (!cfdi.uuidFiscal) {
console.error('[Parser] CFDI sin UUID');
return null;
}
return cfdi;
} catch (error) {
console.error('[Parser Error]', error);
return null;
}
}
/**
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
*/
export function processPackage(zipBase64: string): CfdiParsed[] {
const xmlFiles = extractXmlsFromZip(zipBase64);
const cfdis: CfdiParsed[] = [];
for (const { content } of xmlFiles) {
const cfdi = parseXml(content);
if (cfdi) {
cfdis.push(cfdi);
}
}
return cfdis;
}
/**
* Valida que un XML sea un CFDI válido
*/
export function isValidCfdi(xmlContent: string): boolean {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) return false;
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
if (!extractUuid(comprobante)) return false;
return true;
} catch {
return false;
}
}
export type { CfdiParsed, ExtractedXml };

View File

@@ -0,0 +1,590 @@
import { prisma, tenantDb } from '../../config/database.js';
import { getDecryptedFiel } from '../fiel.service.js';
import {
createSatService,
querySat,
verifySatRequest,
downloadSatPackage,
type FielData,
} from './sat-client.service.js';
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
import type { Pool } from 'pg';
const POLL_INTERVAL_MS = 30000; // 30 segundos
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
interface SyncContext {
fielData: FielData;
service: Service;
rfc: string;
tenantId: string;
pool: Pool;
}
/**
* Actualiza el progreso de un job
*/
async function updateJobProgress(
jobId: string,
updates: Partial<{
status: 'pending' | 'running' | 'completed' | 'failed';
satRequestId: string;
satPackageIds: string[];
cfdisFound: number;
cfdisDownloaded: number;
cfdisInserted: number;
cfdisUpdated: number;
progressPercent: number;
errorMessage: string;
startedAt: Date;
completedAt: Date;
retryCount: number;
nextRetryAt: Date;
}>
): Promise<void> {
await prisma.satSyncJob.update({
where: { id: jobId },
data: updates,
});
}
/**
* Guarda los CFDIs en la base de datos del tenant
*/
async function saveCfdis(
pool: Pool,
cfdis: CfdiParsed[],
jobId: string
): Promise<{ inserted: number; updated: number }> {
let inserted = 0;
let updated = 0;
for (const cfdi of cfdis) {
try {
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
[cfdi.uuidFiscal]
);
if (existing.length > 0) {
await pool.query(
`UPDATE cfdis SET
tipo = $2,
serie = $3,
folio = $4,
fecha_emision = $5,
fecha_timbrado = $6,
rfc_emisor = $7,
nombre_emisor = $8,
rfc_receptor = $9,
nombre_receptor = $10,
subtotal = $11,
descuento = $12,
iva = $13,
isr_retenido = $14,
iva_retenido = $15,
total = $16,
moneda = $17,
tipo_cambio = $18,
metodo_pago = $19,
forma_pago = $20,
uso_cfdi = $21,
estado = $22,
xml_original = $23,
last_sat_sync = NOW(),
sat_sync_job_id = $24::uuid,
updated_at = NOW()
WHERE uuid_fiscal = $1`,
[
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
cfdi.folio,
cfdi.fechaEmision,
cfdi.fechaTimbrado,
cfdi.rfcEmisor,
cfdi.nombreEmisor,
cfdi.rfcReceptor,
cfdi.nombreReceptor,
cfdi.subtotal,
cfdi.descuento,
cfdi.iva,
cfdi.isrRetenido,
cfdi.ivaRetenido,
cfdi.total,
cfdi.moneda,
cfdi.tipoCambio,
cfdi.metodoPago,
cfdi.formaPago,
cfdi.usoCfdi,
cfdi.estado,
cfdi.xmlOriginal,
jobId
]
);
updated++;
} else {
await pool.query(
`INSERT INTO cfdis (
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado,
xml_original, source, sat_sync_job_id, last_sat_sync, created_at
) VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24::uuid, NOW(), NOW()
)`,
[
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
cfdi.folio,
cfdi.fechaEmision,
cfdi.fechaTimbrado,
cfdi.rfcEmisor,
cfdi.nombreEmisor,
cfdi.rfcReceptor,
cfdi.nombreReceptor,
cfdi.subtotal,
cfdi.descuento,
cfdi.iva,
cfdi.isrRetenido,
cfdi.ivaRetenido,
cfdi.total,
cfdi.moneda,
cfdi.tipoCambio,
cfdi.metodoPago,
cfdi.formaPago,
cfdi.usoCfdi,
cfdi.estado,
cfdi.xmlOriginal,
jobId
]
);
inserted++;
}
} catch (error) {
console.error(`[SAT] Error guardando CFDI ${cfdi.uuidFiscal}:`, error);
}
}
return { inserted, updated };
}
/**
* Procesa una solicitud de descarga para un rango de fechas
*/
async function processDateRange(
ctx: SyncContext,
jobId: string,
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
if (!queryResult.success) {
if (queryResult.statusCode === '5004') {
console.log('[SAT] No se encontraron CFDIs en el rango');
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
}
throw new Error(`Error SAT: ${queryResult.message}`);
}
const requestId = queryResult.requestId!;
console.log(`[SAT] Solicitud creada: ${requestId}`);
await updateJobProgress(jobId, { satRequestId: requestId });
let verifyResult;
let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
attempts++;
verifyResult = await verifySatRequest(ctx.service, requestId);
console.log(`[SAT] Estado solicitud: ${verifyResult.status} (intento ${attempts})`);
if (verifyResult.status === 'ready') {
break;
}
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
throw new Error(`Solicitud fallida: ${verifyResult.message}`);
}
}
if (!verifyResult || verifyResult.status !== 'ready') {
throw new Error('Timeout esperando respuesta del SAT');
}
const packageIds = verifyResult.packageIds;
await updateJobProgress(jobId, {
satPackageIds: packageIds,
cfdisFound: verifyResult.totalCfdis,
});
let totalInserted = 0;
let totalUpdated = 0;
let totalDownloaded = 0;
for (let i = 0; i < packageIds.length; i++) {
const packageId = packageIds[i];
console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`);
const downloadResult = await downloadSatPackage(ctx.service, packageId);
if (!downloadResult.success) {
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
}
const cfdis = processPackage(downloadResult.packageContent);
totalDownloaded += cfdis.length;
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
const { inserted, updated } = await saveCfdis(ctx.pool, cfdis, jobId);
totalInserted += inserted;
totalUpdated += updated;
const progress = Math.round(((i + 1) / packageIds.length) * 100);
await updateJobProgress(jobId, {
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: progress,
});
}
return {
found: verifyResult.totalCfdis,
downloaded: totalDownloaded,
inserted: totalInserted,
updated: totalUpdated,
};
}
/**
* Ejecuta sincronización inicial o por rango personalizado
*/
async function processInitialSync(
ctx: SyncContext,
jobId: string,
customDateFrom?: Date,
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
const fechaFin = customDateTo || ahora;
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
let currentDate = new Date(inicioHistorico);
while (currentDate < fechaFin) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
try {
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
}
try {
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
}
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
await new Promise(resolve => setTimeout(resolve, 5000));
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* Ejecuta sincronización diaria (mes actual)
*/
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
const ahora = new Date();
const inicioMes = new Date(ahora.getFullYear(), ahora.getMonth(), 1);
let totalFound = 0;
let totalDownloaded = 0;
let totalInserted = 0;
let totalUpdated = 0;
try {
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
totalUpdated += emitidos.updated;
} catch (error: any) {
console.error('[SAT] Error procesando emitidos:', error.message);
}
try {
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
totalUpdated += recibidos.updated;
} catch (error: any) {
console.error('[SAT] Error procesando recibidos:', error.message);
}
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
/**
* Inicia la sincronización con el SAT
*/
export async function startSync(
tenantId: string,
type: SatSyncType = 'daily',
dateFrom?: Date,
dateTo?: Date
): Promise<string> {
const decryptedFiel = await getDecryptedFiel(tenantId);
if (!decryptedFiel) {
throw new Error('No hay FIEL configurada o está vencida');
}
const fielData: FielData = {
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
};
const service = createSatService(fielData);
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant) {
throw new Error('Tenant no encontrado');
}
const activeSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: { in: ['pending', 'running'] },
},
});
if (activeSync) {
throw new Error('Ya hay una sincronización en curso');
}
const now = new Date();
const job = await prisma.satSyncJob.create({
data: {
tenantId,
type,
status: 'running',
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
dateTo: dateTo || now,
startedAt: now,
},
});
const ctx: SyncContext = {
fielData,
service,
rfc: decryptedFiel.rfc,
tenantId,
pool: tenantDb.getPool(tenantId, tenant.databaseName),
};
// Ejecutar sincronización en background
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id, dateFrom, dateTo);
} else {
await processDailySync(ctx, job.id);
}
await updateJobProgress(job.id, {
status: 'completed',
completedAt: new Date(),
progressPercent: 100,
});
console.log(`[SAT] Sincronización ${job.id} completada`);
} catch (error: any) {
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
await updateJobProgress(job.id, {
status: 'failed',
errorMessage: error.message,
completedAt: new Date(),
});
}
})();
return job.id;
}
/**
* Obtiene el estado actual de sincronización de un tenant
*/
export async function getSyncStatus(tenantId: string): Promise<{
hasActiveSync: boolean;
currentJob?: SatSyncJob;
lastCompletedJob?: SatSyncJob;
totalCfdisSynced: number;
}> {
const activeJob = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: { in: ['pending', 'running'] },
},
orderBy: { createdAt: 'desc' },
});
const lastCompleted = await prisma.satSyncJob.findFirst({
where: {
tenantId,
status: 'completed',
},
orderBy: { completedAt: 'desc' },
});
const totals = await prisma.satSyncJob.aggregate({
where: {
tenantId,
status: 'completed',
},
_sum: {
cfdisInserted: true,
},
});
const mapJob = (job: any): SatSyncJob => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
});
return {
hasActiveSync: !!activeJob,
currentJob: activeJob ? mapJob(activeJob) : undefined,
lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined,
totalCfdisSynced: totals._sum.cfdisInserted || 0,
};
}
/**
* Obtiene el historial de sincronizaciones
*/
export async function getSyncHistory(
tenantId: string,
page: number = 1,
limit: number = 10
): Promise<{ jobs: SatSyncJob[]; total: number }> {
const [jobs, total] = await Promise.all([
prisma.satSyncJob.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.satSyncJob.count({ where: { tenantId } }),
]);
return {
jobs: jobs.map(job => ({
id: job.id,
tenantId: job.tenantId,
type: job.type,
status: job.status,
dateFrom: job.dateFrom.toISOString(),
dateTo: job.dateTo.toISOString(),
cfdiType: job.cfdiType ?? undefined,
satRequestId: job.satRequestId ?? undefined,
satPackageIds: job.satPackageIds,
cfdisFound: job.cfdisFound,
cfdisDownloaded: job.cfdisDownloaded,
cfdisInserted: job.cfdisInserted,
cfdisUpdated: job.cfdisUpdated,
progressPercent: job.progressPercent,
errorMessage: job.errorMessage ?? undefined,
startedAt: job.startedAt?.toISOString(),
completedAt: job.completedAt?.toISOString(),
createdAt: job.createdAt.toISOString(),
retryCount: job.retryCount,
})),
total,
};
}
/**
* Reintenta un job fallido
*/
export async function retryJob(jobId: string): Promise<string> {
const job = await prisma.satSyncJob.findUnique({
where: { id: jobId },
});
if (!job) {
throw new Error('Job no encontrado');
}
if (job.status !== 'failed') {
throw new Error('Solo se pueden reintentar jobs fallidos');
}
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo);
}

View File

@@ -1,4 +1,8 @@
import { prisma } from '../config/database.js';
import { prisma, tenantDb } from '../config/database.js';
import { PLANS } from '@horux/shared';
import { emailService } from './email/email.service.js';
import { randomBytes } from 'crypto';
import bcrypt from 'bcryptjs';
export async function getAllTenants() {
return prisma.tenant.findMany({
@@ -8,7 +12,7 @@ export async function getAllTenants() {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
createdAt: true,
_count: {
select: { users: true }
@@ -26,7 +30,7 @@ export async function getTenantById(id: string) {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
createdAt: true,
@@ -40,104 +44,72 @@ export async function createTenant(data: {
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
adminEmail: string;
adminNombre: string;
amount: number;
}) {
const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const plan = data.plan || 'starter';
const planConfig = PLANS[plan];
// Create tenant record
// 1. Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.rfc);
// 2. Create tenant record
const tenant = await prisma.tenant.create({
data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan: data.plan || 'starter',
schemaName,
cfdiLimit: data.cfdiLimit || 500,
usersLimit: data.usersLimit || 3,
plan,
databaseName,
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
usersLimit: data.usersLimit || planConfig.usersLimit,
}
});
// Create schema and tables for the tenant
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// 3. Create admin user with temp password
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
const hashedPassword = await bcrypt.hash(tempPassword, 10);
// Create CFDIs table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL,
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP NOT NULL,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) NOT NULL,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) NOT NULL,
moneda VARCHAR(3) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(4),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
email: data.adminEmail,
passwordHash: hashedPassword,
nombre: data.adminNombre,
role: 'admin',
},
});
// Create IVA monthly table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INT NOT NULL,
mes INT NOT NULL,
iva_trasladado DECIMAL(18,2) NOT NULL,
iva_acreditable DECIMAL(18,2) NOT NULL,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) NOT NULL,
acumulado DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
// 4. Create initial subscription
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan,
status: 'pending',
amount: data.amount,
frequency: 'monthly',
},
});
// Create alerts table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT NOT NULL,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 5. Send welcome email to client (non-blocking)
emailService.sendWelcome(data.adminEmail, {
nombre: data.adminNombre,
email: data.adminEmail,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
// Create calendario_fiscal table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(20) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'mensual',
completado BOOLEAN DEFAULT FALSE,
notas TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 6. Send new client notification to admin with DB credentials
emailService.sendNewClientAdmin({
clienteNombre: data.nombre,
clienteRfc: data.rfc.toUpperCase(),
adminEmail: data.adminEmail,
adminNombre: data.adminNombre,
tempPassword,
databaseName,
plan,
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
return tenant;
return { tenant, user, tempPassword };
}
export async function updateTenant(id: string, data: {
@@ -163,7 +135,7 @@ export async function updateTenant(id: string, data: {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
active: true,
@@ -173,9 +145,20 @@ export async function updateTenant(id: string, data: {
}
export async function deleteTenant(id: string) {
// Soft delete - just mark as inactive
return prisma.tenant.update({
const tenant = await prisma.tenant.findUnique({
where: { id },
select: { databaseName: true },
});
// Soft-delete the tenant record
await prisma.tenant.update({
where: { id },
data: { active: false }
});
// Soft-delete the database (rename with _deleted_ suffix)
if (tenant) {
await tenantDb.deprovisionDatabase(tenant.databaseName);
tenantDb.invalidatePool(id);
}
}

View File

@@ -1,5 +1,6 @@
import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
@@ -37,8 +38,8 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
throw new Error('Límite de usuarios alcanzado para este plan');
}
// Generate temporary password
const tempPassword = Math.random().toString(36).slice(-8);
// Generate cryptographically secure temporary password
const tempPassword = randomBytes(4).toString('hex');
const passwordHash = await bcrypt.hash(tempPassword, 12);
const user = await prisma.user.create({
@@ -60,8 +61,7 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
},
});
// In production, send email with tempPassword
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
// TODO: Send email with tempPassword to the invited user
return {
...user,
@@ -105,3 +105,93 @@ export async function deleteUsuario(tenantId: string, userId: string): Promise<v
where: { id: userId, tenantId },
});
}
/**
* Obtiene todos los usuarios de todas las empresas (solo admin global)
*/
export async function getAllUsuarios(): Promise<UserListItem[]> {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
tenantId: true,
tenant: {
select: {
nombre: true,
},
},
},
orderBy: [{ tenant: { nombre: 'asc' } }, { createdAt: 'desc' }],
});
return users.map(u => ({
id: u.id,
email: u.email,
nombre: u.nombre,
role: u.role,
active: u.active,
lastLogin: u.lastLogin?.toISOString() || null,
createdAt: u.createdAt.toISOString(),
tenantId: u.tenantId,
tenantName: u.tenant.nombre,
}));
}
/**
* Actualiza un usuario globalmente (puede cambiar de tenant)
*/
export async function updateUsuarioGlobal(
userId: string,
data: UserUpdate & { tenantId?: string }
): Promise<UserListItem> {
const user = await prisma.user.update({
where: { id: userId },
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.role && { role: data.role }),
...(data.active !== undefined && { active: data.active }),
...(data.tenantId && { tenantId: data.tenantId }),
},
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
tenantId: true,
tenant: {
select: {
nombre: true,
},
},
},
});
return {
id: user.id,
email: user.email,
nombre: user.nombre,
role: user.role,
active: user.active,
lastLogin: user.lastLogin?.toISOString() || null,
createdAt: user.createdAt.toISOString(),
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
};
}
/**
* Elimina un usuario globalmente
*/
export async function deleteUsuarioGlobal(userId: string): Promise<void> {
await prisma.user.delete({
where: { id: userId },
});
}

View File

@@ -0,0 +1,31 @@
import { prisma } from '../config/database.js';
const ADMIN_TENANT_RFC = 'CAS2408138W2';
// Cache: tenantId -> { rfc, expires }
const rfcCache = new Map<string, { rfc: string; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Checks if the given user belongs to the global admin tenant (CAS2408138W2).
* Uses an in-memory cache to avoid repeated DB lookups.
*/
export async function isGlobalAdmin(tenantId: string, role: string): Promise<boolean> {
if (role !== 'admin') return false;
const cached = rfcCache.get(tenantId);
if (cached && cached.expires > Date.now()) {
return cached.rfc === ADMIN_TENANT_RFC;
}
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
if (tenant) {
rfcCache.set(tenantId, { rfc: tenant.rfc, expires: Date.now() + CACHE_TTL });
}
return tenant?.rfc === ADMIN_TENANT_RFC;
}

View File

@@ -1,104 +0,0 @@
import { prisma } from '../config/database.js';
export async function createTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL,
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP NOT NULL,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) NOT NULL,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) NOT NULL,
moneda VARCHAR(3) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(4),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
iva_trasladado DECIMAL(18,2) NOT NULL,
iva_acreditable DECIMAL(18,2) NOT NULL,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) NOT NULL,
acumulado DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."isr_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
ingresos_acumulados DECIMAL(18,2) NOT NULL,
deducciones DECIMAL(18,2) NOT NULL,
base_gravable DECIMAL(18,2) NOT NULL,
isr_causado DECIMAL(18,2) NOT NULL,
isr_retenido DECIMAL(18,2) NOT NULL,
isr_a_pagar DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT NOT NULL,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(50) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'unica',
completado BOOLEAN DEFAULT FALSE,
notas TEXT
)
`);
}
export async function setTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`);
}
export async function deleteTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
}

View File

@@ -1,17 +1,19 @@
import jwt from 'jsonwebtoken';
import jwt, { type SignOptions } from 'jsonwebtoken';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN,
});
const options: SignOptions = {
expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'],
};
return jwt.sign(payload, env.JWT_SECRET, options);
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
});
const options: SignOptions = {
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
};
return jwt.sign(payload, env.JWT_SECRET, options);
}
export function verifyToken(token: string): JWTPayload {

View File

@@ -30,7 +30,11 @@ export default function LoginPage() {
const response = await login({ email, password });
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
router.push('/dashboard');
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
router.push(seen ? '/dashboard' : '/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesión');
} finally {

View File

@@ -0,0 +1,305 @@
'use client';
import { useState, useEffect } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const roleLabels = {
admin: { label: 'Administrador', icon: Shield, color: 'text-primary' },
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
};
interface EditingUser {
id: string;
nombre: string;
role: 'admin' | 'contador' | 'visor';
tenantId: string;
}
export default function AdminUsuariosPage() {
const { user: currentUser } = useAuthStore();
const { data: usuarios, isLoading, error } = useAllUsuarios();
const updateUsuario = useUpdateUsuarioGlobal();
const deleteUsuario = useDeleteUsuarioGlobal();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
getTenants().then(setTenants).catch(console.error);
}, []);
const handleEdit = (usuario: any) => {
setEditingUser({
id: usuario.id,
nombre: usuario.nombre,
role: usuario.role,
tenantId: usuario.tenantId,
});
};
const handleSave = async () => {
if (!editingUser) return;
try {
await updateUsuario.mutateAsync({
id: editingUser.id,
data: {
nombre: editingUser.nombre,
role: editingUser.role,
tenantId: editingUser.tenantId,
},
});
setEditingUser(null);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al actualizar usuario');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Estas seguro de eliminar este usuario?')) return;
try {
await deleteUsuario.mutateAsync(id);
} catch (err: any) {
alert(err.response?.data?.error || 'Error al eliminar usuario');
}
};
const filteredUsuarios = usuarios?.filter(u => {
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
const matchesSearch = !searchTerm ||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
return matchesTenant && matchesSearch;
});
// Agrupar por empresa
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
const key = u.tenantId || 'sin-empresa';
if (!acc[key]) {
acc[key] = {
tenantName: u.tenantName || 'Sin empresa',
users: [],
};
}
acc[key].users.push(u);
return acc;
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
if (error) {
return (
<DashboardShell title="Administracion de Usuarios">
<Card>
<CardContent className="py-8 text-center">
<p className="text-destructive">
No tienes permisos para ver esta pagina o ocurrio un error.
</p>
</CardContent>
</Card>
</DashboardShell>
);
}
return (
<DashboardShell title="Administracion de Usuarios">
<div className="space-y-4">
{/* Filtros */}
<Card>
<CardContent className="py-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="w-[250px]">
<Select value={filterTenant} onValueChange={setFilterTenant}>
<SelectTrigger>
<SelectValue placeholder="Filtrar por empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las empresas</SelectItem>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Stats */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" />
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
</div>
</div>
{/* Users by tenant */}
{isLoading ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Cargando usuarios...
</CardContent>
</Card>
) : (
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
<Card key={tenantId}>
<CardHeader className="py-3">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4" />
{tenantName}
<span className="text-muted-foreground font-normal text-sm">
({users?.length} usuarios)
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{users?.map(usuario => {
const roleInfo = roleLabels[usuario.role];
const RoleIcon = roleInfo.icon;
const isCurrentUser = usuario.id === currentUser?.id;
const isEditing = editingUser?.id === usuario.id;
return (
<div key={usuario.id} className="p-4 flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
'bg-primary/10 text-primary font-medium'
)}>
{usuario.nombre.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<Input
value={editingUser.nombre}
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
className="h-8"
/>
<div className="flex gap-2">
<Select
value={editingUser.role}
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="contador">Contador</SelectItem>
<SelectItem value="visor">Visor</SelectItem>
</SelectContent>
</Select>
<Select
value={editingUser.tenantId}
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
>
<SelectTrigger className="h-8 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tenants.map(t => (
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="font-medium">{usuario.nombre}</span>
{isCurrentUser && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
)}
{!usuario.active && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
)}
</div>
<div className="text-sm text-muted-foreground">{usuario.email}</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-4">
{!isEditing && (
<div className={cn('flex items-center gap-1', roleInfo.color)}>
<RoleIcon className="h-4 w-4" />
<span className="text-sm">{roleInfo.label}</span>
</div>
)}
{!isCurrentUser && (
<div className="flex gap-1">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={updateUsuario.isPending}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingUser(null)}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(usuario)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(usuario.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
))
)}
</div>
</DashboardShell>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useDebounce } from '@/lib/hooks/use-debounce';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -8,10 +9,15 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { createManyCfdis } from '@/lib/api/cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useQueryClient } from '@tanstack/react-query';
@@ -228,7 +234,49 @@ export default function CfdiPage() {
limit: 20,
});
const [searchTerm, setSearchTerm] = useState('');
const [columnFilters, setColumnFilters] = useState({
fechaInicio: '',
fechaFin: '',
emisor: '',
receptor: '',
});
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
const [receptorSuggestions, setReceptorSuggestions] = useState<EmisorReceptor[]>([]);
const [loadingEmisor, setLoadingEmisor] = useState(false);
const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false);
// Debounced values for autocomplete
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
// Fetch emisor suggestions when debounced value changes
useEffect(() => {
if (debouncedEmisor.length < 2) {
setEmisorSuggestions([]);
return;
}
setLoadingEmisor(true);
searchEmisores(debouncedEmisor)
.then(setEmisorSuggestions)
.catch(() => setEmisorSuggestions([]))
.finally(() => setLoadingEmisor(false));
}, [debouncedEmisor]);
// Fetch receptor suggestions when debounced value changes
useEffect(() => {
if (debouncedReceptor.length < 2) {
setReceptorSuggestions([]);
return;
}
setLoadingReceptor(true);
searchReceptores(debouncedReceptor)
.then(setReceptorSuggestions)
.catch(() => setReceptorSuggestions([]))
.finally(() => setLoadingReceptor(false));
}, [debouncedReceptor]);
const [showBulkForm, setShowBulkForm] = useState(false);
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
const [bulkData, setBulkData] = useState('');
@@ -255,12 +303,138 @@ export default function CfdiPage() {
const createCfdi = useCreateCfdi();
const deleteCfdi = useDeleteCfdi();
// CFDI Viewer state
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const handleViewCfdi = async (id: string) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
alert('Error al cargar el CFDI');
} finally {
setLoadingCfdi(null);
}
};
const canEdit = user?.role === 'admin' || user?.role === 'contador';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
};
// Export to Excel
const [exporting, setExporting] = useState(false);
const exportToExcel = async () => {
if (!data?.data.length) return;
setExporting(true);
try {
const exportData = data.data.map(cfdi => ({
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Tipo': cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso',
'Serie': cfdi.serie || '',
'Folio': cfdi.folio || '',
'RFC Emisor': cfdi.rfcEmisor,
'Nombre Emisor': cfdi.nombreEmisor,
'RFC Receptor': cfdi.rfcReceptor,
'Nombre Receptor': cfdi.nombreReceptor,
'Subtotal': cfdi.subtotal,
'IVA': cfdi.iva,
'Total': cfdi.total,
'Moneda': cfdi.moneda,
'Estado': cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado',
'UUID': cfdi.uuidFiscal,
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'CFDIs');
// Auto-size columns
const colWidths = Object.keys(exportData[0]).map(key => ({
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length))
}));
ws['!cols'] = colWidths;
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`;
saveAs(blob, fileName);
} catch (error) {
console.error('Error exporting:', error);
alert('Error al exportar');
} finally {
setExporting(false);
}
};
const selectEmisor = (emisor: EmisorReceptor) => {
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
setEmisorSuggestions([]);
};
const selectReceptor = (receptor: EmisorReceptor) => {
setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre }));
setReceptorSuggestions([]);
};
const applyDateFilter = () => {
setFilters({
...filters,
fechaInicio: columnFilters.fechaInicio || undefined,
fechaFin: columnFilters.fechaFin || undefined,
page: 1,
});
setOpenFilter(null);
};
const applyEmisorFilter = () => {
setFilters({
...filters,
emisor: columnFilters.emisor || undefined,
page: 1,
});
setOpenFilter(null);
};
const applyReceptorFilter = () => {
setFilters({
...filters,
receptor: columnFilters.receptor || undefined,
page: 1,
});
setOpenFilter(null);
};
const clearDateFilter = () => {
setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' });
setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 });
setOpenFilter(null);
};
const clearEmisorFilter = () => {
setColumnFilters({ ...columnFilters, emisor: '' });
setFilters({ ...filters, emisor: undefined, page: 1 });
setOpenFilter(null);
};
const clearReceptorFilter = () => {
setColumnFilters({ ...columnFilters, receptor: '' });
setFilters({ ...filters, receptor: undefined, page: 1 });
setOpenFilter(null);
};
const hasDateFilter = filters.fechaInicio || filters.fechaFin;
const hasEmisorFilter = filters.emisor;
const hasReceptorFilter = filters.receptor;
const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter;
const handleFilterType = (tipo?: TipoCfdi) => {
setFilters({ ...filters, tipo, page: 1 });
};
@@ -471,6 +645,32 @@ export default function CfdiPage() {
}
};
// Keyboard shortcuts - Esc to close popovers and forms
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Close open filter popovers
if (openFilter !== null) {
setOpenFilter(null);
return;
}
// Close forms
if (showForm) {
setShowForm(false);
return;
}
if (showBulkForm) {
setShowBulkForm(false);
clearXmlFiles();
return;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [openFilter, showForm, showBulkForm]);
const cancelUpload = () => {
uploadAbortRef.current = true;
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
@@ -558,18 +758,30 @@ export default function CfdiPage() {
Egresos
</Button>
</div>
{canEdit && (
<div className="flex gap-2">
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
<div className="flex gap-2">
{data && data.data.length > 0 && (
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
{exporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Exportar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</div>
)}
)}
{canEdit && (
<>
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
@@ -1041,15 +1253,58 @@ export default function CfdiPage() {
{/* Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</CardTitle>
{hasActiveColumnFilters && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Filtros activos:</span>
{hasDateFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Fecha
<button onClick={clearDateFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
{hasEmisorFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Emisor: {filters.emisor}
<button onClick={clearEmisorFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
{hasReceptorFilter && (
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
Receptor: {filters.receptor}
<button onClick={clearReceptorFilter} className="hover:text-destructive">
<X className="h-3 w-3" />
</button>
</span>
)}
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Cargando...
<div className="space-y-3">
{/* Skeleton loader */}
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="h-4 bg-muted rounded w-20"></div>
<div className="h-5 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-12"></div>
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
<div className="h-4 bg-muted rounded w-24 ml-auto"></div>
<div className="h-5 bg-muted rounded w-16"></div>
<div className="h-8 bg-muted rounded w-8"></div>
</div>
))}
</div>
) : data?.data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
@@ -1060,13 +1315,172 @@ export default function CfdiPage() {
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Fecha
<Popover open={openFilter === 'fecha'} onOpenChange={(open) => setOpenFilter(open ? 'fecha' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasDateFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por fecha</h4>
<div className="space-y-2">
<div>
<Label className="text-xs">Desde</Label>
<Input
type="date"
className="h-8 text-sm"
value={columnFilters.fechaInicio}
onChange={(e) => setColumnFilters({ ...columnFilters, fechaInicio: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Hasta</Label>
<Input
type="date"
className="h-8 text-sm"
value={columnFilters.fechaFin}
onChange={(e) => setColumnFilters({ ...columnFilters, fechaFin: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyDateFilter}>
Aplicar
</Button>
{hasDateFilter && (
<Button size="sm" variant="outline" onClick={clearDateFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">Tipo</th>
<th className="pb-3 font-medium">Folio</th>
<th className="pb-3 font-medium">Emisor</th>
<th className="pb-3 font-medium">Receptor</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Emisor
<Popover open={openFilter === 'emisor'} onOpenChange={(open) => setOpenFilter(open ? 'emisor' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasEmisorFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
<div className="relative">
<Label className="text-xs">RFC o Nombre</Label>
<Input
placeholder="Buscar emisor..."
className="h-8 text-sm"
value={columnFilters.emisor}
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
/>
{emisorSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{emisorSuggestions.map((emisor, idx) => (
<button
key={idx}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
onClick={() => selectEmisor(emisor)}
>
<p className="font-medium truncate">{emisor.nombre}</p>
<p className="text-xs text-muted-foreground">{emisor.rfc}</p>
</button>
))}
</div>
)}
{loadingEmisor && columnFilters.emisor.length >= 2 && emisorSuggestions.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
Buscando...
</div>
)}
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
Aplicar
</Button>
{hasEmisorFilter && (
<Button size="sm" variant="outline" onClick={clearEmisorFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Receptor
<Popover open={openFilter === 'receptor'} onOpenChange={(open) => setOpenFilter(open ? 'receptor' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasReceptorFilter ? 'text-primary' : ''}`}>
<Filter className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="start">
<div className="space-y-3">
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
<div className="relative">
<Label className="text-xs">RFC o Nombre</Label>
<Input
placeholder="Buscar receptor..."
className="h-8 text-sm"
value={columnFilters.receptor}
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
/>
{receptorSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
{receptorSuggestions.map((receptor, idx) => (
<button
key={idx}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
onClick={() => selectReceptor(receptor)}
>
<p className="font-medium truncate">{receptor.nombre}</p>
<p className="text-xs text-muted-foreground">{receptor.rfc}</p>
</button>
))}
</div>
)}
{loadingReceptor && columnFilters.receptor.length >= 2 && receptorSuggestions.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
Buscando...
</div>
)}
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
Aplicar
</Button>
{hasReceptorFilter && (
<Button size="sm" variant="outline" onClick={clearReceptorFilter}>
Limpiar
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 font-medium"></th>
{canEdit && <th className="pb-3 font-medium"></th>}
</tr>
</thead>
@@ -1122,6 +1536,21 @@ export default function CfdiPage() {
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
</span>
</td>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
{canEdit && (
<td className="py-3">
<Button
@@ -1174,6 +1603,12 @@ export default function CfdiPage() {
</CardContent>
</Card>
</main>
<CfdiViewerModal
cfdi={viewingCfdi}
open={viewingCfdi !== null}
onClose={() => setViewingCfdi(null)}
/>
</>
);
}

View File

@@ -5,7 +5,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw } from 'lucide-react';
import Link from 'next/link';
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
{
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
</CardContent>
</Card>
{/* SAT Configuration */}
<Link href="/configuracion/sat">
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RefreshCw className="h-4 w-4" />
Sincronizacion SAT
</CardTitle>
<CardDescription>
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
</p>
</CardContent>
</Card>
</Link>
{/* Theme Selection */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,156 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FielUploadModal } from '@/components/sat/FielUploadModal';
import { SyncStatus } from '@/components/sat/SyncStatus';
import { SyncHistory } from '@/components/sat/SyncHistory';
import { getFielStatus, deleteFiel } from '@/lib/api/fiel';
import type { FielStatus } from '@horux/shared';
export default function SatConfigPage() {
const [fielStatus, setFielStatus] = useState<FielStatus | null>(null);
const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchFielStatus = async () => {
try {
const status = await getFielStatus();
setFielStatus(status);
} catch (err) {
console.error('Error fetching FIEL status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFielStatus();
}, []);
const handleUploadSuccess = (status: FielStatus) => {
setFielStatus(status);
setShowUploadModal(false);
};
const handleDelete = async () => {
if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) {
return;
}
setDeleting(true);
try {
await deleteFiel();
setFielStatus({ configured: false });
} catch (err) {
console.error('Error deleting FIEL:', err);
} finally {
setDeleting(false);
}
};
if (loading) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Configuracion SAT</h1>
<p>Cargando...</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Configuracion SAT</h1>
<p className="text-muted-foreground">
Gestiona tu FIEL y la sincronizacion automatica de CFDIs
</p>
</div>
</div>
{/* Estado de la FIEL */}
<Card>
<CardHeader>
<CardTitle>FIEL (e.firma)</CardTitle>
<CardDescription>
Tu firma electronica para autenticarte con el SAT
</CardDescription>
</CardHeader>
<CardContent>
{fielStatus?.configured ? (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">RFC</p>
<p className="font-medium">{fielStatus.rfc}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">No. Serie</p>
<p className="font-medium text-xs">{fielStatus.serialNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Vigente hasta</p>
<p className="font-medium">
{fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<p className={`font-medium ${fielStatus.isExpired ? 'text-red-500' : 'text-green-500'}`}>
{fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`}
</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowUploadModal(true)}
>
Actualizar FIEL
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Eliminando...' : 'Eliminar FIEL'}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-muted-foreground">
No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar
la sincronizacion automatica de CFDIs con el SAT.
</p>
<Button onClick={() => setShowUploadModal(true)}>
Configurar FIEL
</Button>
</div>
)}
</CardContent>
</Card>
{/* Estado de Sincronizacion */}
<SyncStatus
fielConfigured={fielStatus?.configured || false}
onSyncStarted={fetchFielStatus}
/>
{/* Historial */}
<SyncHistory fielConfigured={fielStatus?.configured || false} />
{/* Modal de carga */}
{showUploadModal && (
<FielUploadModal
onSuccess={handleUploadSuccess}
onClose={() => setShowUploadModal(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import {
useSubscription,
usePaymentHistory,
useGeneratePaymentLink,
} from '@/lib/hooks/use-subscription';
import {
CreditCard,
Calendar,
CheckCircle,
AlertCircle,
Clock,
XCircle,
ExternalLink,
Loader2,
AlertTriangle,
CalendarClock,
} from 'lucide-react';
const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
};
function getDaysUntil(dateStr: string | null): number | null {
if (!dateStr) return null;
const diff = new Date(dateStr).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export default function SuscripcionPage() {
const { user } = useAuthStore();
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: payments } = usePaymentHistory(user?.tenantId);
const generateLink = useGeneratePaymentLink();
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const status = statusConfig[subscription?.status || ''] || statusConfig.pending;
const StatusIcon = status.icon;
const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd ?? null);
const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0;
const isExpiringSoon = daysUntilEnd !== null && daysUntilEnd > 0 && daysUntilEnd <= 5;
const needsPayment = subscription?.status === 'pending' || isExpired;
const handleGenerateLink = async () => {
if (!user?.tenantId) return;
try {
const result = await generateLink.mutateAsync(user.tenantId);
setPaymentUrl(result.paymentUrl);
window.open(result.paymentUrl, '_blank');
} catch {
// error handled by mutation state
}
};
return (
<>
<Header title="Suscripción" />
<main className="p-6 space-y-6">
{/* Warning banner: expired */}
{!isLoading && subscription && isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-red-800">Tu suscripción ha vencido</p>
<p className="text-sm text-red-700 mt-1">
Tu período de facturación terminó el {formatDate(subscription.currentPeriodEnd)}.
Realiza tu pago para continuar usando todas las funciones de Horux360.
</p>
</div>
</div>
)}
{/* Warning banner: expiring soon */}
{!isLoading && subscription && isExpiringSoon && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Tu suscripción vence pronto</p>
<p className="text-sm text-yellow-700 mt-1">
Tu período de facturación termina en {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''} ({formatDate(subscription.currentPeriodEnd)}).
Asegúrate de tener tu método de pago al día.
</p>
</div>
</div>
)}
{/* Warning banner: pending payment */}
{!isLoading && subscription && subscription.status === 'pending' && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<Clock className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">
Tu suscripción está pendiente de pago. Haz clic en el botón de abajo para completar tu pago.
</p>
</div>
</div>
)}
{/* Subscription Status + Pay button */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Estado de Suscripción
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/4" />
</div>
) : subscription ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${status.bgColor} ${status.color}`}>
<StatusIcon className="h-4 w-4" />
{status.label}
</span>
</div>
<div>
<p className="text-sm text-muted-foreground">Monto Mensual</p>
<p className="text-lg font-semibold">
${Number(subscription.amount).toLocaleString('es-MX')} MXN
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-lg font-semibold capitalize">{subscription.frequency === 'monthly' ? 'Mensual' : subscription.frequency}</p>
</div>
</div>
{/* Pay button */}
{needsPayment && Number(subscription.amount) > 0 && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 pt-4 border-t">
<button
onClick={handleGenerateLink}
disabled={generateLink.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{generateLink.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{generateLink.isPending ? 'Generando link...' : 'Pagar ahora'}
</button>
{paymentUrl && (
<a
href={paymentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Abrir link de pago nuevamente
</a>
)}
{generateLink.isError && (
<p className="text-sm text-red-600">
Error al generar el link. Intenta de nuevo o contacta soporte.
</p>
)}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">No se encontró información de suscripción. Contacta a soporte.</p>
)}
</CardContent>
</Card>
{/* Next payment / Billing period */}
{subscription && (subscription.currentPeriodStart || subscription.currentPeriodEnd) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="h-5 w-5" />
Período de Facturación
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Inicio del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Fin del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Próximo pago</p>
{daysUntilEnd !== null ? (
isExpired ? (
<p className="font-medium text-red-600">Vencido pago requerido</p>
) : (
<p className="font-medium">
En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}
<span className="text-muted-foreground"> ({formatDate(subscription.currentPeriodEnd)})</span>
</p>
)
) : (
<p className="font-medium text-muted-foreground">Sin fecha definida</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Payment History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Historial de Pagos
</CardTitle>
</CardHeader>
<CardContent>
{payments && payments.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
</tr>
</thead>
<tbody>
{payments.map((payment) => (
<tr key={payment.id} className="border-b last:border-0">
<td className="py-2.5 px-3">
{new Date(payment.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</td>
<td className="py-2.5 px-3 font-medium">
${Number(payment.amount).toLocaleString('es-MX')} MXN
</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
payment.status === 'approved'
? 'bg-green-50 text-green-700'
: payment.status === 'rejected'
? 'bg-red-50 text-red-700'
: 'bg-yellow-50 text-yellow-700'
}`}>
{payment.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{payment.status === 'rejected' && <XCircle className="h-3 w-3" />}
{payment.status !== 'approved' && payment.status !== 'rejected' && <Clock className="h-3 w-3" />}
{payment.status === 'approved' ? 'Aprobado' :
payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span>
</td>
<td className="py-2.5 px-3 text-muted-foreground capitalize">
{payment.paymentMethod === 'bank_transfer' ? 'Transferencia' :
payment.paymentMethod || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-muted-foreground">No hay pagos registrados aún.</p>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,5 @@
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
export default function Page() {
return <OnboardingScreen />;
}

View File

@@ -0,0 +1,317 @@
'use client';
import { forwardRef } from 'react';
import type { Cfdi } from '@horux/shared';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveUnidad?: string;
claveProdServ?: string;
}
interface CfdiInvoiceProps {
cfdi: Cfdi;
conceptos?: CfdiConcepto[];
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const formatDateTime = (dateString: string) =>
new Date(dateString).toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const tipoLabels: Record<string, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
pago: 'Pago',
nomina: 'Nómina',
};
const formaPagoLabels: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica',
'04': 'Tarjeta de crédito',
'28': 'Tarjeta de débito',
'99': 'Por definir',
};
const metodoPagoLabels: Record<string, string> = {
PUE: 'Pago en una sola exhibición',
PPD: 'Pago en parcialidades o diferido',
};
const usoCfdiLabels: Record<string, string> = {
G01: 'Adquisición de mercancías',
G02: 'Devoluciones, descuentos o bonificaciones',
G03: 'Gastos en general',
I01: 'Construcciones',
I02: 'Mobilario y equipo de oficina',
I03: 'Equipo de transporte',
I04: 'Equipo de cómputo',
I05: 'Dados, troqueles, moldes',
I06: 'Comunicaciones telefónicas',
I07: 'Comunicaciones satelitales',
I08: 'Otra maquinaria y equipo',
D01: 'Honorarios médicos',
D02: 'Gastos médicos por incapacidad',
D03: 'Gastos funerales',
D04: 'Donativos',
D05: 'Intereses por créditos hipotecarios',
D06: 'Aportaciones voluntarias SAR',
D07: 'Primas por seguros de gastos médicos',
D08: 'Gastos de transportación escolar',
D09: 'Depósitos en cuentas para el ahorro',
D10: 'Pagos por servicios educativos',
P01: 'Por definir',
S01: 'Sin efectos fiscales',
CP01: 'Pagos',
CN01: 'Nómina',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
>
{/* Header con gradiente */}
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
<div className="flex justify-between items-start">
<div>
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="text-right">
<div className="flex items-center justify-end gap-3 mb-2">
<span
className={`px-3 py-1 text-xs font-bold rounded-full ${
cfdi.estado === 'vigente'
? 'bg-green-400 text-green-900'
: 'bg-red-400 text-red-900'
}`}
>
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
</span>
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
{tipoLabels[cfdi.tipo] || cfdi.tipo}
</span>
</div>
<div className="text-3xl font-bold tracking-tight">
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
{cfdi.folio || 'S/N'}
</div>
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
</div>
</div>
</div>
<div className="p-6">
{/* Receptor */}
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
<div className="flex items-start justify-between">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
</div>
{cfdi.usoCfdi && (
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
<p className="text-sm font-medium text-gray-700 mt-1">
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
</p>
</div>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="grid grid-cols-4 gap-3 mb-5">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.metodoPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.formaPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
)}
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-5">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
Conceptos
</h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr
key={idx}
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
>
<td className="py-3 px-4">
<p className="text-gray-800">{concepto.descripcion}</p>
{concepto.claveProdServ && (
<p className="text-xs text-gray-400 mt-0.5">
Clave: {concepto.claveProdServ}
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
</p>
)}
</td>
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
<td className="text-right py-3 px-4 text-gray-700">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right py-3 px-4 font-medium text-gray-800">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-5">
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
<div className="divide-y divide-gray-200">
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Descuento</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.iva > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA (16%)</span>
<span className="font-medium">{formatCurrency(cfdi.iva)}</span>
</div>
)}
{cfdi.ivaRetenido > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
</div>
)}
{cfdi.isrRetenido > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">ISR Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
</div>
)}
</div>
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
<span className="font-semibold">TOTAL</span>
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal Digital */}
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex gap-4">
{/* QR Placeholder */}
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
<div className="text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
</div>
</div>
{/* Info del Timbre */}
<div className="flex-1 min-w-0">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Timbre Fiscal Digital
</h3>
<div className="space-y-1.5">
<div>
<span className="text-xs text-gray-500">UUID: </span>
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuidFiscal}</span>
</div>
<div>
<span className="text-xs text-gray-500">Fecha de Timbrado: </span>
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaTimbrado)}</span>
</div>
</div>
</div>
</div>
{/* Leyenda */}
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
Este documento es una representación impresa de un CFDI Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
</p>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';

View File

@@ -0,0 +1,218 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import type { Cfdi } from '@horux/shared';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CfdiInvoice } from './cfdi-invoice';
import { getCfdiXml } from '@/lib/api/cfdi';
import { Download, FileText, Loader2, Printer } from 'lucide-react';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
}
interface CfdiViewerModalProps {
cfdi: Cfdi | null;
open: boolean;
onClose: () => void;
}
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const conceptos: CfdiConcepto[] = [];
// Find all Concepto elements
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === 'Concepto') {
const el = elements[i];
conceptos.push({
descripcion: el.getAttribute('Descripcion') || '',
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
importe: parseFloat(el.getAttribute('Importe') || '0'),
});
}
}
return conceptos;
} catch {
return [];
}
}
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
const invoiceRef = useRef<HTMLDivElement>(null);
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
const [xmlContent, setXmlContent] = useState<string | null>(null);
useEffect(() => {
if (cfdi?.xmlOriginal) {
setXmlContent(cfdi.xmlOriginal);
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
} else {
setXmlContent(null);
setConceptos([]);
}
}, [cfdi]);
const handleDownloadPdf = async () => {
if (!invoiceRef.current || !cfdi) return;
setDownloading('pdf');
try {
const html2pdf = (await import('html2pdf.js')).default;
const opt = {
margin: 10,
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
image: { type: 'jpeg' as const, quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
};
await html2pdf().set(opt).from(invoiceRef.current).save();
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error al generar el PDF');
} finally {
setDownloading(null);
}
};
const handleDownloadXml = async () => {
if (!cfdi) return;
setDownloading('xml');
try {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
}
if (!xml) {
alert('No hay XML disponible para este CFDI');
return;
}
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading XML:', error);
alert('Error al descargar el XML');
} finally {
setDownloading(null);
}
};
const handlePrint = () => {
if (!invoiceRef.current) return;
// Create a print-specific stylesheet
const printStyles = document.createElement('style');
printStyles.innerHTML = `
@media print {
body * {
visibility: hidden;
}
#cfdi-print-area, #cfdi-print-area * {
visibility: visible;
}
#cfdi-print-area {
position: absolute;
left: 0;
top: 0;
width: 100%;
padding: 20px;
}
@page {
size: A4;
margin: 15mm;
}
}
`;
document.head.appendChild(printStyles);
// Add ID to the invoice container for print targeting
invoiceRef.current.id = 'cfdi-print-area';
// Trigger print
window.print();
// Clean up
document.head.removeChild(printStyles);
invoiceRef.current.removeAttribute('id');
};
if (!cfdi) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Vista de Factura</DialogTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={downloading !== null}
>
{downloading === 'pdf' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadXml}
disabled={downloading !== null || !xmlContent}
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
>
{downloading === 'xml' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
XML
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePrint}
disabled={downloading !== null}
title="Imprimir factura"
>
<Printer className="h-4 w-4 mr-1" />
Imprimir
</Button>
</div>
</div>
</DialogHeader>
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,3 @@
import { Sidebar } from './sidebar';
import { Header } from './header';
interface DashboardShellProps {
@@ -8,13 +7,12 @@ interface DashboardShellProps {
}
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
// Navigation is handled by the parent layout.tsx which respects theme settings
// DashboardShell only provides Header and content wrapper
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-64">
<Header title={title}>{headerContent}</Header>
<main className="p-6">{children}</main>
</div>
</div>
<>
<Header title={title}>{headerContent}</Header>
<main className="p-6">{children}</main>
</>
);
}

View File

@@ -15,24 +15,36 @@ import {
Bell,
Users,
Building2,
UserCog,
CreditCard,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { hasFeature, type Plan } from '@horux/shared';
const navigation = [
interface NavItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
}
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
{ name: 'Calendario', href: '/calendario', icon: Calendar },
{ name: 'Alertas', href: '/alertas', icon: Bell },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Usuarios', href: '/usuarios', icon: Users },
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
];
const adminNavigation = [
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
];
export function Sidebar() {
@@ -51,9 +63,15 @@ export function Sidebar() {
}
};
// Filter navigation based on tenant plan features
const plan = (user?.plan || 'starter') as Plan;
const filteredNav = navigation.filter(
(item) => !item.feature || hasFeature(plan, item.feature)
);
const allNavigation = user?.role === 'admin'
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
: navigation;
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">

View File

@@ -0,0 +1,190 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
/**
* Onboarding persistence key.
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
*/
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
export default function OnboardingScreen() {
const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const [isNewUser, setIsNewUser] = useState(true);
const [loading, setLoading] = useState(false);
const safePush = (path: string) => {
// Avoid multiple navigations if user clicks quickly.
if (loading) return;
setLoading(true);
router.push(path);
};
// Redirect to login if not authenticated
useEffect(() => {
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
useEffect(() => {
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
// If the user has already seen onboarding, go to dashboard automatically.
if (seen) {
setIsNewUser(false);
setLoading(true);
const t = setTimeout(() => router.push('/dashboard'), 900);
return () => clearTimeout(t);
}
}, [router]);
const handleContinue = () => {
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
setLoading(true);
setTimeout(() => router.push('/dashboard'), 700);
};
const handleReset = () => {
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
location.reload();
};
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
// Show loading while store hydrates
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-pulse text-slate-500">Cargando...</div>
</div>
);
}
// Don't render if not authenticated
if (!isAuthenticated) {
return null;
}
return (
<main className="min-h-screen relative overflow-hidden bg-white">
{/* Grid tech claro */}
<div
className="absolute inset-0 opacity-[0.05]"
style={{
backgroundImage:
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Glow global azul (sutil) */}
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-4xl">
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
</div>
<div className="leading-tight">
<p className="text-sm font-semibold text-slate-800">Horux360</p>
<p className="text-xs text-slate-500">Pantalla de inicio</p>
</div>
</div>
<span className="text-xs text-slate-500">{headerStatus}</span>
</div>
{/* Body */}
<div className="p-6 md:p-8">
{isNewUser ? (
<div className="grid gap-8 md:grid-cols-2 md:items-center">
{/* Left */}
<div>
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
Bienvenido a Horux360
</h1>
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
Revisa este breve video para conocer el flujo. Después podrás continuar.
</p>
<div className="mt-6 flex items-center gap-3">
<button
onClick={handleContinue}
disabled={loading}
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
>
{loading ? 'Cargando…' : 'Continuar'}
</button>
<button
onClick={() => safePush('/login')}
disabled={loading}
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
Ver más
</button>
</div>
<div className="mt-6 text-xs text-slate-500">
Usuario nuevo: muestra video Usuario recurrente: redirección automática
</div>
</div>
{/* Right (video) - elegante sin glow */}
<div className="relative">
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
<div className="p-3">
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
</div>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
Video introductorio
</span>
<span>v1</span>
</div>
</div>
</div>
</div>
</div>
) : (
<div className="py-12 flex flex-col items-center justify-center text-center">
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
</div>
<h2 className="mt-5 text-lg font-semibold text-slate-800">
Redirigiendo al dashboard
</h2>
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
</div>
<button
onClick={handleReset}
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
>
Ver video otra vez (reset demo)
</button>
</div>
)}
</div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { uploadFiel } from '@/lib/api/fiel';
import type { FielStatus } from '@horux/shared';
interface FielUploadModalProps {
onSuccess: (status: FielStatus) => void;
onClose: () => void;
}
export function FielUploadModal({ onSuccess, onClose }: FielUploadModalProps) {
const [cerFile, setCerFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const result = reader.result as string;
// Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,")
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
});
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!cerFile || !keyFile || !password) {
setError('Todos los campos son requeridos');
return;
}
setLoading(true);
try {
const cerBase64 = await fileToBase64(cerFile);
const keyBase64 = await fileToBase64(keyFile);
const result = await uploadFiel({
cerFile: cerBase64,
keyFile: keyBase64,
password,
});
if (result.status) {
onSuccess(result.status);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Error al subir la FIEL');
} finally {
setLoading(false);
}
}, [cerFile, keyFile, password, onSuccess]);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md mx-4">
<CardHeader>
<CardTitle>Configurar FIEL (e.firma)</CardTitle>
<CardDescription>
Sube tu certificado y llave privada para sincronizar CFDIs con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cer">Certificado (.cer)</Label>
<Input
id="cer"
type="file"
accept=".cer"
onChange={(e) => setCerFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="key">Llave Privada (.key)</Label>
<Input
id="key"
type="file"
accept=".key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contrasena de la llave</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Ingresa la contrasena de tu FIEL"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
className="flex-1"
>
Cancelar
</Button>
<Button
type="submit"
disabled={loading}
className="flex-1"
>
{loading ? 'Subiendo...' : 'Configurar FIEL'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getSyncHistory, retrySync } from '@/lib/api/sat';
import type { SatSyncJob } from '@horux/shared';
interface SyncHistoryProps {
fielConfigured: boolean;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
const typeLabels: Record<string, string> = {
initial: 'Inicial',
daily: 'Diaria',
};
export function SyncHistory({ fielConfigured }: SyncHistoryProps) {
const [jobs, setJobs] = useState<SatSyncJob[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const limit = 10;
const fetchHistory = async () => {
try {
const data = await getSyncHistory(page, limit);
setJobs(data.jobs);
setTotal(data.total);
} catch (err) {
console.error('Error fetching sync history:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchHistory();
} else {
setLoading(false);
}
}, [fielConfigured, page]);
const handleRetry = async (jobId: string) => {
try {
await retrySync(jobId);
fetchHistory();
} catch (err) {
console.error('Error retrying job:', err);
}
};
if (!fielConfigured) {
return null;
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando historial...</p>
</CardContent>
</Card>
);
}
if (jobs.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">No hay sincronizaciones registradas.</p>
</CardContent>
</Card>
);
}
const totalPages = Math.ceil(total / limit);
return (
<Card>
<CardHeader>
<CardTitle>Historial de Sincronizaciones</CardTitle>
<CardDescription>
Registro de todas las sincronizaciones con el SAT
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[job.status]}`}>
{statusLabels[job.status]}
</span>
<span className="text-xs text-muted-foreground">
{typeLabels[job.type]}
</span>
</div>
<p className="text-sm">
{job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'}
</p>
<p className="text-xs text-muted-foreground">
{job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados
</p>
{job.errorMessage && (
<p className="text-xs text-red-500 mt-1">{job.errorMessage}</p>
)}
</div>
{job.status === 'failed' && (
<Button
size="sm"
variant="outline"
onClick={() => handleRetry(job.id)}
>
Reintentar
</Button>
)}
{job.status === 'running' && (
<div className="text-right">
<p className="text-sm font-medium">{job.progressPercent}%</p>
<p className="text-xs text-muted-foreground">{job.cfdisDownloaded} descargados</p>
</div>
)}
</div>
))}
</div>
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<Button
size="sm"
variant="outline"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
>
Anterior
</Button>
<span className="py-2 px-3 text-sm">
Pagina {page} de {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
>
Siguiente
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared';
interface SyncStatusProps {
fielConfigured: boolean;
onSyncStarted?: () => void;
}
const statusLabels: Record<string, string> = {
pending: 'Pendiente',
running: 'En progreso',
completed: 'Completado',
failed: 'Fallido',
};
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
const [status, setStatus] = useState<SatSyncStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState('');
const [showCustomDate, setShowCustomDate] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const fetchStatus = async () => {
try {
const data = await getSyncStatus();
setStatus(data);
} catch (err) {
console.error('Error fetching sync status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (fielConfigured) {
fetchStatus();
// Actualizar cada 30 segundos si hay sync activo
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
} else {
setLoading(false);
}
}, [fielConfigured]);
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
setStartingSync(true);
setError('');
try {
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
if (customDates && dateFrom && dateTo) {
// Convertir a formato completo con hora
params.dateFrom = `${dateFrom}T00:00:00`;
params.dateTo = `${dateTo}T23:59:59`;
}
await startSync(params);
await fetchStatus();
setShowCustomDate(false);
onSyncStarted?.();
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
} finally {
setStartingSync(false);
}
};
if (!fielConfigured) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Configura tu FIEL para habilitar la sincronizacion automatica
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
La sincronizacion con el SAT requiere una FIEL valida configurada.
</p>
</CardContent>
</Card>
);
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Cargando estado...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Sincronizacion SAT</CardTitle>
<CardDescription>
Estado de la sincronizacion automatica de CFDIs
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{status?.hasActiveSync && status.currentJob && (
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors[status.currentJob.status]}`}>
{statusLabels[status.currentJob.status]}
</span>
<span className="text-sm text-muted-foreground">
{status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'}
</span>
</div>
{status.currentJob.status === 'running' && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${status.currentJob.progressPercent}%` }}
/>
</div>
)}
<p className="text-sm mt-2">
{status.currentJob.cfdisDownloaded} CFDIs descargados
</p>
</div>
)}
{status?.lastCompletedJob && !status.hasActiveSync && (
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-sm ${statusColors.completed}`}>
Ultima sincronizacion exitosa
</span>
</div>
<p className="text-sm">
{new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')}
</p>
<p className="text-sm text-muted-foreground">
{status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-center">
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">{status?.totalCfdisSynced || 0}</p>
<p className="text-sm text-muted-foreground">CFDIs sincronizados</p>
</div>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold">3:00 AM</p>
<p className="text-sm text-muted-foreground">Sincronizacion diaria</p>
</div>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{/* Formulario de fechas personalizadas */}
{showCustomDate && (
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">Fecha inicio</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={dateTo || undefined}
/>
</div>
<div>
<Label htmlFor="dateTo">Fecha fin</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
min={dateFrom || undefined}
/>
</div>
</div>
<div className="flex gap-2">
<Button
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
onClick={() => handleStartSync('initial', true)}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
</Button>
<Button
variant="outline"
onClick={() => setShowCustomDate(false)}
>
Cancelar
</Button>
</div>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('daily')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
</Button>
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => setShowCustomDate(!showCustomDate)}
className="flex-1"
>
Periodo personalizado
</Button>
</div>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="w-full"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-[9999] w-72 rounded-md border bg-white dark:bg-gray-900 p-4 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -9,6 +9,8 @@ export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse>
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
if (filters.rfc) params.set('rfc', filters.rfc);
if (filters.emisor) params.set('emisor', filters.emisor);
if (filters.receptor) params.set('receptor', filters.receptor);
if (filters.search) params.set('search', filters.search);
if (filters.page) params.set('page', filters.page.toString());
if (filters.limit) params.set('limit', filters.limit.toString());
@@ -89,3 +91,27 @@ export async function createManyCfdis(
export async function deleteCfdi(id: string): Promise<void> {
await apiClient.delete(`/cfdi/${id}`);
}
export async function getCfdiXml(id: string): Promise<string> {
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
responseType: 'text'
});
return response.data;
}
export interface EmisorReceptor {
rfc: string;
nombre: string;
}
export async function searchEmisores(search: string): Promise<EmisorReceptor[]> {
if (search.length < 2) return [];
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/emisores?search=${encodeURIComponent(search)}`);
return response.data;
}
export async function searchReceptores(search: string): Promise<EmisorReceptor[]> {
if (search.length < 2) return [];
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/receptores?search=${encodeURIComponent(search)}`);
return response.data;
}

16
apps/web/lib/api/fiel.ts Normal file
View File

@@ -0,0 +1,16 @@
import { apiClient } from './client';
import type { FielStatus, FielUploadRequest } from '@horux/shared';
export async function uploadFiel(data: FielUploadRequest): Promise<{ message: string; status: FielStatus }> {
const response = await apiClient.post('/fiel/upload', data);
return response.data;
}
export async function getFielStatus(): Promise<FielStatus> {
const response = await apiClient.get<FielStatus>('/fiel/status');
return response.data;
}
export async function deleteFiel(): Promise<void> {
await apiClient.delete('/fiel');
}

45
apps/web/lib/api/sat.ts Normal file
View File

@@ -0,0 +1,45 @@
import { apiClient } from './client';
import type {
SatSyncJob,
SatSyncStatusResponse,
SatSyncHistoryResponse,
StartSyncRequest,
StartSyncResponse,
} from '@horux/shared';
export async function startSync(data?: StartSyncRequest): Promise<StartSyncResponse> {
const response = await apiClient.post<StartSyncResponse>('/sat/sync', data || {});
return response.data;
}
export async function getSyncStatus(): Promise<SatSyncStatusResponse> {
const response = await apiClient.get<SatSyncStatusResponse>('/sat/sync/status');
return response.data;
}
export async function getSyncHistory(page: number = 1, limit: number = 10): Promise<SatSyncHistoryResponse> {
const response = await apiClient.get<SatSyncHistoryResponse>('/sat/sync/history', {
params: { page, limit },
});
return response.data;
}
export async function getSyncJob(id: string): Promise<SatSyncJob> {
const response = await apiClient.get<SatSyncJob>(`/sat/sync/${id}`);
return response.data;
}
export async function retrySync(id: string): Promise<StartSyncResponse> {
const response = await apiClient.post<StartSyncResponse>(`/sat/sync/${id}/retry`);
return response.data;
}
export async function getCronInfo(): Promise<{ scheduled: boolean; expression: string; timezone: string }> {
const response = await apiClient.get('/sat/cron');
return response.data;
}
export async function runCron(): Promise<{ message: string }> {
const response = await apiClient.post('/sat/cron/run');
return response.data;
}

View File

@@ -0,0 +1,47 @@
import { apiClient } from './client';
export interface Subscription {
id: string;
tenantId: string;
plan: string;
status: string;
amount: string;
frequency: string;
mpPreapprovalId: string | null;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
createdAt: string;
updatedAt: string;
}
export interface Payment {
id: string;
tenantId: string;
subscriptionId: string | null;
mpPaymentId: string | null;
amount: string;
status: string;
paymentMethod: string | null;
paidAt: string | null;
createdAt: string;
}
export async function getSubscription(tenantId: string): Promise<Subscription> {
const response = await apiClient.get<Subscription>(`/subscriptions/${tenantId}`);
return response.data;
}
export async function generatePaymentLink(tenantId: string): Promise<{ paymentUrl: string }> {
const response = await apiClient.post<{ paymentUrl: string }>(`/subscriptions/${tenantId}/generate-link`);
return response.data;
}
export async function markAsPaid(tenantId: string, amount: number): Promise<Payment> {
const response = await apiClient.post<Payment>(`/subscriptions/${tenantId}/mark-paid`, { amount });
return response.data;
}
export async function getPaymentHistory(tenantId: string): Promise<Payment[]> {
const response = await apiClient.get<Payment[]>(`/subscriptions/${tenantId}/payments`);
return response.data;
}

View File

@@ -5,7 +5,7 @@ export interface Tenant {
nombre: string;
rfc: string;
plan: string;
schemaName: string;
databaseName: string;
createdAt: string;
_count?: {
users: number;

View File

@@ -19,3 +19,18 @@ export async function updateUsuario(id: string, data: UserUpdate): Promise<UserL
export async function deleteUsuario(id: string): Promise<void> {
await apiClient.delete(`/usuarios/${id}`);
}
// Funciones globales (admin global)
export async function getAllUsuarios(): Promise<UserListItem[]> {
const response = await apiClient.get<UserListItem[]>('/usuarios/global/all');
return response.data;
}
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
return response.data;
}
export async function deleteUsuarioGlobal(id: string): Promise<void> {
await apiClient.delete(`/usuarios/global/${id}`);
}

View File

@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
return useQuery({
queryKey: ['cfdis', filters],
queryFn: () => cfdiApi.getCfdis(filters),
staleTime: 30 * 1000, // 30 segundos
gcTime: 5 * 60 * 1000, // 5 minutos en cache
});
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as subscriptionApi from '../api/subscription';
export function useSubscription(tenantId: string | undefined) {
return useQuery({
queryKey: ['subscription', tenantId],
queryFn: () => subscriptionApi.getSubscription(tenantId!),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000,
});
}
export function usePaymentHistory(tenantId: string | undefined) {
return useQuery({
queryKey: ['payments', tenantId],
queryFn: () => subscriptionApi.getPaymentHistory(tenantId!),
enabled: !!tenantId,
staleTime: 60 * 1000,
});
}
export function useGeneratePaymentLink() {
return useMutation({
mutationFn: (tenantId: string) => subscriptionApi.generatePaymentLink(tenantId),
});
}
export function useMarkAsPaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, amount }: { tenantId: string; amount: number }) =>
subscriptionApi.markAsPaid(tenantId, amount),
onSuccess: (_, { tenantId }) => {
queryClient.invalidateQueries({ queryKey: ['subscription', tenantId] });
queryClient.invalidateQueries({ queryKey: ['payments', tenantId] });
},
});
}

View File

@@ -38,3 +38,31 @@ export function useDeleteUsuario() {
},
});
}
// Hooks globales (admin global)
export function useAllUsuarios() {
return useQuery({
queryKey: ['usuarios', 'global'],
queryFn: usuariosApi.getAllUsuarios,
});
}
export function useUpdateUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuarioGlobal(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}
export function useDeleteUsuarioGlobal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => usuariosApi.deleteUsuarioGlobal(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
},
});
}

View File

@@ -11,9 +11,10 @@
"dependencies": {
"@horux/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -26,17 +27,21 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"file-saver": "^2.0.5",
"html2pdf.js": "^0.14.0",
"lucide-react": "^0.460.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"recharts": "^2.12.0",
"react-hook-form": "^7.53.0",
"recharts": "^2.12.0",
"tailwind-merge": "^2.5.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",

Some files were not shown because too many files have changed in this diff Show More