This commit is contained in:
Lizandro Guarnizo
2026-05-26 21:55:48 -05:00
parent d8f38a6669
commit 36c9e0a6d5
5 changed files with 382 additions and 0 deletions
+1
View File
@@ -60,6 +60,7 @@ func Migrate() {
&models.BoldWebhookLog{},
&models.BoldCallbackLog{},
&models.DlocalPaymentLog{},
&models.PaypalConfig{},
// Documentación por SaaS
&models.SaasProducto{},
&models.DocCategoria{},
+62
View File
@@ -0,0 +1,62 @@
package models
import (
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// PaypalConfig almacena las credenciales de PayPal.
// Solo un registro activo a la vez.
type PaypalConfig struct {
gorm.Model
// Credenciales Sandbox
ClientIDSandbox string `json:"client_id_sandbox" gorm:"column:client_id_sandbox;type:text"`
ClientSecretSandbox string `json:"client_secret_sandbox" gorm:"column:client_secret_sandbox;type:text"`
// Credenciales Producción
ClientIDProd string `json:"client_id_prod" gorm:"column:client_id_prod;type:text"`
ClientSecretProd string `json:"client_secret_prod" gorm:"column:client_secret_prod;type:text"`
// Modo activo: "sandbox" | "live"
Modo string `json:"modo" gorm:"column:modo;default:'sandbox'"`
// Webhook ID para verificación de firmas
WebhookID string `json:"webhook_id" gorm:"column:webhook_id;type:text"`
// URLs de retorno
ReturnURL string `json:"return_url" gorm:"column:return_url;type:text"`
CancelURL string `json:"cancel_url" gorm:"column:cancel_url;type:text"`
// Nota interna
Nota string `json:"nota" gorm:"column:nota;type:text"`
Activo bool `json:"activo" gorm:"column:activo;default:true"`
}
func (PaypalConfig) TableName() string { return "paypal_config" }
// GetPaypalConfig retorna la configuración activa.
func GetPaypalConfig() (*PaypalConfig, error) {
var item PaypalConfig
if err := app.Http.Database.DB.Where("activo = ?", true).Order("id DESC").First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
// SavePaypalConfig desactiva la config previa y guarda la nueva (o actualiza si ya tiene ID).
func SavePaypalConfig(s PaypalConfig) error {
app.Http.Database.DB.Model(&PaypalConfig{}).
Where("activo = ?", true).
Update("activo", false)
s.Activo = true
if s.ID > 0 {
return app.Http.Database.DB.Model(&s).Updates(map[string]interface{}{
"client_id_sandbox": s.ClientIDSandbox,
"client_secret_sandbox": s.ClientSecretSandbox,
"client_id_prod": s.ClientIDProd,
"client_secret_prod": s.ClientSecretProd,
"modo": s.Modo,
"webhook_id": s.WebhookID,
"return_url": s.ReturnURL,
"cancel_url": s.CancelURL,
"nota": s.Nota,
"activo": true,
}).Error
}
return app.Http.Database.DB.Create(&s).Error
}
+261
View File
@@ -37,6 +37,12 @@
:class="dlocalModo === 'prod' ? 'bg-green-500' : 'bg-yellow-500'"></span>
dLocal — <span x-text="dlocalModo === 'prod' ? 'Producción' : 'Desarrollo'"></span>
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold flex items-center gap-1"
:class="paypalModo === 'live' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'">
<span class="w-2 h-2 rounded-full inline-block"
:class="paypalModo === 'live' ? 'bg-green-500' : 'bg-yellow-500'"></span>
PayPal — <span x-text="paypalModo === 'live' ? 'Producción' : 'Sandbox'"></span>
</span>
</div>
</div>
@@ -60,6 +66,15 @@
</svg>
dLocal
</button>
<button @click="activeTab = 'paypal'"
:class="activeTab === 'paypal' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-500 hover:text-gray-700'"
class="pb-2 transition-colors flex items-center gap-2">
<!-- PayPal icon -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.566-5.946.36-1.847.174-3.388-.778-4.471z"/>
</svg>
PayPal
</button>
</nav>
</div>
@@ -475,6 +490,196 @@
</div><!-- /TAB BOLD -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- TAB: PAYPAL -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div x-show="activeTab === 'paypal'" x-transition>
<div class="grid lg:grid-cols-2 gap-6">
<!-- Formulario de credenciales PayPal -->
<div class="border border-gray-200 rounded-xl p-5">
<h2 class="text-base font-semibold mb-1 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#003087]" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.566-5.946.36-1.847.174-3.388-.778-4.471z"/>
</svg>
Credenciales PayPal
</h2>
<p class="text-xs text-gray-500 mb-4">Panel PayPal → Apps &amp; Credentials → Create App</p>
<!-- Selector de entorno -->
<div class="mb-5">
<label class="block text-xs font-medium text-gray-600 mb-1.5">Entorno activo</label>
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" x-model="paypalModo" value="sandbox" class="accent-[#8eb02f]"/>
<span class="text-sm px-2 py-0.5 rounded bg-yellow-50 text-yellow-700 font-medium">Sandbox</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" x-model="paypalModo" value="live" class="accent-[#8eb02f]"/>
<span class="text-sm px-2 py-0.5 rounded bg-green-50 text-green-700 font-medium">Producción (Live)</span>
</label>
</div>
</div>
<!-- Sandbox -->
<div class="mb-5 p-4 rounded-lg bg-yellow-50 border border-yellow-200">
<p class="text-xs font-semibold text-yellow-700 mb-3 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
</svg>
Sandbox
</p>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Client ID (sandbox)</label>
<input x-model="paypal.client_id_sandbox" type="text" placeholder="AXxx..." class="ui-input text-xs font-mono" autocomplete="off"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Client Secret (sandbox)</label>
<input x-model="paypal.client_secret_sandbox" type="password" placeholder="EHxx..." class="ui-input text-xs font-mono" autocomplete="off"/>
</div>
</div>
</div>
<!-- Producción -->
<div class="mb-5 p-4 rounded-lg bg-green-50 border border-green-200">
<p class="text-xs font-semibold text-green-700 mb-3 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" 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>
Producción
</p>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Client ID (producción)</label>
<input x-model="paypal.client_id_prod" type="text" placeholder="AXxx..." class="ui-input text-xs font-mono" autocomplete="off"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Client Secret (producción)</label>
<input x-model="paypal.client_secret_prod" type="password" placeholder="EHxx..." class="ui-input text-xs font-mono" autocomplete="off"/>
</div>
</div>
</div>
<!-- Webhook ID -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">
Webhook ID
<span class="text-gray-400 font-normal">(para verificación de firma)</span>
</label>
<input x-model="paypal.webhook_id" type="text" placeholder="Ej: 8PT597110X687430N" class="ui-input text-xs font-mono" autocomplete="off"/>
</div>
<!-- URLs -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-600 mb-1">Return URL</label>
<input x-model="paypal.return_url" type="url" placeholder="https://tudominio.com/pago-exitoso" class="ui-input text-xs"/>
</div>
<div class="mb-5">
<label class="block text-xs font-medium text-gray-600 mb-1">Cancel URL</label>
<input x-model="paypal.cancel_url" type="url" placeholder="https://tudominio.com/pago-cancelado" class="ui-input text-xs"/>
</div>
<!-- Nota -->
<div class="mb-5">
<label class="block text-xs font-medium text-gray-600 mb-1">Nota interna</label>
<input x-model="paypal.nota" type="text" placeholder="Opcional" class="ui-input text-xs"/>
</div>
<button @click="savePaypal()"
class="w-full py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
style="background-color:#003087"
onmouseover="this.style.backgroundColor='#001f5c'"
onmouseout="this.style.backgroundColor='#003087'">
Guardar configuración PayPal
</button>
</div>
<!-- Info / Guía PayPal -->
<div class="space-y-4">
<!-- Estado actual -->
<div class="border border-gray-200 rounded-xl p-5">
<h3 class="text-sm font-semibold mb-3 text-gray-700">Estado actual</h3>
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">Entorno</span>
<span class="font-semibold"
:class="paypalModo === 'live' ? 'text-green-600' : 'text-yellow-600'"
x-text="paypalModo === 'live' ? 'Producción 🟢' : 'Sandbox 🟡'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Client ID (activo)</span>
<span class="font-mono text-gray-700"
x-text="paypalModo === 'live'
? (paypal.client_id_prod ? paypal.client_id_prod.slice(0,8)+'...' : '—')
: (paypal.client_id_sandbox ? paypal.client_id_sandbox.slice(0,8)+'...' : '—')"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Webhook ID</span>
<span class="font-mono text-gray-700 truncate max-w-[180px]" x-text="paypal.webhook_id || '—'"></span>
</div>
</div>
</div>
<!-- Webhook URL -->
<div class="border border-gray-200 rounded-xl p-5">
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2 text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-[#8eb02f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
URL del Webhook
</h3>
<p class="text-xs text-gray-500 mb-3">Registra esta URL en PayPal → Apps → Webhooks</p>
<div class="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<span class="text-xs font-mono text-gray-700 flex-1 truncate">/webhooks/paypal</span>
<button @click="copyPaypalWebhook()" title="Copiar URL"
class="text-[#8eb02f] hover:text-[#6d8c24] flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<!-- Eventos soportados -->
<div class="border border-gray-200 rounded-xl p-5">
<h3 class="text-sm font-semibold mb-3 text-gray-700">Eventos del webhook</h3>
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-mono bg-green-50 text-green-700 px-2 py-0.5 rounded">PAYMENT.CAPTURE.COMPLETED</span>
<span class="text-gray-500">Pago completado ✅</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-mono bg-red-50 text-red-600 px-2 py-0.5 rounded">PAYMENT.CAPTURE.DENIED</span>
<span class="text-gray-500">Pago denegado ❌</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-mono bg-orange-50 text-orange-600 px-2 py-0.5 rounded">PAYMENT.CAPTURE.REFUNDED</span>
<span class="text-gray-500">Devolución 🔄</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="font-mono bg-purple-50 text-purple-600 px-2 py-0.5 rounded">CUSTOMER.DISPUTE.CREATED</span>
<span class="text-gray-500">Disputa iniciada ⚠️</span>
</div>
</div>
</div>
<!-- Guía rápida -->
<div class="border border-gray-200 rounded-xl p-5">
<h3 class="text-sm font-semibold mb-3 text-gray-700">Guía rápida</h3>
<ol class="space-y-2 text-xs text-gray-600 list-decimal list-inside">
<li>Entra a <span class="font-mono">developer.paypal.com</span><strong>Apps &amp; Credentials</strong>.</li>
<li>Crea o selecciona una app y copia el <strong>Client ID</strong> y <strong>Secret</strong>.</li>
<li>En la misma app activa <strong>Webhooks</strong> y pega la URL de arriba.</li>
<li>Copia el <strong>Webhook ID</strong> generado y pégalo en este formulario.</li>
<li>Cuando estés listo para producción cambia a <strong>Live</strong> y repite con las credenciales de producción.</li>
</ol>
</div>
</div>
</div>
</div><!-- /TAB PAYPAL -->
<!-- Modal detalle de notificación webhook -->
<div x-show="showLogModal" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div @click.outside="showLogModal = false" class="bg-white rounded-2xl shadow-2xl w-full max-w-lg p-6 relative">
@@ -1044,6 +1249,20 @@ function pasarelasApp() {
validandoLogID: 0,
validandoCallbackID: 0,
// ─── PayPal ──────────────────────────────────────────────────────
paypalModo: 'sandbox',
paypal: {
id: 0,
client_id_sandbox: '',
client_secret_sandbox: '',
client_id_prod: '',
client_secret_prod: '',
webhook_id: '',
return_url: '',
cancel_url: '',
nota: '',
},
// ─── dLocal ─────────────────────────────────────────────────────
dlocalModo: 'dev',
dlocal: {
@@ -1093,6 +1312,7 @@ function pasarelasApp() {
init() {
this.loadBold();
this.loadDlocal();
this.loadPaypal();
// Carga perezosa: se carga cuando el usuario abre esos sub-tabs
this.loadBoldLogs();
},
@@ -1374,6 +1594,47 @@ function pasarelasApp() {
}
},
// ─── PayPal helpers ──────────────────────────────────────────────
async loadPaypal() {
try {
const r = await axios.get('/app/pasarelas/paypal/config');
if (r.data.data) {
const d = r.data.data;
this.paypal = {
id: d.ID || 0,
client_id_sandbox: d.client_id_sandbox || '',
client_secret_sandbox: d.client_secret_sandbox || '',
client_id_prod: d.client_id_prod || '',
client_secret_prod: d.client_secret_prod || '',
webhook_id: d.webhook_id || '',
return_url: d.return_url || '',
cancel_url: d.cancel_url || '',
nota: d.nota || '',
};
this.paypalModo = d.modo || 'sandbox';
}
} catch (_) {}
},
async savePaypal() {
this.loading = true;
try {
const payload = { ...this.paypal, modo: this.paypalModo };
await axios.post('/app/pasarelas/paypal/save', payload);
this.showToast('Configuración PayPal guardada ✓');
this.loadPaypal();
} catch (e) {
this.showToast(e.response?.data?.error || 'Error guardando PayPal', 'error');
} finally {
this.loading = false;
}
},
copyPaypalWebhook() {
const url = window.location.origin + '/webhooks/paypal';
navigator.clipboard.writeText(url).then(() => this.showToast('URL copiada: ' + url));
},
// ─── Toast ───────────────────────────────────────────────────────
showToast(msg, type = 'success') {
this.toast = { show: true, msg, type };
+55
View File
@@ -12,11 +12,13 @@ import (
func PasarelasPage(c *fiber.Ctx) error {
boldCfg, _ := models.GetBoldConfig()
dlocalCfg, _ := models.GetLastActiveDlocalApi()
paypalCfg, _ := models.GetPaypalConfig()
data := fiber.Map{
"Title": "Pasarelas de Pago",
"Bold": boldCfg,
"Dlocal": dlocalCfg,
"Paypal": paypalCfg,
"user": c.Locals("user"),
"modules": c.Locals("userModules"),
}
@@ -543,3 +545,56 @@ func ValidarBoldLog(c *fiber.Ctx) error {
"data": entry,
})
}
// ─── PayPal ───────────────────────────────────────────────────────────────────
// GetPaypalConfigAPI devuelve la configuración activa de PayPal.
func GetPaypalConfigAPI(c *fiber.Ctx) error {
cfg, err := models.GetPaypalConfig()
if err != nil {
return c.JSON(fiber.Map{"data": nil})
}
return c.JSON(fiber.Map{"data": cfg})
}
// SavePaypalConfigWeb guarda o actualiza la configuración de PayPal.
func SavePaypalConfigWeb(c *fiber.Ctx) error {
type body struct {
ID uint `json:"id" form:"id"`
ClientIDSandbox string `json:"client_id_sandbox" form:"client_id_sandbox"`
ClientSecretSandbox string `json:"client_secret_sandbox" form:"client_secret_sandbox"`
ClientIDProd string `json:"client_id_prod" form:"client_id_prod"`
ClientSecretProd string `json:"client_secret_prod" form:"client_secret_prod"`
Modo string `json:"modo" form:"modo"`
WebhookID string `json:"webhook_id" form:"webhook_id"`
ReturnURL string `json:"return_url" form:"return_url"`
CancelURL string `json:"cancel_url" form:"cancel_url"`
Nota string `json:"nota" form:"nota"`
}
var b body
if err := c.BodyParser(&b); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if b.Modo == "" {
b.Modo = "sandbox"
}
cfg := models.PaypalConfig{
ClientIDSandbox: b.ClientIDSandbox,
ClientSecretSandbox: b.ClientSecretSandbox,
ClientIDProd: b.ClientIDProd,
ClientSecretProd: b.ClientSecretProd,
Modo: b.Modo,
WebhookID: b.WebhookID,
ReturnURL: b.ReturnURL,
CancelURL: b.CancelURL,
Nota: b.Nota,
}
cfg.ID = b.ID
if err := models.SavePaypalConfig(cfg); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Configuración PayPal guardada"})
}
+3
View File
@@ -228,6 +228,9 @@ func UserRoutes(app fiber.Router) {
protected.Get("/pasarelas/dlocal/logs", controllers.DlocalPaymentLogsPaginated)
protected.Post("/pasarelas/dlocal/logs/:id/validar", controllers.ValidarDlocalLog)
protected.Post("/pasarelas/dlocal/registro-pago", apiControllers.DlocalRegistrarPago)
// PayPal
protected.Get("/pasarelas/paypal/config", controllers.GetPaypalConfigAPI)
protected.Post("/pasarelas/paypal/save", controllers.SavePaypalConfigWeb)
// Bold API (crear link, consultar estado)
protected.Post("/pasarelas/bold/crear-link", apiControllers.BoldCreatePaymentLink)
protected.Get("/pasarelas/bold/link/:linkID", apiControllers.BoldGetLinkStatus)