up
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user