diff --git a/BOLD_INTEGRACION.md b/BOLD_INTEGRACION.md new file mode 100644 index 0000000..34adf53 --- /dev/null +++ b/BOLD_INTEGRACION.md @@ -0,0 +1,637 @@ +# Integración Bold — Guía completa + +Guía de referencia para integrar Bold (pasarela de pagos colombiana) en cualquier sistema backend. +Basada en experiencia real de producción con PHP; los conceptos aplican a cualquier lenguaje. + +--- + +## Índice + +1. [Credenciales y modos](#1-credenciales-y-modos) +2. [Crear un Payment Link (API)](#2-crear-un-payment-link-api) +3. [Consultar estado de un link](#3-consultar-estado-de-un-link) +4. [Callback URL (retorno del usuario)](#4-callback-url-retorno-del-usuario) +5. [Webhook — configuración](#5-webhook--configuración) +6. [Webhook — verificación de firma](#6-webhook--verificación-de-firma) +7. [Webhook — procesamiento del evento](#7-webhook--procesamiento-del-evento) +8. [Estrategia de referencias múltiples](#8-estrategia-de-referencias-múltiples) +9. [Idempotencia](#9-idempotencia) +10. [Esquema de base de datos](#10-esquema-de-base-de-datos) +11. [Flujo completo de extremo a extremo](#11-flujo-completo-de-extremo-a-extremo) +12. [Errores frecuentes y soluciones](#12-errores-frecuentes-y-soluciones) + +--- + +## 1. Credenciales y modos + +Bold maneja dos entornos que se diferencian **únicamente por la API key**; el endpoint es el mismo. + +| Parámetro | Descripción | +|--------------------|-----------------------------------------------------------| +| `bold_api_key` | Clave de la API (diferente para test y producción) | +| `bold_secret_key` | Clave secreta para verificar la firma del webhook | +| `bold_mode` | `"test"` / `"production"` (para lógica propia del sistema)| + +> **Importante:** En modo **test** la clave secreta del webhook es una cadena vacía `""`. +> En producción se usa la clave real del panel de Bold. + +### Obtener las credenciales + +1. Ingresa al panel de Bold → **Configuración → Integraciones → API**. +2. Copia la **API Key** y la **Secret Key**. +3. Para el webhook, registra tu URL en **Configuración → Webhooks**. + +--- + +## 2. Crear un Payment Link (API) + +### Endpoint + +``` +POST https://integrations.api.bold.co/online/link/v1 +``` + +### Headers + +``` +Authorization: x-api-key {bold_api_key} +Content-Type: application/json +Accept: application/json +``` + +### Payload JSON + +```json +{ + "amount_type": "CLOSE", + "amount": { + "currency": "COP", + "total_amount": 25000 + }, + "description": "Descripción del producto o servicio", + "callback_url": "https://tudominio.com/pago_exitoso.php", + "payer_email": "cliente@ejemplo.com", + "reference": "TU-REF-12345-1746123456" +} +``` + +> **Moneda COP:** Bold no usa centavos. `total_amount: 25000` equivale a $25.000 pesos. + +#### Campos clave + +| Campo | Tipo | Descripción | +|----------------|---------|-----------------------------------------------------------------------------| +| `amount_type` | string | `"CLOSE"` = monto fijo. `"OPEN"` = el usuario puede ingresar el monto. | +| `total_amount` | int | Valor en pesos COP (sin centavos). | +| `callback_url` | string | URL a la que Bold redirige al usuario tras el pago. | +| `payer_email` | string | Pre-rellena el email en la pasarela. | +| `reference` | string | **Tu referencia personalizada.** Llega al webhook. Max 60 chars, alfanumérico + guiones. | + +### Respuesta exitosa (`HTTP 200`) + +```json +{ + "payload": { + "payment_link": "LNK_abc123xyz", + "url": "https://checkout.bold.co/payment/LNK_abc123xyz" + } +} +``` + +- `payment_link` → ID interno de Bold (formato `LNK_xxx`). Guardarlo en tu base de datos. +- `url` → URL a la que debes redirigir al usuario. + +### Ejemplo en PHP + +```php +$payload = [ + 'amount_type' => 'CLOSE', + 'amount' => ['currency' => 'COP', 'total_amount' => 25000], + 'description' => 'Inscripción al evento', + 'callback_url' => 'https://tudominio.com/pago_exitoso.php', + 'payer_email' => $email, + 'reference' => 'ERA-' . $usuarioId . '-' . time(), +]; + +$ch = curl_init('https://integrations.api.bold.co/online/link/v1'); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => [ + 'Authorization: x-api-key ' . $apiKey, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, +]); + +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($httpCode !== 200) { + // Manejar error +} + +$data = json_decode($response, true); +$linkId = $data['payload']['payment_link']; +$paymentUrl = $data['payload']['url']; + +header('Location: ' . $paymentUrl); +exit; +``` + +--- + +## 3. Consultar estado de un link + +Útil si el webhook no llegó o como verificación extra en el callback. + +### Endpoint + +``` +GET https://integrations.api.bold.co/online/link/v1/{payment_link} +``` + +### Headers + +``` +Authorization: x-api-key {bold_api_key} +Accept: application/json +``` + +### Respuesta relevante + +```json +{ + "payload": { + "payment_link": "LNK_abc123xyz", + "status": "ACTIVE", + "transactions": [ + { + "status": "APPROVED", + "payment_method": "CARD", + "amount": 25000 + } + ] + } +} +``` + +--- + +## 4. Callback URL (retorno del usuario) + +Cuando el usuario termina (o abandona) el pago, Bold lo redirige a tu `callback_url` con parámetros GET. + +> **Advertencia:** Bold puede usar distintos nombres de parámetro según la versión del checkout. +> **Nunca confíes en un único parámetro.** Revisa todos los posibles: + +``` +bold-order-id, order_id, payment_link, payment_link_id, reference, id +``` + +### Estrategia robusta en PHP + +```php +// Recolectar todos los candidatos posibles +$candidateRefs = []; +foreach (['bold-order-id', 'order_id', 'payment_link', 'reference', 'id'] as $key) { + $val = trim((string)($_GET[$key] ?? '')); + if ($val !== '') { + $candidateRefs[] = $val; + } +} +// Agregar lo guardado en sesión antes de la redirección +if (!empty($_SESSION['bold_order_id'])) { + $candidateRefs[] = $_SESSION['bold_order_id']; +} +$candidateRefs = array_values(array_unique(array_filter($candidateRefs))); + +// Buscar usuario por cualquiera de las referencias +$usuario = null; +foreach ($candidateRefs as $ref) { + $usuarioId = buscarUsuarioIdPorReferencia($pdo, $ref); + if ($usuarioId) { + // Cargar el usuario y salir del loop + break; + } +} + +// Respaldo: usar ID de sesión si no se encontró por referencia +if (!$usuario && !empty($_SESSION['usuario_id'])) { + // Cargar por $_SESSION['usuario_id'] +} +``` + +> **Nota crítica:** La callback se dispara **antes** del webhook. No es seguro marcar un pago +> como aprobado solo por el callback; el origen de verdad es el **webhook**. +> En el callback solo confirma que el usuario regresó y muestra una pantalla de "procesando". + +--- + +## 5. Webhook — configuración + +### Registrar la URL en Bold + +Panel de Bold → **Configuración → Webhooks** → agregar URL: + +``` +https://tudominio.com/webhook.php +``` + +### Eventos disponibles + +| Evento | Descripción | +|------------------|------------------------------------| +| `SALE_APPROVED` | Pago aprobado exitosamente | +| `SALE_REJECTED` | Pago rechazado por la entidad | +| `SALE_REVERSED` | Reverso / devolución | +| `CHARGEBACK` | Contracargo iniciado | + +Para la mayoría de casos solo necesitas `SALE_APPROVED`. + +### Requisitos del receptor + +- Debe responder **HTTP 200** en **menos de 2 segundos**. +- Si la respuesta demora más, Bold reintentará el envío. +- La URL debe ser HTTPS en producción. + +--- + +## 6. Webhook — verificación de firma + +Bold firma cada notificación con HMAC-SHA256. El header es `x-bold-signature`. + +### Algoritmo de verificación + +``` +1. Leer el body RAW (antes de hacer cualquier json_decode) +2. Codificar en Base64: encoded = base64(rawBody) +3. Calcular HMAC: computed = HMAC-SHA256(encoded, secretKey) → hexadecimal +4. Comparar con el header x-bold-signature (timing-safe) +``` + +> **Modo test:** la `secretKey` es `""` (cadena vacía). +> **Modo producción:** usa la clave del panel de Bold. + +### Implementación PHP + +```php +$rawBody = (string) file_get_contents('php://input'); +$signatureHeader = $_SERVER['HTTP_X_BOLD_SIGNATURE'] ?? ''; + +// Responder 200 ANTES de todo procesamiento +http_response_code(200); +header('Content-Type: application/json'); +header('Connection: close'); +$body = '{"ok":true}'; +header('Content-Length: ' . strlen($body)); +echo $body; +if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); +} else { + flush(); +} + +// --- A partir de aquí el cliente ya recibió 200 --- + +if ($signatureHeader !== '') { + $keyForHmac = ($boldMode === 'production') ? $secretKey : ''; + $encoded = base64_encode($rawBody); + $computed = hash_hmac('sha256', $encoded, $keyForHmac); + + if (!hash_equals($computed, $signatureHeader)) { + // Firma inválida: loguear y salir + exit; + } +} +``` + +--- + +## 7. Webhook — procesamiento del evento + +### Estructura del payload JSON + +```json +{ + "id": "notif_uuid_unico", + "type": "SALE_APPROVED", + "subject": "TXN_xyz789", + "data": { + "payment_id": "TXN_xyz789", + "amount": { + "total": 25000, + "currency": "COP" + }, + "payer_email": "cliente@ejemplo.com", + "metadata": { + "reference": "ERA-42-1746123456" + } + } +} +``` + +### Campos a extraer + +| Campo | Ruta en JSON | Descripción | +|----------------------------|---------------------------------------------|------------------------------------------| +| `notification_id` | `event.id` | ID único de la notificación (idempotencia) | +| `tipo` | `event.type` | Tipo de evento | +| `payment_id` | `event.data.payment_id` o `event.subject` | ID de la transacción Bold | +| `referencia` | `event.data.metadata.reference` | Tu referencia personalizada | +| `payer_email` | `event.data.payer_email` | Email del pagador | +| `amount` | `event.data.amount.total` | Monto en pesos COP | + +### Flujo de procesamiento + +```php +$event = json_decode($rawBody, true); +$notificationId = $event['id'] ?? ''; +$tipo = $event['type'] ?? ''; +$data = $event['data'] ?? []; + +$paymentId = $data['payment_id'] ?? ($event['subject'] ?? ''); +$referencia = $data['metadata']['reference'] ?? ''; +$email = $data['payer_email'] ?? ''; + +// Solo procesar ventas aprobadas +if ($tipo !== 'SALE_APPROVED') { + exit; +} + +// Verificar idempotencia (ver sección 9) +// Resolver usuario (ver sección 8) +// Marcar pago en la base de datos +// Enviar correo de confirmación +``` + +--- + +## 8. Estrategia de referencias múltiples + +**Problema real:** Bold puede enviar en el webhook un `payment_id` diferente al `LNK_xxx` +original del payment link. Si solo guardas `bold_order_id`, la búsqueda falla. + +**Solución:** tabla de historial de referencias. Cada vez que aparece una referencia nueva +asociada a un usuario, se registra. + +### Tabla `usuarios_referencias_pago` + +```sql +CREATE TABLE IF NOT EXISTS `usuarios_referencias_pago` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `usuario_id` INT NOT NULL, + `referencia` VARCHAR(120) NOT NULL, + `tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida', + `origen` VARCHAR(40) NOT NULL DEFAULT 'sistema', + `creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_referencia` (`referencia`), + INDEX `idx_usuario` (`usuario_id`), + CONSTRAINT `fk_ref_usuario` + FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### Cuándo registrar referencias + +| Momento | Referencias a guardar | `tipo` | +|----------------------|--------------------------------------------------------|------------------| +| Al crear el link | `LNK_xxx` (payment_link) | `payment_link` | +| Al crear el link | `ERA-{id}-{ts}` (tu referencia) | `referencia_era` | +| En el callback URL | Todos los params GET de Bold | `callback` | +| En el webhook | `payment_id` del evento | `webhook_payment_id` | +| En el webhook | `metadata.reference` del evento | `webhook_referencia` | + +### Función de búsqueda por cualquier referencia + +```php +function buscarUsuarioIdPorReferencia(PDO $pdo, string $referencia): ?int { + $stmt = $pdo->prepare(' + SELECT usuario_id + FROM usuarios_referencias_pago + WHERE referencia = ? + LIMIT 1 + '); + $stmt->execute([trim($referencia)]); + $row = $stmt->fetchColumn(); + return $row ? (int)$row : null; +} +``` + +### Cascada de búsqueda en el webhook + +```php +$usuarioId = null; + +// 1. Por referencia personalizada ERA-{id}-{ts} +if ($referencia !== '') { + $usuarioId = buscarUsuarioIdPorReferencia($pdo, $referencia); +} + +// 2. Por payment_id de la transacción +if (!$usuarioId && $paymentId !== '') { + $usuarioId = buscarUsuarioIdPorReferencia($pdo, $paymentId); +} + +// 3. Por email (último recurso, solo si hay un único pendiente) +if (!$usuarioId && $payerEmail !== '') { + $stmt = $pdo->prepare(' + SELECT id FROM usuarios + WHERE email = ? AND estado_pago = "pendiente" + ORDER BY fecha_registro DESC LIMIT 1 + '); + $stmt->execute([$payerEmail]); + $row = $stmt->fetchColumn(); + $usuarioId = $row ? (int)$row : null; +} +``` + +--- + +## 9. Idempotencia + +Bold puede reenviar la misma notificación varias veces (reintentos ante timeouts). +Debes garantizar que procesar la misma notificación dos veces no cause efectos duplicados +(doble correo, doble registro de pago, etc.). + +### Tabla `webhook_log` + +```sql +CREATE TABLE IF NOT EXISTS `webhook_log` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `notification_id` VARCHAR(64) NOT NULL, + `tipo` VARCHAR(30) NOT NULL, + `payment_id` VARCHAR(64) DEFAULT NULL, + `referencia` VARCHAR(120) DEFAULT NULL, + `usuario_id` INT DEFAULT NULL, + `procesado` TINYINT(1) NOT NULL DEFAULT 0, + `recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_notification` (`notification_id`), + INDEX `idx_usuario` (`usuario_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### Patrón de INSERT IGNORE + +```php +// Intentar insertar. Si ya existe (UNIQUE conflict) → rowCount() = 0 → duplicado +$ins = $pdo->prepare(' + INSERT IGNORE INTO webhook_log (notification_id, tipo, payment_id, referencia) + VALUES (?, ?, ?, ?) +'); +$ins->execute([$notificationId, $tipo, $paymentId, $referencia]); + +if ($ins->rowCount() === 0) { + // Notificación ya procesada anteriormente + exit; +} + +// Continúa procesamiento... + +// Al finalizar, marcar como procesado +$pdo->prepare('UPDATE webhook_log SET procesado=1, usuario_id=? WHERE notification_id=?') + ->execute([$usuarioId, $notificationId]); +``` + +--- + +## 10. Esquema de base de datos + +Tablas mínimas necesarias: + +```sql +-- Tabla principal de usuarios/registros +CREATE TABLE `usuarios` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `token` VARCHAR(64) NOT NULL UNIQUE, + `bold_order_id` VARCHAR(80) DEFAULT NULL, -- LNK_xxx principal + `estado_pago` ENUM('pendiente','pagado') NOT NULL DEFAULT 'pendiente', + `fecha_registro` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Historial de todas las referencias de Bold asociadas al usuario +CREATE TABLE `usuarios_referencias_pago` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `usuario_id` INT NOT NULL, + `referencia` VARCHAR(120) NOT NULL, + `tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida', + `origen` VARCHAR(40) NOT NULL DEFAULT 'sistema', + `creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_referencia` (`referencia`), + INDEX `idx_usuario` (`usuario_id`), + CONSTRAINT `fk_ref_usuario` + FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Log de webhooks para idempotencia +CREATE TABLE `webhook_log` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `notification_id` VARCHAR(64) NOT NULL, + `tipo` VARCHAR(30) NOT NULL, + `payment_id` VARCHAR(64) DEFAULT NULL, + `referencia` VARCHAR(120) DEFAULT NULL, + `usuario_id` INT DEFAULT NULL, + `procesado` TINYINT(1) NOT NULL DEFAULT 0, + `recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uq_notification` (`notification_id`), + INDEX `idx_usuario` (`usuario_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + +## 11. Flujo completo de extremo a extremo + +``` +Usuario llena formulario + │ + ▼ +[Tu backend] genera referencia personalizada: + ERA-{usuarioId}-{timestamp} + │ + ▼ +POST → API Bold /online/link/v1 + │ + ▼ respuesta LNK_xxx + URL +[Guardar en DB]: + - usuarios.bold_order_id = LNK_xxx + - referencias_pago: LNK_xxx (tipo: payment_link) + - referencias_pago: ERA-... (tipo: referencia_era) + - sesión: bold_order_id, usuario_id + │ + ▼ +Redirigir usuario a URL Bold (checkout) + │ + Usuario paga + │ + ┌────┴────────────────────────────────┐ + │ │ + ▼ ▼ +callback_url (GET) webhook (POST) ← fuente de verdad + Bold redirige al usuario Bold notifica el evento + con params: bold-order-id, etc. + │ │ + ▼ ▼ +Mostrar pantalla "procesando" 1. Responder 200 INMEDIATAMENTE +No marcar como pagado aún 2. Verificar firma HMAC + 3. Idempotencia (INSERT IGNORE) + 4. Resolver usuario por referencias + 5. UPDATE estado_pago = 'pagado' + 6. Enviar correo de confirmación +``` + +--- + +## 12. Errores frecuentes y soluciones + +### El webhook no llega + +- Verificar que la URL esté registrada en el panel de Bold. +- La URL debe ser **pública** (no localhost). Usar [ngrok](https://ngrok.com) para pruebas locales: + ```bash + ngrok http 80 + # Registrar la URL https://xxxx.ngrok.io/webhook.php en Bold + ``` +- Confirmar que el servidor responde HTTP 200 en < 2 s. +- Revisar el log de webhooks en el panel de Bold para ver reintentos. + +### Firma inválida en modo test + +El `secretKey` en modo test es `""` (cadena vacía). Asegúrate de usar eso, no la clave real. + +```php +$keyForHmac = ($boldMode === 'production') ? $secretKey : ''; +``` + +### No se encuentra el usuario en el webhook + +El `payment_id` que llega en el webhook puede ser **diferente** al `LNK_xxx` creado con la API. +Solución: usar la tabla de historial de referencias y la referencia personalizada `ERA-{id}-{ts}`. + +### Bold reintenta y se procesan pagos dobles + +Implementar idempotencia con `webhook_log` usando `INSERT IGNORE` sobre `notification_id`. + +### HTTP 400 al crear el link + +- Verificar que `total_amount` sea entero (no float). +- `reference` no puede superar 60 caracteres ni contener caracteres especiales. +- La `callback_url` debe ser una URL accesible públicamente. + +### Error de timeout en curl + +Bold tiene un timeout de respuesta. Si el servidor tarda más de 15 s, la llamada falla. +Configurar `CURLOPT_TIMEOUT` en 15 y asegurar que la conexión a internet es estable. + +--- + +## Referencias oficiales + +- Documentación Bold: [https://developers.bold.co](https://developers.bold.co) +- Panel Bold: [https://dashboard.bold.co](https://dashboard.bold.co) +- API de links: `POST https://integrations.api.bold.co/online/link/v1` +- Consultar link: `GET https://integrations.api.bold.co/online/link/v1/{id}` diff --git a/main.go b/main.go index 2531e39..fb732bc 100755 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/pyroscope-io/pyroscope/pkg/agent/profiler" "github.com/sujit-baniya/fiber-boilerplate/app" "github.com/sujit-baniya/fiber-boilerplate/migrations" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" "github.com/sujit-baniya/fiber-boilerplate/rest/routes" ) @@ -47,6 +48,8 @@ func main() { // Seed automático (idempotente) de módulos del sistema migrations.SeedRenovaciones() migrations.SeedIntegraciones() + // Auto-migrar query_history si no existe + app.Http.Database.DB.AutoMigrate(&models.QueryHistory{}) // Iniciar cron de vencimientos services.IniciarCron() defer services.DetenerCron() diff --git a/migrations/migrate.go b/migrations/migrate.go index 719ede6..e5f93f5 100755 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -53,6 +53,8 @@ func Migrate() { // Integraciones externas &models.HostingerConfig{}, &models.CloudflareConfig{}, + // Query Runner + &models.QueryHistory{}, // Pasarelas de pago &models.BoldConfig{}, &models.BoldWebhookLog{}, diff --git a/pkg/models/query_history.go b/pkg/models/query_history.go new file mode 100644 index 0000000..57a88b8 --- /dev/null +++ b/pkg/models/query_history.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "github.com/sujit-baniya/fiber-boilerplate/app" + "gorm.io/gorm" +) + +// QueryHistory almacena el historial de consultas ejecutadas por los usuarios. +type QueryHistory struct { + gorm.Model + ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"` + ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"` + SQL string `json:"sql" gorm:"column:sql;type:text"` + Status string `json:"status" gorm:"column:status"` // ok | error + ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"` + RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"` + DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"` + ExecutedAt time.Time `json:"executed_at" gorm:"column:executed_at;autoCreateTime"` +} + +func (QueryHistory) TableName() string { + return "query_history" +} + +// SaveQueryHistory guarda una entrada en el historial. +func SaveQueryHistory(h QueryHistory) error { + return app.Http.Database.DB.Create(&h).Error +} + +// GetQueryHistory devuelve el historial de una conexión con paginación. +func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) { + var items []QueryHistory + var total int64 + db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID) + db.Count(&total) + err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error + return items, total, err +} + +// DeleteQueryHistory elimina todo el historial de una conexión. +func DeleteQueryHistory(conxDbID uint) error { + return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error +} diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go new file mode 100644 index 0000000..6c53e5c --- /dev/null +++ b/pkg/services/query_runner_service.go @@ -0,0 +1,298 @@ +package services + +import ( + "database/sql" + "fmt" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/jackc/pgx/v5/stdlib" + _ "github.com/mattn/go-sqlite3" + _ "github.com/microsoft/go-mssqldb" + + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" +) + +// QueryResult contiene el resultado de una consulta SQL. +type QueryResult struct { + Columns []string `json:"columns"` + Rows []map[string]any `json:"rows"` + RowCount int `json:"row_count"` + AffectedRows int64 `json:"affected_rows"` + DurationMs int64 `json:"duration_ms"` + IsSelect bool `json:"is_select"` + Error string `json:"error,omitempty"` +} + +// openDynamicDB abre una conexión a la base de datos indicada por ConxDb. +func openDynamicDB(c models.ConxDb) (*sql.DB, error) { + driver := strings.ToLower(c.TipoDb.Nombre) + host := c.Servidor.IpServidor + port := c.Puerto + user := c.Usuario + pass := c.Password + + var dsn string + var driverName string + + switch { + case strings.Contains(driver, "postgres"): + driverName = "postgres" + dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable", host, port, user, pass) + case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"): + driverName = "mysql" + dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, pass, host, port) + case strings.Contains(driver, "sqlite"): + driverName = "sqlite3" + dsn = host // para sqlite el host es la ruta del archivo + case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"): + driverName = "sqlserver" + dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s", user, pass, host, port) + default: + return nil, fmt.Errorf("driver no soportado: %s", driver) + } + + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, err + } + db.SetConnMaxLifetime(30 * time.Second) + db.SetMaxOpenConns(2) + return db, nil +} + +// ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult. +// También guarda en query_history. +func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult { + start := time.Now() + + db, err := openDynamicDB(conx) + if err != nil { + saveHistory(conx.ID, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds()) + return QueryResult{Error: err.Error()} + } + defer db.Close() + + // Si se especifica una base de datos para seleccionar + if database != "" { + driver := strings.ToLower(conx.TipoDb.Nombre) + if strings.Contains(driver, "postgres") { + // En postgres se cambia con SET search_path o reconectando con dbname en DSN + db2, err2 := openDynamicDBWithName(conx, database) + if err2 == nil { + db.Close() + db = db2 + } + } else { + if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil { + saveHistory(conx.ID, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds()) + return QueryResult{Error: err2.Error()} + } + } + } + + trimmed := strings.TrimSpace(sqlText) + isSelect := isSelectStatement(trimmed) + + var result QueryResult + result.IsSelect = isSelect + + if isSelect { + rows, err := db.Query(trimmed) + if err != nil { + elapsed := time.Since(start).Milliseconds() + saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed) + return QueryResult{Error: err.Error(), IsSelect: true} + } + defer rows.Close() + + cols, _ := rows.Columns() + result.Columns = cols + + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + rows.Scan(ptrs...) + row := make(map[string]any, len(cols)) + for i, col := range cols { + v := vals[i] + if b, ok := v.([]byte); ok { + row[col] = string(b) + } else { + row[col] = v + } + } + result.Rows = append(result.Rows, row) + } + result.RowCount = len(result.Rows) + } else { + res, err := db.Exec(trimmed) + elapsed := time.Since(start).Milliseconds() + if err != nil { + saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed) + return QueryResult{Error: err.Error()} + } + affected, _ := res.RowsAffected() + result.AffectedRows = affected + result.DurationMs = elapsed + saveHistory(conx.ID, sqlText, "ok", "", affected, elapsed) + return result + } + + result.DurationMs = time.Since(start).Milliseconds() + saveHistory(conx.ID, sqlText, "ok", "", int64(result.RowCount), result.DurationMs) + return result +} + +// ListDatabases devuelve la lista de bases de datos del servidor. +func ListDatabases(conx models.ConxDb) ([]string, error) { + db, err := openDynamicDB(conx) + if err != nil { + return nil, err + } + defer db.Close() + + driver := strings.ToLower(conx.TipoDb.Nombre) + var query string + switch { + case strings.Contains(driver, "postgres"): + query = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname" + case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"): + query = "SHOW DATABASES" + case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"): + query = "SELECT name FROM sys.databases ORDER BY name" + default: + return []string{"main"}, nil + } + + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var dbs []string + for rows.Next() { + var name string + rows.Scan(&name) + dbs = append(dbs, name) + } + return dbs, nil +} + +// ListTables devuelve las tablas de una base de datos. +func ListTables(conx models.ConxDb, database string) ([]string, error) { + driver := strings.ToLower(conx.TipoDb.Nombre) + + var db *sql.DB + var err error + + if strings.Contains(driver, "postgres") { + db, err = openDynamicDBWithName(conx, database) + } else { + db, err = openDynamicDB(conx) + } + if err != nil { + return nil, err + } + defer db.Close() + + var query string + switch { + case strings.Contains(driver, "postgres"): + query = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" + case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"): + if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil { + return nil, err + } + query = "SHOW TABLES" + case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"): + query = fmt.Sprintf("USE [%s]; SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", database) + default: + query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + } + + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + rows.Scan(&name) + tables = append(tables, name) + } + return tables, nil +} + +// TestConnection verifica si la conexión es válida. +func TestDBConnection(conx models.ConxDb) error { + db, err := openDynamicDB(conx) + if err != nil { + return err + } + defer db.Close() + return db.Ping() +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) { + host := c.Servidor.IpServidor + port := c.Puerto + user := c.Usuario + pass := c.Password + driver := strings.ToLower(c.TipoDb.Nombre) + + var dsn string + switch { + case strings.Contains(driver, "postgres"): + dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, pass, dbName) + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + db.SetConnMaxLifetime(30 * time.Second) + db.SetMaxOpenConns(2) + return db, nil + default: + return openDynamicDB(c) + } +} + +func isSelectStatement(sql string) bool { + upper := strings.ToUpper(strings.TrimSpace(sql)) + keywords := []string{"SELECT ", "SHOW ", "DESCRIBE ", "EXPLAIN ", "WITH ", "PRAGMA "} + for _, kw := range keywords { + if strings.HasPrefix(upper, kw) { + return true + } + } + return false +} + +func quoteIdentifier(name, driver string) string { + d := strings.ToLower(driver) + if strings.Contains(d, "postgres") { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` + } + return "`" + strings.ReplaceAll(name, "`", "``") + "`" +} + +func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs int64) { + models.SaveQueryHistory(models.QueryHistory{ + ConxDbID: conxID, + SQL: sqlText, + Status: status, + ErrorMsg: errMsg, + RowsAffect: rows, + DurationMs: durationMs, + ExecutedAt: time.Now(), + }) +} diff --git a/resources/views/conx_db.html b/resources/views/conx_db.html index ab90f9a..40b116f 100755 --- a/resources/views/conx_db.html +++ b/resources/views/conx_db.html @@ -68,6 +68,15 @@ d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> + + + + + + diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html new file mode 100644 index 0000000..746eb3d --- /dev/null +++ b/resources/views/query_runner.html @@ -0,0 +1,382 @@ +
+ + +
+ +
+ +
+ + + + + +
+ + +
+ + + + +
+ + +
+ + + +
+ + + +
+ + +
+
+
+ SQL Editor +
+ +
+ +
+
+ + +
+ + +
+ + +
+
+ + + + Sin resultados. Ejecuta una consulta. +
+ + + + + + + + + + +
#
+
+ + +
+
+ Historial de consultas + +
+
Sin historial para esta conexión.
+ +
+ +
+
+
+ + +
+
+ + diff --git a/rest/controllers/query_runner_controller.go b/rest/controllers/query_runner_controller.go new file mode 100644 index 0000000..261eafa --- /dev/null +++ b/rest/controllers/query_runner_controller.go @@ -0,0 +1,237 @@ +package controllers + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sujit-baniya/fiber-boilerplate/app" + "github.com/sujit-baniya/fiber-boilerplate/pkg/models" + "github.com/sujit-baniya/fiber-boilerplate/pkg/services" +) + +// QueryRunnerPage renderiza la vista del editor SQL. +// GET /app/query-runner?conx_db_id=1 +func QueryRunnerPage(c *fiber.Ctx) error { + conxIDStr := c.Query("conx_db_id", "") + var conx models.ConxDb + if conxIDStr != "" { + id, _ := strconv.ParseUint(conxIDStr, 10, 32) + app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id) + } + + data := fiber.Map{ + "user": c.Locals("user").(map[string]interface{}), + "modules": c.Locals("userModules"), + "conx_db_id": conxIDStr, + "conx": conx, + } + if err := c.Render("query_runner", data, "layouts/main"); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return nil +} + +// GetConxDbList devuelve todas las conexiones DB disponibles (para el selector). +func GetConxDbList(c *fiber.Ctx) error { + var items []models.ConxDb + app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items) + return c.JSON(fiber.Map{"data": items}) +} + +// GetDatabases lista las bases de datos de una conexión. +// GET /app/query-runner/databases?conx_db_id=1 +func GetDatabases(c *fiber.Ctx) error { + conx, err := loadConxDb(c.Query("conx_db_id", "0")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + dbs, err := services.ListDatabases(conx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"data": dbs}) +} + +// GetTables lista las tablas de una base de datos. +// GET /app/query-runner/tables?conx_db_id=1&db=mydb +func GetTables(c *fiber.Ctx) error { + conx, err := loadConxDb(c.Query("conx_db_id", "0")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + dbName := c.Query("db", "") + tables, err := services.ListTables(conx, dbName) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"data": tables}) +} + +// RunQuery ejecuta una consulta SQL. +// POST /app/query-runner/run +// Body: { conx_db_id, database, sql } +func RunQuery(c *fiber.Ctx) error { + var body struct { + ConxDbID uint `json:"conx_db_id"` + Database string `json:"database"` + SQL string `json:"sql"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"}) + } + if strings.TrimSpace(body.SQL) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"}) + } + + conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID))) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + result := services.ExecuteSQL(conx, body.Database, body.SQL) + return c.JSON(result) +} + +// TestConnection verifica que la conexión funciona. +// GET /app/query-runner/test?conx_db_id=1 +func TestConnection(c *fiber.Ctx) error { + conx, err := loadConxDb(c.Query("conx_db_id", "0")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + if err := services.TestDBConnection(conx); err != nil { + return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"ok": false, "error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// GetHistory devuelve el historial de una conexión. +// GET /app/query-runner/history?conx_db_id=1&page=1 +func GetHistory(c *fiber.Ctx) error { + conxIDStr := c.Query("conx_db_id", "0") + conxID, _ := strconv.ParseUint(conxIDStr, 10, 32) + page, _ := strconv.Atoi(c.Query("page", "1")) + if page < 1 { + page = 1 + } + limit := 50 + offset := (page - 1) * limit + + items, total, err := models.GetQueryHistory(uint(conxID), limit, offset) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + totalPages := int(math.Ceil(float64(total) / float64(limit))) + return c.JSON(fiber.Map{ + "data": items, + "total": total, + "totalPages": totalPages, + "page": page, + }) +} + +// ClearHistory borra el historial de una conexión. +// DELETE /app/query-runner/history?conx_db_id=1 +func ClearHistory(c *fiber.Ctx) error { + conxIDStr := c.Query("conx_db_id", "0") + conxID, _ := strconv.ParseUint(conxIDStr, 10, 32) + if err := models.DeleteQueryHistory(uint(conxID)); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} + +// ExportCSV exporta el resultado de una consulta como CSV. +// POST /app/query-runner/export/csv +func ExportCSV(c *fiber.Ctx) error { + var body struct { + ConxDbID uint `json:"conx_db_id"` + Database string `json:"database"` + SQL string `json:"sql"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"}) + } + conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID))) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + result := services.ExecuteSQL(conx, body.Database, body.SQL) + if result.Error != "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error}) + } + + var buf bytes.Buffer + w := csv.NewWriter(&buf) + w.Write(result.Columns) + for _, row := range result.Rows { + rec := make([]string, len(result.Columns)) + for i, col := range result.Columns { + v := row[col] + if v == nil { + rec[i] = "" + } else { + rec[i] = fmt.Sprintf("%v", v) + } + } + w.Write(rec) + } + w.Flush() + + filename := fmt.Sprintf("query_%s.csv", time.Now().Format("20060102_150405")) + c.Set("Content-Disposition", "attachment; filename="+filename) + c.Set("Content-Type", "text/csv; charset=utf-8") + return c.SendStream(bytes.NewReader(buf.Bytes()), buf.Len()) +} + +// ExportJSON exporta el resultado como JSON. +// POST /app/query-runner/export/json +func ExportJSON(c *fiber.Ctx) error { + var body struct { + ConxDbID uint `json:"conx_db_id"` + Database string `json:"database"` + SQL string `json:"sql"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"}) + } + conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID))) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + result := services.ExecuteSQL(conx, body.Database, body.SQL) + if result.Error != "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error}) + } + + data, _ := json.MarshalIndent(result.Rows, "", " ") + filename := fmt.Sprintf("query_%s.json", time.Now().Format("20060102_150405")) + c.Set("Content-Disposition", "attachment; filename="+filename) + c.Set("Content-Type", "application/json; charset=utf-8") + return c.SendStream(bytes.NewReader(data), len(data)) +} + +// ── helper ─────────────────────────────────────────────────────────────────── + +func loadConxDb(idStr string) (models.ConxDb, error) { + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil || id == 0 { + return models.ConxDb{}, fmt.Errorf("conx_db_id inválido") + } + var conx models.ConxDb + if err := app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id).Error; err != nil { + return models.ConxDb{}, fmt.Errorf("conexión no encontrada: %w", err) + } + return conx, nil +} diff --git a/rest/controllers/seed_controller.go b/rest/controllers/seed_controller.go index 981da5f..1ed16f5 100644 --- a/rest/controllers/seed_controller.go +++ b/rest/controllers/seed_controller.go @@ -20,6 +20,14 @@ func RunSeed(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + if err := seedModulo("Conexiones", "Gestión de servidores y conexiones de base de datos", []seedEntry{ + {"Conexiones DB", "Lista y configuración de conexiones de base de datos", "/app/conexion_db"}, + {"Query Runner", "Editor SQL: ejecutar consultas, exportar y ver historial", "/app/query-runner"}, + {"Conexiones SSH", "Gestión de conexiones SSH a servidores", "/app/conexion_ssh"}, + }); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if err := seedModulo("Renovaciones", "Gestión de contratos, clientes y notificaciones de vencimiento", []seedEntry{ {"Contratos", "Gestión de contratos y vencimientos", "/app/contratos"}, {"Clientes", "Clientes y datos de contacto", "/app/clientes"}, diff --git a/rest/routes/user.go b/rest/routes/user.go index a362a94..aabd978 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -97,7 +97,17 @@ func UserRoutes(app fiber.Router) { // ─── Seed manual (admin) ──────────────────────────────────────────── protected.Get("/run-seed", controllers.RunSeed) - + // ─── Query Runner (editor SQL) ──────────────────────────────────────────── + protected.Get("/query-runner", middlewares.MenuMiddleware, controllers.QueryRunnerPage) + protected.Get("/query-runner/connections", controllers.GetConxDbList) + protected.Get("/query-runner/databases", controllers.GetDatabases) + protected.Get("/query-runner/tables", controllers.GetTables) + protected.Get("/query-runner/test", controllers.TestConnection) + protected.Post("/query-runner/run", controllers.RunQuery) + protected.Get("/query-runner/history", controllers.GetHistory) + protected.Delete("/query-runner/history", controllers.ClearHistory) + protected.Post("/query-runner/export/csv", controllers.ExportCSV) + protected.Post("/query-runner/export/json", controllers.ExportJSON) // ─── Hostinger API ──────────────────────────────────────────────── protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage) protected.Post("/hostinger/config", controllers.SaveHostingerConfig) diff --git a/template_out/plantilla1.html b/template_out/plantilla1.html new file mode 100644 index 0000000..73919d0 --- /dev/null +++ b/template_out/plantilla1.html @@ -0,0 +1,149 @@ + + + + + + + + Renovación de servicios — USITE + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
 
+ + + + + + + +
+ USITE + + ● Renovación +
+ + +

+ Renueva antes de que venza +

+

+ Uno o más servicios están próximos a expirar. Actúa antes de la fecha límite para evitar interrupciones. +

+ +
+ + +

+ Hola {{.ClienteNombre}} — para garantizar la continuidad digital de {{.ClienteEmpresa}}, te recomendamos renovar antes de la fecha indicada. +

+ + + + + + + + +
+

Vence

+

{{.FechaVencimiento}}

+
+

Días restantes

+

{{.DiasRestantes}} días

+
+

Total a pagar

+

${{.Total}}

+
+ + +

Servicios incluidos

+ + + + {{range .Servicios}} + + + + + {{end}} +
+ + {{.Nombre}} + {{.Moneda}} {{.Precio}}
+ + + + + + +
+ + Renovar servicios  → + +
+ +
+

+ Si ya realizaste el pago, ignora este mensaje. ¿Necesitas ayuda? Escríbenos directamente a través de nuestro canal de soporte. +

+
+ + + + + +
+

+ © USITE — Soluciones de Software a Medida +

+

Mensaje automático · u-site.app

+
+ USITE +
+
+ + +
+
+ +