feat: selector de pasarela (Bold/dLocal/ninguna) en regla + muestra {{.EnlacePago}} en editor de plantillas
This commit is contained in:
@@ -7,12 +7,13 @@ import (
|
|||||||
|
|
||||||
type NotificacionRegla struct {
|
type NotificacionRegla struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Nombre string `json:"nombre" gorm:"column:nombre"`
|
Nombre string `json:"nombre" gorm:"column:nombre"`
|
||||||
DiasAntes int `json:"dias_antes" gorm:"column:dias_antes"`
|
DiasAntes int `json:"dias_antes" gorm:"column:dias_antes"`
|
||||||
PlantillaID uint `json:"plantilla_id" gorm:"column:plantilla_id"`
|
PlantillaID uint `json:"plantilla_id" gorm:"column:plantilla_id"`
|
||||||
Plantilla PlantillaCorreo `json:"plantilla" gorm:"foreignKey:PlantillaID"`
|
Plantilla PlantillaCorreo `json:"plantilla" gorm:"foreignKey:PlantillaID"`
|
||||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||||
AplicaA string `json:"aplica_a" gorm:"column:aplica_a;default:'todos'"` // todos | renovable | unico
|
AplicaA string `json:"aplica_a" gorm:"column:aplica_a;default:'todos'"` // todos | renovable | unico
|
||||||
|
PasarelaEnlace string `json:"pasarela_enlace" gorm:"column:pasarela_enlace;default:'bold'"` // bold | dlocal | ninguna
|
||||||
}
|
}
|
||||||
|
|
||||||
func (NotificacionRegla) TableName() string { return "notificacion_reglas" }
|
func (NotificacionRegla) TableName() string { return "notificacion_reglas" }
|
||||||
@@ -48,11 +49,12 @@ func CreateRegla(r NotificacionRegla) error {
|
|||||||
|
|
||||||
func UpdateRegla(r NotificacionRegla) error {
|
func UpdateRegla(r NotificacionRegla) error {
|
||||||
return app.Http.Database.DB.Model(&r).Updates(map[string]interface{}{
|
return app.Http.Database.DB.Model(&r).Updates(map[string]interface{}{
|
||||||
"nombre": r.Nombre,
|
"nombre": r.Nombre,
|
||||||
"dias_antes": r.DiasAntes,
|
"dias_antes": r.DiasAntes,
|
||||||
"plantilla_id": r.PlantillaID,
|
"plantilla_id": r.PlantillaID,
|
||||||
"activo": r.Activo,
|
"activo": r.Activo,
|
||||||
"aplica_a": r.AplicaA,
|
"aplica_a": r.AplicaA,
|
||||||
|
"pasarela_enlace": r.PasarelaEnlace,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func EnviarNotificacionGrupo(regla *models.NotificacionRegla, cliente *models.Cl
|
|||||||
// Generar o reutilizar el enlace de pago para el primer contrato del grupo
|
// Generar o reutilizar el enlace de pago para el primer contrato del grupo
|
||||||
enlacePago := ""
|
enlacePago := ""
|
||||||
if len(contratos) > 0 {
|
if len(contratos) > 0 {
|
||||||
enlacePago = ObtenerOCrearEnlacePago(&contratos[0])
|
enlacePago = ObtenerOCrearEnlacePago(&contratos[0], regla.PasarelaEnlace)
|
||||||
}
|
}
|
||||||
|
|
||||||
datos := DatosPlantilla{
|
datos := DatosPlantilla{
|
||||||
@@ -221,24 +221,40 @@ func ReenviarLog(logEntry *models.NotificacionLog) error {
|
|||||||
return sendErr
|
return sendErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObtenerOCrearEnlacePago devuelve el enlace Bold vigente del contrato.
|
// ObtenerOCrearEnlacePago devuelve el enlace de pago vigente del contrato.
|
||||||
// Si ya existe uno (no ha sido pagado aún), lo reutiliza.
|
// gateway puede ser "bold", "dlocal" o "ninguna"/"".
|
||||||
// Si no existe, crea uno nuevo en Bold y lo guarda en el contrato.
|
// Si ya existe un enlace guardado, lo reutiliza (mismo ciclo).
|
||||||
// Cuando Bold notifica SALE_APPROVED, llamar models.LimpiarEnlacePago(contratoID)
|
// Cuando el pago se aprueba, llamar models.LimpiarEnlacePago para el siguiente ciclo.
|
||||||
// para que la próxima notificación genere un link nuevo.
|
func ObtenerOCrearEnlacePago(contrato *models.Contrato, gateway string) string {
|
||||||
func ObtenerOCrearEnlacePago(contrato *models.Contrato) string {
|
// Normalizar gateway vacío → bold por defecto
|
||||||
|
if gateway == "" {
|
||||||
|
gateway = "bold"
|
||||||
|
}
|
||||||
|
if gateway == "ninguna" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Reutilizar si ya hay uno vigente
|
// Reutilizar si ya hay uno vigente
|
||||||
if contrato.EnlacePago != "" {
|
if contrato.EnlacePago != "" {
|
||||||
return contrato.EnlacePago
|
return contrato.EnlacePago
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch gateway {
|
||||||
|
case "dlocal":
|
||||||
|
return obtenerEnlaceDlocal(contrato)
|
||||||
|
default: // "bold"
|
||||||
|
return obtenerEnlaceBold(contrato)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// obtenerEnlaceBold crea un payment link en Bold y lo persiste en el contrato.
|
||||||
|
func obtenerEnlaceBold(contrato *models.Contrato) string {
|
||||||
cfg, err := models.GetBoldConfig()
|
cfg, err := models.GetBoldConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[BOLD] ObtenerOCrearEnlacePago: sin config activa para contrato %d", contrato.ID)
|
log.Printf("[BOLD] sin config activa para contrato %d", contrato.ID)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nombre de servicios en la descripción
|
|
||||||
desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID)
|
desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID)
|
||||||
if len(contrato.Servicios) > 0 {
|
if len(contrato.Servicios) > 0 {
|
||||||
desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre)
|
desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre)
|
||||||
@@ -257,13 +273,55 @@ func ObtenerOCrearEnlacePago(contrato *models.Contrato) string {
|
|||||||
|
|
||||||
result, err := CreateBoldPaymentLink(cfg, req)
|
result, err := CreateBoldPaymentLink(cfg, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[BOLD] ObtenerOCrearEnlacePago: error creando link para contrato %d: %v", contrato.ID, err)
|
log.Printf("[BOLD] error creando link para contrato %d: %v", contrato.ID, err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistir en BD
|
|
||||||
_ = models.GuardarEnlacePago(contrato.ID, result.Payload.PaymentLink, result.Payload.URL)
|
_ = models.GuardarEnlacePago(contrato.ID, result.Payload.PaymentLink, result.Payload.URL)
|
||||||
contrato.EnlacePago = result.Payload.URL
|
contrato.EnlacePago = result.Payload.URL
|
||||||
contrato.EnlacePagoLinkID = result.Payload.PaymentLink
|
contrato.EnlacePagoLinkID = result.Payload.PaymentLink
|
||||||
return result.Payload.URL
|
return result.Payload.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// obtenerEnlaceDlocal crea un pago en dLocal y devuelve la URL de redirección.
|
||||||
|
func obtenerEnlaceDlocal(contrato *models.Contrato) string {
|
||||||
|
cfg, err := models.GetLastActiveDlocalApi()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DLOCAL] sin config activa para contrato %d", contrato.ID)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := fmt.Sprintf("Renovación contrato #%d", contrato.ID)
|
||||||
|
if len(contrato.Servicios) > 0 {
|
||||||
|
desc = fmt.Sprintf("Renovación: %s", contrato.Servicios[0].Nombre)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagoReq := PagoRequest{
|
||||||
|
Currency: "COP",
|
||||||
|
Amount: contrato.PrecioAcordado,
|
||||||
|
OrderID: int(contrato.ID),
|
||||||
|
Description: desc,
|
||||||
|
SuccessURL: cfg.UrlProd + "/pago-exitoso",
|
||||||
|
BackURL: cfg.UrlProd + "/pago-cancelado",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := CreatePago(*cfg, pagoReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DLOCAL] error creando pago para contrato %d: %v", contrato.ID, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil || resp.RedirectURL == "" {
|
||||||
|
log.Printf("[DLOCAL] respuesta sin redirect_url para contrato %d: %s", contrato.ID, string(body))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = models.GuardarEnlacePago(contrato.ID, resp.ID, resp.RedirectURL)
|
||||||
|
contrato.EnlacePago = resp.RedirectURL
|
||||||
|
contrato.EnlacePagoLinkID = resp.ID
|
||||||
|
return resp.RedirectURL
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,9 @@
|
|||||||
<code>{{.FechaVencimiento}}</code>,
|
<code>{{.FechaVencimiento}}</code>,
|
||||||
<code>{{.DiasRestantes}}</code>,
|
<code>{{.DiasRestantes}}</code>,
|
||||||
<code>{{.Total}}</code>,
|
<code>{{.Total}}</code>,
|
||||||
<code>{{range .Servicios}}...{{.Nombre}} {{.Precio}}...{{end}}</code>
|
<code>{{range .Servicios}}...{{.Nombre}} {{.Precio}}...{{end}}</code>,
|
||||||
|
<code>{{.EnlacePago}}</code>
|
||||||
|
<span class="ml-1 text-blue-500">(URL de pago generada automáticamente por Bold o dLocal al enviar)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="save()">
|
<form @submit.prevent="save()">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<th class="py-2 px-3">Días antes</th>
|
<th class="py-2 px-3">Días antes</th>
|
||||||
<th class="py-2 px-3">Plantilla</th>
|
<th class="py-2 px-3">Plantilla</th>
|
||||||
<th class="py-2 px-3">Aplica a</th>
|
<th class="py-2 px-3">Aplica a</th>
|
||||||
|
<th class="py-2 px-3">Pasarela</th>
|
||||||
<th class="py-2 px-3">Estado</th>
|
<th class="py-2 px-3">Estado</th>
|
||||||
<th class="py-2 px-3 w-24"></th>
|
<th class="py-2 px-3 w-24"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -35,6 +36,11 @@
|
|||||||
<td class="py-2 px-3">
|
<td class="py-2 px-3">
|
||||||
<span class="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600" x-text="d.aplica_a"></span>
|
<span class="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600" x-text="d.aplica_a"></span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
:class="d.pasarela_enlace==='dlocal' ? 'bg-blue-100 text-blue-700' : d.pasarela_enlace==='ninguna' ? 'bg-gray-100 text-gray-500' : 'bg-green-100 text-green-700'"
|
||||||
|
x-text="d.pasarela_enlace==='dlocal' ? 'dLocal' : d.pasarela_enlace==='ninguna' ? 'Ninguna' : 'Bold'"></span>
|
||||||
|
</td>
|
||||||
<td class="py-2 px-3">
|
<td class="py-2 px-3">
|
||||||
<button @click="toggleActivo(d)"
|
<button @click="toggleActivo(d)"
|
||||||
:class="d.activo ? 'bg-green-500' : 'bg-gray-300'"
|
:class="d.activo ? 'bg-green-500' : 'bg-gray-300'"
|
||||||
@@ -86,6 +92,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-600">Pasarela de pago (enlace)</label>
|
||||||
|
<select x-model="form.pasarela_enlace" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
|
<option value="bold">Bold (Colombia)</option>
|
||||||
|
<option value="dlocal">dLocal</option>
|
||||||
|
<option value="ninguna">Ninguna (sin enlace de pago)</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Genera automáticamente <code>{{.EnlacePago}}</code> al enviar</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs font-medium text-gray-600">Aplica a</label>
|
<label class="text-xs font-medium text-gray-600">Aplica a</label>
|
||||||
<select x-model="form.aplica_a" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
<select x-model="form.aplica_a" class="mt-1 w-full border rounded px-3 py-2 text-sm">
|
||||||
@@ -133,7 +148,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
loading: false, datos: [], plantillas: [],
|
loading: false, datos: [], plantillas: [],
|
||||||
addModal: false, editModal: false, deleteModal: false,
|
addModal: false, editModal: false, deleteModal: false,
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
form: { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', activo:true },
|
form: { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true },
|
||||||
toast: { show:false, msg:'', type:'ok' },
|
toast: { show:false, msg:'', type:'ok' },
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -151,7 +166,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openEdit(d) {
|
openEdit(d) {
|
||||||
this.form = { nombre: d.nombre, dias_antes: d.dias_antes, plantilla_id: d.plantilla_id, aplica_a: d.aplica_a, activo: d.activo };
|
this.form = { nombre: d.nombre, dias_antes: d.dias_antes, plantilla_id: d.plantilla_id, aplica_a: d.aplica_a, pasarela_enlace: d.pasarela_enlace||'bold', activo: d.activo };
|
||||||
this.selectedId = d.ID;
|
this.selectedId = d.ID;
|
||||||
this.editModal = true;
|
this.editModal = true;
|
||||||
},
|
},
|
||||||
@@ -159,7 +174,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
closeModals() {
|
closeModals() {
|
||||||
this.addModal = this.editModal = this.deleteModal = false;
|
this.addModal = this.editModal = this.deleteModal = false;
|
||||||
this.selectedId = null;
|
this.selectedId = null;
|
||||||
this.form = { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', activo:true };
|
this.form = { nombre:'', dias_antes:30, plantilla_id:'', aplica_a:'todos', pasarela_enlace:'bold', activo:true };
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleActivo(d) {
|
async toggleActivo(d) {
|
||||||
|
|||||||
Reference in New Issue
Block a user