diff --git a/WEBHOOK_LARAVEL.md b/WEBHOOK_LARAVEL.md new file mode 100644 index 0000000..0071b28 --- /dev/null +++ b/WEBHOOK_LARAVEL.md @@ -0,0 +1,130 @@ +# Webhook de notificación de pagos — Implementación Laravel + +## Contexto + +El sistema **soft_usite** (Go/Fiber) envía una notificación HTTP `POST` a una URL configurable +cada vez que se confirma un pago mediante **Bold** o **dLocal**. +El proveedor SaaS debe exponer un endpoint en Laravel para recibirla. + +--- + +## 1. Ruta + +``` +POST /api/pagos/notificacion +``` + +- Registrar en `routes/api.php`. +- Excluir del middleware CSRF (`VerifyCsrfToken` no aplica en rutas `api`, pero verificar que no haya middleware adicional que lo requiera). + +--- + +## 2. Autenticación + +El sistema envía un header de API key configurable (nombre y valor definidos en `saas_api_configs`). +Por defecto el header es `X-API-Key`. + +| Variable `.env` | Descripción | +|---|---| +| `WEBHOOK_SECRET` | Valor secreto que debe coincidir con el configurado en soft_usite | +| `WEBHOOK_HEADER` | Nombre del header (default: `X-API-Key`) | + +Si el header no coincide → responder `401 Unauthorized`. + +--- + +## 3. Body JSON recibido + +```json +{ + "contrato_id": 42, + "referencia": "contrato-42", + "email": "cliente@ejemplo.com", + "monto": 150000.00, + "moneda": "COP", + "fuente": "bold" +} +``` + +| Campo | Tipo | Descripción | +|---|---|---| +| `contrato_id` | `integer` | ID del contrato en soft_usite | +| `referencia` | `string` | Formato `"contrato-{id}"` | +| `email` | `string` | Email del pagador | +| `monto` | `float` | Monto pagado | +| `moneda` | `string` | ISO 4217: `COP`, `USD`, etc. | +| `fuente` | `string` | `"bold"` o `"dlocal"` | + +--- + +## 4. Lógica a implementar + +1. **Validar** el body con las reglas mínimas (`contrato_id` required integer, `referencia` required string, etc.). +2. **Idempotencia:** buscar en `webhook_logs` si ya existe un registro con la misma `referencia` + `fuente` procesado. Si existe → responder `200 {"ok": true}` sin reprocesar. +3. **Guardar log** en tabla `webhook_logs` con todos los campos más `ip` y `raw_body`. +4. **Buscar el contrato** local (o el recurso equivalente) por `contrato_id`. +5. **Marcar como pagado:** `pago_confirmado = true`, `fecha_pago = now()`. +6. **Disparar evento/job** extensible: `PagoConfirmadoEvent` o `ProcesarPagoJob` — envío de correo, activación de cuenta, etc. + +--- + +## 5. Respuesta + +Siempre responder `HTTP 200` con `{"ok": true}`, incluso ante errores internos. +Los errores deben loguearse con `Log::error()` sin exponer detalles en la respuesta, +para evitar reintentos innecesarios desde el sistema externo. + +--- + +## 6. Tabla `webhook_logs` + +```php +Schema::create('webhook_logs', function (Blueprint $table) { + $table->id(); + $table->unsignedInteger('contrato_id'); + $table->string('referencia')->index(); + $table->string('email')->nullable(); + $table->decimal('monto', 12, 2)->nullable(); + $table->string('moneda', 10)->nullable(); + $table->string('fuente', 20)->nullable(); // bold | dlocal + $table->string('ip', 45)->nullable(); + $table->text('raw_body')->nullable(); + $table->boolean('procesado')->default(false); + $table->timestamps(); + + $table->unique(['referencia', 'fuente']); // idempotencia +}); +``` + +--- + +## 7. Archivos a generar + +| Archivo | Descripción | +|---|---| +| `app/Http/Controllers/Api/WebhookPagoController.php` | Controlador principal | +| `app/Models/WebhookLog.php` | Modelo Eloquent | +| `database/migrations/xxxx_create_webhook_logs_table.php` | Migración | +| `app/Events/PagoConfirmado.php` | Evento (opcional pero recomendado) | +| `app/Listeners/ProcesarPagoConfirmado.php` | Listener del evento | +| `routes/api.php` | Registro de la ruta | +| `.env.example` | Variables `WEBHOOK_SECRET` y `WEBHOOK_HEADER` | + +--- + +## 8. Variables `.env.example` + +```env +WEBHOOK_SECRET=tu_clave_secreta_aqui +WEBHOOK_HEADER=X-API-Key +``` + +--- + +## 9. Notas de seguridad + +- **No** exponer detalles de error en la respuesta JSON. +- El valor de `WEBHOOK_SECRET` debe tener al menos 32 caracteres aleatorios. +- Registrar siempre la IP de origen (`$request->ip()`) en el log. +- No procesar el pago si el log ya existe (idempotencia por `referencia + fuente`). +- Comparar el API key con `hash_equals()` para evitar timing attacks. diff --git a/pkg/services/cloudflare_service.go b/pkg/services/cloudflare_service.go index 05c43af..7e0b11e 100644 --- a/pkg/services/cloudflare_service.go +++ b/pkg/services/cloudflare_service.go @@ -1,6 +1,7 @@ package services import ( + "bytes" "encoding/json" "fmt" "io" @@ -212,3 +213,115 @@ func (c *CloudflareClient) GetFirewallRules(zoneID string) ([]CFFirewallRule, er } return rules, nil } + +// ─── Helpers de escritura ──────────────────────────────────────────────────── + +// doRequest realiza una petición con body JSON y decodifica la respuesta. +func (c *CloudflareClient) doRequest(method, path string, payload interface{}, dest interface{}) error { + var bodyReader io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cloudflare: serializar payload: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, cloudflareBaseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("cloudflare: crear request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cloudflare: ejecutar request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("cloudflare: leer body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("cloudflare: status %d – %s", resp.StatusCode, string(body)) + } + + // Para DELETE la respuesta puede ser vacía o tener solo {"success":true,"result":{"id":"..."}} + if len(body) == 0 { + return nil + } + + var wrapper struct { + Success bool `json:"success"` + Errors []CFError `json:"errors"` + Result json.RawMessage `json:"result"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { + return fmt.Errorf("cloudflare: decodificar envelope: %w", err) + } + if !wrapper.Success { + if len(wrapper.Errors) > 0 { + return fmt.Errorf("cloudflare API error %d: %s", wrapper.Errors[0].Code, wrapper.Errors[0].Message) + } + return fmt.Errorf("cloudflare: respuesta no exitosa") + } + if dest != nil && wrapper.Result != nil { + if err := json.Unmarshal(wrapper.Result, dest); err != nil { + return fmt.Errorf("cloudflare: decodificar result: %w", err) + } + } + return nil +} + +// ─── Tipos de entrada para DNS ─────────────────────────────────────────────── + +// CFDNSRecordInput es el payload para crear o actualizar un registro DNS. +type CFDNSRecordInput struct { + Type string `json:"type"` // A, AAAA, CNAME, TXT, MX, NS, SRV, CAA… + Name string `json:"name"` // Nombre del registro (ej. "www" o "@") + Content string `json:"content"` // Valor del registro + TTL int `json:"ttl"` // 1 = automático, o segundos (min 60) + Proxied bool `json:"proxied"` // true = nube naranja + Priority int `json:"priority,omitempty"` // Solo para MX / SRV +} + +// ─── DNS CRUD ──────────────────────────────────────────────────────────────── + +// CreateDNSRecord crea un nuevo registro DNS en la zona indicada. +func (c *CloudflareClient) CreateDNSRecord(zoneID string, input CFDNSRecordInput) (*CFDNSRecord, error) { + var record CFDNSRecord + path := fmt.Sprintf("/zones/%s/dns_records", zoneID) + if err := c.doRequest(http.MethodPost, path, input, &record); err != nil { + return nil, err + } + return &record, nil +} + +// UpdateDNSRecord actualiza (PUT completo) un registro DNS existente. +func (c *CloudflareClient) UpdateDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) { + var record CFDNSRecord + path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) + if err := c.doRequest(http.MethodPut, path, input, &record); err != nil { + return nil, err + } + return &record, nil +} + +// PatchDNSRecord actualiza parcialmente (PATCH) un registro DNS existente. +func (c *CloudflareClient) PatchDNSRecord(zoneID, recordID string, input CFDNSRecordInput) (*CFDNSRecord, error) { + var record CFDNSRecord + path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) + if err := c.doRequest(http.MethodPatch, path, input, &record); err != nil { + return nil, err + } + return &record, nil +} + +// DeleteDNSRecord elimina un registro DNS por su ID. +func (c *CloudflareClient) DeleteDNSRecord(zoneID, recordID string) error { + path := fmt.Sprintf("/zones/%s/dns_records/%s", zoneID, recordID) + return c.doRequest(http.MethodDelete, path, nil, nil) +} diff --git a/rest/controllers/cloudflare_controller.go b/rest/controllers/cloudflare_controller.go index 0e07257..6c84b25 100644 --- a/rest/controllers/cloudflare_controller.go +++ b/rest/controllers/cloudflare_controller.go @@ -73,7 +73,7 @@ func GetCloudflareUser(c *fiber.Ctx) error { } data, err := client.GetUser() if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data}) } @@ -88,7 +88,7 @@ func GetCloudflareZones(c *fiber.Ctx) error { } data, err := client.GetZones() if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data}) } @@ -107,7 +107,7 @@ func GetCloudflareDNS(c *fiber.Ctx) error { } data, err := client.GetDNSRecords(zoneID) if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data, "zone_id": zoneID}) } @@ -126,7 +126,7 @@ func GetCloudflareSSL(c *fiber.Ctx) error { } data, err := client.GetSSLCertificates(zoneID) if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data, "zone_id": zoneID}) } @@ -145,7 +145,111 @@ func GetCloudflareFirewall(c *fiber.Ctx) error { } data, err := client.GetFirewallRules(zoneID) if err != nil { - return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"data": data, "zone_id": zoneID}) } + +// ─── DNS CRUD ───────────────────────────────────────────────────────────────── + +// CreateCloudflareDNS crea un nuevo registro DNS en la zona (:zone_id). +func CreateCloudflareDNS(c *fiber.Ctx) error { + zoneID := c.Params("zone_id") + if zoneID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id requerido"}) + } + var input services.CFDNSRecordInput + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + if input.Type == "" || input.Name == "" || input.Content == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"}) + } + if input.TTL == 0 { + input.TTL = 1 // automático + } + client, err := cloudflareClient() + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "No se encontró configuración activa de Cloudflare", + }) + } + record, err := client.CreateDNSRecord(zoneID, input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"data": record}) +} + +// UpdateCloudflareDNS actualiza (PUT completo) un registro DNS (:zone_id/:record_id). +func UpdateCloudflareDNS(c *fiber.Ctx) error { + zoneID := c.Params("zone_id") + recordID := c.Params("record_id") + if zoneID == "" || recordID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) + } + var input services.CFDNSRecordInput + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + if input.Type == "" || input.Name == "" || input.Content == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type, name y content son requeridos"}) + } + if input.TTL == 0 { + input.TTL = 1 + } + client, err := cloudflareClient() + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "No se encontró configuración activa de Cloudflare", + }) + } + record, err := client.UpdateDNSRecord(zoneID, recordID, input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"data": record}) +} + +// PatchCloudflareDNS actualiza parcialmente un registro DNS (:zone_id/:record_id). +func PatchCloudflareDNS(c *fiber.Ctx) error { + zoneID := c.Params("zone_id") + recordID := c.Params("record_id") + if zoneID == "" || recordID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) + } + var input services.CFDNSRecordInput + if err := c.BodyParser(&input); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) + } + client, err := cloudflareClient() + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "No se encontró configuración activa de Cloudflare", + }) + } + record, err := client.PatchDNSRecord(zoneID, recordID, input) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"data": record}) +} + +// DeleteCloudflareDNS elimina un registro DNS (:zone_id/:record_id). +func DeleteCloudflareDNS(c *fiber.Ctx) error { + zoneID := c.Params("zone_id") + recordID := c.Params("record_id") + if zoneID == "" || recordID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "zone_id y record_id requeridos"}) + } + client, err := cloudflareClient() + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "No se encontró configuración activa de Cloudflare", + }) + } + if err := client.DeleteDNSRecord(zoneID, recordID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Registro DNS eliminado"}) +} diff --git a/rest/routes/user.go b/rest/routes/user.go index 16c6c57..ce6bd44 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -126,6 +126,10 @@ func UserRoutes(app fiber.Router) { protected.Get("/cloudflare/user", controllers.GetCloudflareUser) protected.Get("/cloudflare/zones", controllers.GetCloudflareZones) protected.Get("/cloudflare/zones/:zone_id/dns", controllers.GetCloudflareDNS) + protected.Post("/cloudflare/zones/:zone_id/dns", controllers.CreateCloudflareDNS) + protected.Put("/cloudflare/zones/:zone_id/dns/:record_id", controllers.UpdateCloudflareDNS) + protected.Patch("/cloudflare/zones/:zone_id/dns/:record_id", controllers.PatchCloudflareDNS) + protected.Delete("/cloudflare/zones/:zone_id/dns/:record_id", controllers.DeleteCloudflareDNS) protected.Get("/cloudflare/zones/:zone_id/ssl", controllers.GetCloudflareSSL) protected.Get("/cloudflare/zones/:zone_id/firewall", controllers.GetCloudflareFirewall)