This commit is contained in:
Lizandro Guarnizo
2026-05-06 15:44:51 -05:00
parent 1a8cc92a22
commit 048ebf5d70
4 changed files with 365 additions and 30 deletions
+1
View File
@@ -66,6 +66,7 @@ func main() {
migrations.SeedRenovaciones()
migrations.SeedIntegraciones()
migrations.SeedPasarelas()
migrations.SeedSaas()
migrations.SeedServidores()
migrations.SeedAdministracion()
// Iniciar cron de vencimientos
+58
View File
@@ -95,6 +95,9 @@ func Migrate() {
// Insertar submódulo "Pasarelas de Pago" en el módulo Integraciones
SeedPasarelas()
// Insertar submódulos de SaaS (Productos + Integraciones) en el módulo Integraciones
SeedSaas()
log.Println("Migration Completed...")
}
@@ -476,3 +479,58 @@ func SeedAdministracion() {
log.Println("[SEED] Seed de Administración completado.")
}
// SeedSaas agrega los submódulos de gestión de productos SaaS e integraciones
// de pago al módulo "Integraciones" ya existente. Es idempotente.
func SeedSaas() {
db := app.Http.Database.DB
// Reutilizar el módulo "Integraciones" (creado por SeedIntegraciones)
var modulo models.Modules
if err := db.Where("title = ?", "Integraciones").First(&modulo).Error; err != nil {
log.Printf("[SEED] Módulo 'Integraciones' no encontrado para SeedSaas: %v", err)
return
}
entries := []struct{ title, desc, url string }{
{"Productos SaaS", "Gestión de productos SaaS y su documentación asociada", "/app/saas"},
{"Integraciones SaaS", "Callbacks de pago hacia APIs externas y logs de despacho", "/app/saas-api"},
}
var insertados []models.Submodules
for _, e := range entries {
var sub models.Submodules
if err := db.Where("url = ?", e.url).First(&sub).Error; err != nil {
sub = models.Submodules{
Title: e.title,
Description: e.desc,
Url: e.url,
ModuleId: modulo.ID,
ModifiedAt: time.Now(),
}
if err := db.Create(&sub).Error; err != nil {
log.Printf("[SEED] Error creando submódulo '%s': %v", e.title, err)
continue
}
log.Printf("[SEED] Submódulo '%s' creado (ID %d)", e.title, sub.ID)
} else {
log.Printf("[SEED] Submódulo '%s' ya existe (ID %d)", sub.Title, sub.ID)
}
insertados = append(insertados, sub)
}
var roles []models.Roles
if err := db.Find(&roles).Error; err != nil {
log.Printf("[SEED] Error obteniendo roles: %v", err)
return
}
for _, rol := range roles {
if err := db.Model(&rol).Association("Submodules").Append(&insertados); err != nil {
log.Printf("[SEED] Error asignando submódulos SaaS al rol '%s': %v", rol.Name, err)
} else {
log.Printf("[SEED] Submódulos SaaS asignados al rol '%s'", rol.Name)
}
}
log.Println("[SEED] Seed de SaaS completado.")
}
+272 -18
View File
@@ -530,6 +530,20 @@
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div x-show="activeTab === 'dlocal'" x-transition>
<!-- Sub-tabs dLocal -->
<div class="border-b border-gray-100 mb-5">
<nav class="-mb-px flex gap-5 text-xs">
<button @click="dlocalTab = 'config'"
:class="dlocalTab==='config' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
class="pb-2 transition-colors">Configuración</button>
<button @click="dlocalTab = 'planes'; loadDlocalPlanes()"
:class="dlocalTab==='planes' ? 'border-b-2 border-[#8eb02f] text-[#6d8c24] font-semibold' : 'text-gray-400 hover:text-gray-600'"
class="pb-2 transition-colors">Planes de suscripción</button>
</nav>
</div>
<!-- ── Sub-tab: Configuración ─────────────────────────────────── -->
<div x-show="dlocalTab === 'config'">
<div class="grid lg:grid-cols-2 gap-6">
<!-- Formulario dLocal -->
@@ -635,28 +649,167 @@
</div>
<div class="border border-gray-200 rounded-xl p-5">
<h3 class="text-sm font-semibold mb-2 text-gray-700">Endpoints disponibles</h3>
<div class="space-y-1.5 text-xs">
<div class="flex items-center gap-2">
<span class="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-mono text-[10px]">POST</span>
<span class="text-gray-600">/v1/dlocal/subscription/crear-plan</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-green-50 text-green-600 px-1.5 py-0.5 rounded font-mono text-[10px]">GET</span>
<span class="text-gray-600">/v1/dlocal/subscription/ver-plan/:id</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-yellow-50 text-yellow-600 px-1.5 py-0.5 rounded font-mono text-[10px]">PATCH</span>
<span class="text-gray-600">/v1/dlocal/subscription/actualizar-plan/:id</span>
</div>
<div class="flex items-center gap-2">
<span class="bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded font-mono text-[10px]">POST</span>
<span class="text-gray-600">/v1/dlocal/payment/crear-pago</span>
</div>
<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 el panel dLocal → Integraciones → Webhook</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/dlocal</span>
<button @click="copyDlocalWebhook()" 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>
</div>
</div>
</div><!-- /sub-tab config -->
<!-- ── Sub-tab: Planes de suscripción ─────────────────────────── -->
<div x-show="dlocalTab === 'planes'">
<div class="flex justify-between items-center mb-4">
<p class="text-xs text-gray-500">Planes almacenados en dLocal directamente. Los cambios se reflejan en tiempo real.</p>
<button @click="openPlanAdd()" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm">+ Nuevo plan</button>
</div>
<!-- Loading planes -->
<div x-show="dlocalPlanesLoading" class="py-8 text-center text-gray-400 text-sm">Consultando planes en dLocal…</div>
<div x-show="dlocalPlanesError" class="py-4 px-4 bg-red-50 text-red-600 rounded text-sm" x-text="dlocalPlanesError"></div>
<!-- Tabla de planes -->
<div x-show="!dlocalPlanesLoading && !dlocalPlanesError" class="border border-gray-200 rounded-xl overflow-hidden">
<div x-show="dlocalPlanes.length === 0" class="py-12 text-center text-gray-400 text-sm">
Sin planes creados en dLocal aún.
</div>
<div x-show="dlocalPlanes.length > 0">
<table class="w-full text-xs table-auto">
<thead>
<tr class="bg-gray-50 text-left border-b">
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">ID</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Nombre</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Monto</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Frecuencia</th>
<th class="py-3 px-4 font-semibold text-gray-500 uppercase tracking-wide text-[10px]">Estado</th>
<th class="py-3 px-4"></th>
</tr>
</thead>
<tbody>
<template x-for="plan in dlocalPlanes" :key="plan.id">
<tr class="border-b last:border-0 hover:bg-gray-50 transition-colors">
<td class="py-3 px-4 font-mono text-gray-400 text-[10px]" x-text="(plan.id||'—').slice(0,12)+'…'"></td>
<td class="py-3 px-4">
<div class="font-medium text-gray-800" x-text="plan.name || '—'"></div>
<div class="text-gray-400 text-[10px] mt-0.5" x-text="plan.description || ''"></div>
</td>
<td class="py-3 px-4 font-semibold text-gray-800">
<span x-text="plan.currency + ' ' + (plan.amount || 0).toLocaleString('es-CO')"></span>
</td>
<td class="py-3 px-4 text-gray-600">
<span x-text="plan.frequency_type || '—'"></span>
<span x-show="plan.frequency_value" x-text="' x' + plan.frequency_value" class="text-gray-400"></span>
<span x-show="plan.day_of_month" x-text="' (día ' + plan.day_of_month + ')'" class="text-gray-400"></span>
</td>
<td class="py-3 px-4">
<span x-text="plan.status || 'active'"
:class="(plan.status === 'inactive' || plan.status === 'INACTIVE') ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-700'"
class="px-2 py-0.5 rounded-full text-[10px] font-semibold capitalize"></span>
</td>
<td class="py-3 px-4">
<button @click="openPlanEdit(plan)" title="Editar" class="text-blue-500 hover:text-blue-700 mr-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Modal crear / editar plan -->
<div x-show="showPlanModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" style="display:none">
<div class="bg-white rounded-lg shadow-xl w-full max-w-xl mx-4 max-h-screen overflow-y-auto">
<div class="flex justify-between items-center p-5 border-b">
<h2 class="text-lg font-semibold" x-text="planEditMode ? 'Editar plan dLocal' : 'Nuevo plan dLocal'"></h2>
<button @click="showPlanModal = false" class="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div class="p-5 space-y-3">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Nombre <span class="text-red-500">*</span></label>
<input type="text" x-model="planForm.name" class="border rounded w-full p-2 text-sm" placeholder="Ej: Plan mensual VCard">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">Descripción</label>
<input type="text" x-model="planForm.description" class="border rounded w-full p-2 text-sm" placeholder="Descripción breve">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Moneda <span class="text-red-500">*</span></label>
<input type="text" x-model="planForm.currency" class="border rounded w-full p-2 text-sm font-mono" placeholder="COP / USD">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Monto <span class="text-red-500">*</span></label>
<input type="number" x-model.number="planForm.amount" class="border rounded w-full p-2 text-sm" placeholder="50000">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Tipo de frecuencia <span class="text-red-500">*</span></label>
<select x-model="planForm.frequency_type" class="border rounded w-full p-2 text-sm">
<option value="MONTHLY">Mensual</option>
<option value="WEEKLY">Semanal</option>
<option value="DAILY">Diario</option>
<option value="YEARLY">Anual</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Valor frecuencia <span class="text-gray-400 font-normal">(ej: 1)</span></label>
<input type="number" x-model.number="planForm.frequency_value" class="border rounded w-full p-2 text-sm" min="1">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Día del mes <span class="text-gray-400 font-normal">(opcional)</span></label>
<input type="number" x-model.number="planForm.day_of_month" class="border rounded w-full p-2 text-sm" min="1" max="31">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">País <span class="text-gray-400 font-normal">(opcional)</span></label>
<input type="text" x-model="planForm.country" class="border rounded w-full p-2 text-sm font-mono" placeholder="CO">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">URL de notificación (webhook)</label>
<input type="url" x-model="planForm.notification_url" class="border rounded w-full p-2 text-sm font-mono" placeholder="https://…/webhooks/dlocal">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">URL éxito</label>
<input type="url" x-model="planForm.success_url" class="border rounded w-full p-2 text-sm font-mono">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">URL retorno</label>
<input type="url" x-model="planForm.back_url" class="border rounded w-full p-2 text-sm font-mono">
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-700 mb-1">URL error</label>
<input type="url" x-model="planForm.error_url" class="border rounded w-full p-2 text-sm font-mono">
</div>
</div>
<p x-show="planError" x-text="planError" class="text-red-500 text-sm"></p>
</div>
<div class="flex justify-end gap-3 p-5 border-t">
<button @click="showPlanModal = false" class="px-4 py-2 border rounded text-sm">Cancelar</button>
<button @click="savePlan()" :disabled="planSaving" class="bg-[#8eb02f] text-white px-4 py-2 rounded text-sm disabled:opacity-50">
<span x-show="!planSaving">Guardar en dLocal</span>
<span x-show="planSaving">Guardando…</span>
</button>
</div>
</div>
</div>
</div><!-- /sub-tab planes -->
</div>
</div><!-- /container -->
@@ -723,6 +876,23 @@ function pasarelasApp() {
url_dev: '',
},
// Sub-tabs dLocal
dlocalTab: 'config',
// Planes dLocal
dlocalPlanes: [],
dlocalPlanesLoading: false,
dlocalPlanesError: '',
showPlanModal: false,
planEditMode: false,
planSaving: false,
planError: '',
planEditID: '',
planForm: {},
defaultPlanForm() {
return { name:'', description:'', currency:'COP', amount:0, frequency_type:'MONTHLY', frequency_value:1, day_of_month:0, country:'CO', notification_url:'', success_url:'', back_url:'', error_url:'' };
},
// ─── Init ───────────────────────────────────────────────────────
init() {
this.loadBold();
@@ -849,6 +1019,90 @@ function pasarelasApp() {
}
},
async loadDlocalPlanes() {
this.dlocalPlanesLoading = true;
this.dlocalPlanesError = '';
try {
const r = await axios.get('/app/dlocal/planes');
// dLocal devuelve { message, response } donde response es el JSON raw
const raw = r.data.response;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
// La API de dLocal devuelve { plans: [...] } o directamente un array
this.dlocalPlanes = Array.isArray(parsed) ? parsed : (parsed.plans || parsed.data || []);
} catch (e) {
this.dlocalPlanesError = e.response?.data?.error || 'No se pudo obtener los planes de dLocal.';
} finally {
this.dlocalPlanesLoading = false;
}
},
openPlanAdd() {
this.planEditMode = false;
this.planEditID = '';
this.planForm = this.defaultPlanForm();
this.planError = '';
this.showPlanModal = true;
},
openPlanEdit(plan) {
this.planEditMode = true;
this.planEditID = plan.id;
this.planForm = {
name: plan.name || '',
description: plan.description || '',
currency: plan.currency || 'COP',
amount: plan.amount || 0,
frequency_type: plan.frequency_type || 'MONTHLY',
frequency_value: plan.frequency_value || 1,
day_of_month: plan.day_of_month || 0,
country: plan.country || '',
notification_url: plan.notification_url || '',
success_url: plan.success_url || '',
back_url: plan.back_url || '',
error_url: plan.error_url || '',
};
this.planError = '';
this.showPlanModal = true;
},
async savePlan() {
this.planError = '';
if (!this.planForm.name) { this.planError = 'El nombre es requerido.'; return; }
if (!this.planForm.currency) { this.planError = 'La moneda es requerida.'; return; }
if (!this.planForm.amount) { this.planError = 'El monto es requerido.'; return; }
this.planSaving = true;
try {
if (this.planEditMode) {
// Solo campos editables en PATCH
const patch = {
name: this.planForm.name,
description: this.planForm.description,
amount: this.planForm.amount,
notification_url: this.planForm.notification_url,
back_url: this.planForm.back_url,
success_url: this.planForm.success_url,
error_url: this.planForm.error_url,
};
await axios.patch(`/app/dlocal/planes/${this.planEditID}`, patch);
this.showToast('Plan actualizado en dLocal ✓');
} else {
await axios.post('/app/dlocal/planes', this.planForm);
this.showToast('Plan creado en dLocal ✓');
}
this.showPlanModal = false;
await this.loadDlocalPlanes();
} catch (e) {
this.planError = e.response?.data?.error || 'Error al guardar el plan.';
} finally {
this.planSaving = false;
}
},
copyDlocalWebhook() {
const url = window.location.origin + '/webhooks/dlocal';
navigator.clipboard.writeText(url).then(() => this.showToast('URL copiada: ' + url));
},
// ─── Toast ───────────────────────────────────────────────────────
showToast(msg, type = 'success') {
this.toast = { show: true, msg, type };
+34 -12
View File
@@ -19,6 +19,7 @@
<tr>
<th class="py-2 px-4 border-b">Nombre</th>
<th class="py-2 px-4 border-b">SaaS</th>
<th class="py-2 px-4 border-b">Pasarela</th>
<th class="py-2 px-4 border-b">Endpoint</th>
<th class="py-2 px-4 border-b">Método</th>
<th class="py-2 px-4 border-b">Estado</th>
@@ -27,12 +28,21 @@
</thead>
<tbody class="text-gray-500">
<template x-if="items.length === 0 && !loading">
<tr><td colspan="6" class="py-4 text-center text-gray-400">Sin registros</td></tr>
<tr><td colspan="7" class="py-4 text-center text-gray-400">Sin registros</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-4 font-medium text-gray-800" x-text="item.nombre"></td>
<td class="py-2 px-4 text-xs text-gray-600" x-text="item.SaasProducto ? item.SaasProducto.nombre : '-'"></td>
<td class="py-2 px-4">
<span x-text="item.pasarela || 'ambas'"
:class="{
'bg-purple-100 text-purple-700': item.pasarela === 'dlocal',
'bg-orange-100 text-orange-700': item.pasarela === 'bold',
'bg-gray-100 text-gray-600': !item.pasarela || item.pasarela === 'ambas'
}"
class="text-xs px-2 py-0.5 rounded-full capitalize"></span>
</td>
<td class="py-2 px-4">
<span class="font-mono text-xs text-blue-700 break-all" x-text="item.endpoint_url"></span>
</td>
@@ -86,16 +96,27 @@
<label class="block text-sm font-medium text-gray-700 mb-1">Nombre / etiqueta <span class="text-red-500">*</span></label>
<input type="text" x-model="form.nombre" class="border rounded w-full p-2 text-sm" placeholder="Ej: VCard confirmar pago">
</div>
<!-- SaaS -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Producto SaaS <span class="text-red-500">*</span></label>
<select x-model.number="form.saas_id" class="border rounded w-full p-2 text-sm">
<option value="">— Seleccionar —</option>
<template x-for="s in saasOpts" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
</template>
</select>
<p class="text-xs text-gray-400 mt-0.5">El SaaS debe tener un Servicio vinculado para que se active al confirmar contratos.</p>
<!-- SaaS + Pasarela -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Producto SaaS <span class="text-red-500">*</span></label>
<select x-model.number="form.saas_id" class="border rounded w-full p-2 text-sm">
<option value="">— Seleccionar —</option>
<template x-for="s in saasOpts" :key="s.ID">
<option :value="s.ID" x-text="s.nombre"></option>
</template>
</select>
<p class="text-xs text-gray-400 mt-0.5">Debe tener un Servicio vinculado para activarse al confirmar contratos.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Pasarela de pago</label>
<select x-model="form.pasarela" class="border rounded w-full p-2 text-sm">
<option value="ambas">Ambas (Bold + dLocal)</option>
<option value="dlocal">Solo dLocal</option>
<option value="bold">Solo Bold</option>
</select>
<p class="text-xs text-gray-400 mt-0.5">Qué pasarela dispara esta notificación.</p>
</div>
</div>
<!-- Endpoint URL -->
<div>
@@ -170,7 +191,7 @@ function saasApiApp() {
saving: false, error: '',
form: {},
defaultForm() {
return { saas_id: 0, nombre: '', endpoint_url: '', metodo: 'POST', api_key_header: '', api_key_value: '', payload_template: '', timeout_seg: 10, activo: true };
return { saas_id: 0, nombre: '', pasarela: 'ambas', endpoint_url: '', metodo: 'POST', api_key_header: '', api_key_value: '', payload_template: '', timeout_seg: 10, activo: true };
},
async init() { await this.load(1); },
async load(p) {
@@ -197,6 +218,7 @@ function saasApiApp() {
id: item.ID,
saas_id: item.saas_id,
nombre: item.nombre,
pasarela: item.pasarela || 'ambas',
endpoint_url: item.endpoint_url,
metodo: item.metodo || 'POST',
api_key_header: item.api_key_header || '',