replace cta with social_media question; add SocialMedia field to landing_session
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
# Bold — Solicitar y consumir enlaces de pago
|
||||||
|
|
||||||
|
## Requisitos previos
|
||||||
|
|
||||||
|
Tener configurada la pasarela Bold con una API key activa. Se gestiona desde:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /pasarelas/bold/save
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Crear un enlace de pago
|
||||||
|
|
||||||
|
**Endpoint:** `POST /pasarelas/bold/crear-link`
|
||||||
|
**Autenticación:** requerida (sesión o token)
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /pasarelas/bold/crear-link
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_amount": 50000,
|
||||||
|
"description": "Renovación plan básico",
|
||||||
|
"reference": "REF-UNICA-001",
|
||||||
|
"payer_email": "cliente@correo.com",
|
||||||
|
"callback_url": "https://tudominio.com/pago-resultado"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Campo | Tipo | Requerido | Descripción |
|
||||||
|
|----------------|--------|-----------|-----------------------------------------------------|
|
||||||
|
| `total_amount` | int64 | ✅ | Monto en COP sin centavos (ej. `50000` = $50.000) |
|
||||||
|
| `description` | string | ✅ | Descripción visible en el checkout Bold |
|
||||||
|
| `reference` | string | ✅ | Referencia única por pago (usada para identificarlo)|
|
||||||
|
| `payer_email` | string | ❌ | Email del pagador (prellenado en el checkout) |
|
||||||
|
| `callback_url` | string | ❌ | URL de redirección tras el pago (override del config)|
|
||||||
|
|
||||||
|
### Response `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_link": "pl_abc123xyz",
|
||||||
|
"url": "https://checkout.bold.co/payment/pl_abc123xyz"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url` → redirige al usuario a esta URL para que complete el pago.
|
||||||
|
- `payment_link` → ID del link, guárdalo para consultar su estado después.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Consultar el estado de un enlace
|
||||||
|
|
||||||
|
**Endpoint:** `GET /pasarelas/bold/link/:linkID`
|
||||||
|
**Autenticación:** requerida
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /pasarelas/bold/link/pl_abc123xyz
|
||||||
|
Authorization: <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response `200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": "{ ...payload crudo de la API de Bold... }"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Webhook (notificación automática de Bold)
|
||||||
|
|
||||||
|
Bold notifica los pagos a:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /webhooks/bold
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta ruta es **pública** (sin autenticación). Bold la llama automáticamente al completarse un pago. El sistema valida la firma HMAC-SHA256 y registra el evento en `bold_webhook_log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Usar el servicio directamente en Go
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := models.GetBoldConfig()
|
||||||
|
if err != nil {
|
||||||
|
// sin configuración activa
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := services.CreateBoldPaymentLink(cfg, services.BoldPaymentLinkRequest{
|
||||||
|
AmountType: "CLOSE",
|
||||||
|
Amount: services.BoldAmountField{
|
||||||
|
Currency: "COP",
|
||||||
|
TotalAmount: 50000,
|
||||||
|
},
|
||||||
|
Description: "Renovación plan X",
|
||||||
|
Reference: "REF-UNICA-001",
|
||||||
|
PayerEmail: "cliente@correo.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// error de la API Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutURL := result.Payload.URL
|
||||||
|
linkID := result.Payload.PaymentLink
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Polling automático
|
||||||
|
|
||||||
|
El sistema verifica pagos pendientes cada **15 minutos** mediante un cron job (`VerificarPagosBoldPendientes`). Esto cubre los casos donde el webhook no llega.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- `reference` debe ser **única por transacción**. Reutilizarla puede causar conflictos al resolver el pago.
|
||||||
|
- El modo activo (`test` / `production`) y las API keys se configuran en la tabla `bold_config`. Solo un registro puede estar activo a la vez.
|
||||||
|
- En modo `test` la `secret_key` es cadena vacía según la documentación oficial de Bold.
|
||||||
@@ -12,13 +12,14 @@ import (
|
|||||||
type LandingSession struct {
|
type LandingSession struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
Token string `json:"token" gorm:"column:token;uniqueIndex;not null"`
|
||||||
UserName string `json:"user_name" gorm:"column:user_name"`
|
UserName string `json:"user_name" gorm:"column:user_name"`
|
||||||
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
UserEmail string `json:"user_email" gorm:"column:user_email"`
|
||||||
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
UserPhone string `json:"user_phone" gorm:"column:user_phone"`
|
||||||
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
UserCompany string `json:"user_company" gorm:"column:user_company"`
|
||||||
UserJob string `json:"user_job" gorm:"column:user_job"`
|
UserJob string `json:"user_job" gorm:"column:user_job"`
|
||||||
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
UserWebsite string `json:"user_website" gorm:"column:user_website"`
|
||||||
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
UserAddress string `json:"user_address" gorm:"column:user_address"`
|
||||||
|
SocialMedia string `json:"social_media" gorm:"column:social_media"`
|
||||||
// Historial de mensajes almacenado como JSON
|
// Historial de mensajes almacenado como JSON
|
||||||
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"`
|
||||||
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"`
|
||||||
|
|||||||
@@ -866,6 +866,100 @@ func mongoStripComments(text string) string {
|
|||||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regexes para convertir sintaxis shell de Mongo a Extended JSON.
|
||||||
|
var (
|
||||||
|
reNewDateEmpty = regexp.MustCompile(`\bnew\s+Date\s*\(\s*\)`)
|
||||||
|
reNewDateStr = regexp.MustCompile(`\bnew\s+Date\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||||
|
reNewDateMs = regexp.MustCompile(`\bnew\s+Date\s*\(\s*(\d+)\s*\)`)
|
||||||
|
reISODate = regexp.MustCompile(`\bISODate\s*\(\s*["']([^"']+)["']\s*\)`)
|
||||||
|
reISODateEmpty = regexp.MustCompile(`\bISODate\s*\(\s*\)`)
|
||||||
|
reObjectId = regexp.MustCompile(`\bObjectId\s*\(\s*["']([0-9a-fA-F]{24})["']\s*\)`)
|
||||||
|
reNumberInt = regexp.MustCompile(`\bNumberInt\s*\(\s*(\d+)\s*\)`)
|
||||||
|
reNumberLong = regexp.MustCompile(`\bNumberLong\s*\(\s*(\d+)\s*\)`)
|
||||||
|
reNumberDecimal = regexp.MustCompile(`\bNumberDecimal\s*\(\s*["']?([0-9.eE+\-]+)["']?\s*\)`)
|
||||||
|
reBinData = regexp.MustCompile(`\bBinData\s*\(\s*(\d+)\s*,\s*["']([^"']*)["']\s*\)`)
|
||||||
|
reTimestamp = regexp.MustCompile(`\bTimestamp\s*\(\s*(\d+)\s*,\s*(\d+)\s*\)`)
|
||||||
|
reMinKey = regexp.MustCompile(`\bMinKey\b`)
|
||||||
|
reMaxKey = regexp.MustCompile(`\bMaxKey\b`)
|
||||||
|
reUndefined = regexp.MustCompile(`\bundefined\b`)
|
||||||
|
reUnquotedKeys = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// mongoShellToExtJSON convierte sintaxis shell de Mongo a Extended JSON strict
|
||||||
|
// para que bson.UnmarshalExtJSON pueda parsearlo correctamente.
|
||||||
|
func mongoShellToExtJSON(s string) string {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
// new Date() sin argumentos → fecha actual
|
||||||
|
s = reNewDateEmpty.ReplaceAllStringFunc(s, func(_ string) string {
|
||||||
|
return `{"$date":"` + now + `"}`
|
||||||
|
})
|
||||||
|
// new Date("iso-string")
|
||||||
|
s = reNewDateStr.ReplaceAllString(s, `{"$$date":"$1"}`)
|
||||||
|
// new Date(milliseconds)
|
||||||
|
s = reNewDateMs.ReplaceAllString(s, `{"$$date":{"$$numberLong":"$1"}}`)
|
||||||
|
// ISODate("iso-string")
|
||||||
|
s = reISODate.ReplaceAllString(s, `{"$$date":"$1"}`)
|
||||||
|
// ISODate() sin argumentos → fecha actual
|
||||||
|
s = reISODateEmpty.ReplaceAllStringFunc(s, func(_ string) string {
|
||||||
|
return `{"$date":"` + now + `"}`
|
||||||
|
})
|
||||||
|
// ObjectId("hex")
|
||||||
|
s = reObjectId.ReplaceAllString(s, `{"$$oid":"$1"}`)
|
||||||
|
// NumberInt(n)
|
||||||
|
s = reNumberInt.ReplaceAllString(s, `{"$$numberInt":"$1"}`)
|
||||||
|
// NumberLong(n)
|
||||||
|
s = reNumberLong.ReplaceAllString(s, `{"$$numberLong":"$1"}`)
|
||||||
|
// NumberDecimal(n)
|
||||||
|
s = reNumberDecimal.ReplaceAllString(s, `{"$$numberDecimal":"$1"}`)
|
||||||
|
// BinData(subtype, base64)
|
||||||
|
s = reBinData.ReplaceAllString(s, `{"$$binary":{"base64":"$2","subType":"$1"}}`)
|
||||||
|
// Timestamp(t, i)
|
||||||
|
s = reTimestamp.ReplaceAllString(s, `{"$$timestamp":{"t":$1,"i":$2}}`)
|
||||||
|
// MinKey / MaxKey
|
||||||
|
s = reMinKey.ReplaceAllString(s, `{"$$minKey":1}`)
|
||||||
|
s = reMaxKey.ReplaceAllString(s, `{"$$maxKey":1}`)
|
||||||
|
// undefined → null
|
||||||
|
s = reUndefined.ReplaceAllString(s, `null`)
|
||||||
|
// Comillas simples → dobles (solo en valores string, no dentro de ya-convertidos)
|
||||||
|
s = mongoSingleToDoubleQuotes(s)
|
||||||
|
// Claves sin comillas → claves con comillas dobles
|
||||||
|
s = reUnquotedKeys.ReplaceAllString(s, `$1"$2"$3`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// mongoSingleToDoubleQuotes convierte 'valor' → "valor" respetando escapes.
|
||||||
|
func mongoSingleToDoubleQuotes(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inDouble := false
|
||||||
|
inSingle := false
|
||||||
|
runes := []rune(s)
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
c := runes[i]
|
||||||
|
switch {
|
||||||
|
case c == '\\' && (inDouble || inSingle):
|
||||||
|
b.WriteRune(c)
|
||||||
|
i++
|
||||||
|
if i < len(runes) {
|
||||||
|
b.WriteRune(runes[i])
|
||||||
|
}
|
||||||
|
case c == '"' && !inSingle:
|
||||||
|
inDouble = !inDouble
|
||||||
|
b.WriteRune(c)
|
||||||
|
case c == '\'' && !inDouble:
|
||||||
|
inSingle = !inSingle
|
||||||
|
if inSingle {
|
||||||
|
b.WriteRune('"')
|
||||||
|
} else {
|
||||||
|
b.WriteRune('"')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func mongoExecuteSQL(conx models.ConxDb, database, queryText string) QueryResult {
|
func mongoExecuteSQL(conx models.ConxDb, database, queryText string) QueryResult {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
client, err := mongoConnect(conx)
|
client, err := mongoConnect(conx)
|
||||||
@@ -891,6 +985,8 @@ func mongoExecuteSQL(conx models.ConxDb, database, queryText string) QueryResult
|
|||||||
func mongoRunQuery(ctx context.Context, db *mongo.Database, query string) (*QueryResult, error) {
|
func mongoRunQuery(ctx context.Context, db *mongo.Database, query string) (*QueryResult, error) {
|
||||||
// Eliminar comentarios y líneas vacías
|
// Eliminar comentarios y líneas vacías
|
||||||
query = mongoStripComments(query)
|
query = mongoStripComments(query)
|
||||||
|
// Convertir sintaxis shell de Mongo (new Date(), ObjectId, etc.) a Extended JSON
|
||||||
|
query = mongoShellToExtJSON(query)
|
||||||
|
|
||||||
m := mongoQueryRe.FindStringSubmatch(query)
|
m := mongoQueryRe.FindStringSubmatch(query)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ var landingQuestions = []struct {
|
|||||||
{"target", "¿A quién va dirigida tu landing page? ¿Cuál es tu cliente ideal?"},
|
{"target", "¿A quién va dirigida tu landing page? ¿Cuál es tu cliente ideal?"},
|
||||||
{"offer", "¿Qué producto o servicio principal quieres destacar?"},
|
{"offer", "¿Qué producto o servicio principal quieres destacar?"},
|
||||||
{"value_prop", "¿Cuál es tu propuesta de valor? ¿Por qué deberían elegirte a ti y no a la competencia?"},
|
{"value_prop", "¿Cuál es tu propuesta de valor? ¿Por qué deberían elegirte a ti y no a la competencia?"},
|
||||||
{"cta", "¿Qué acción quieres que tome el visitante? (Ej: agendar cita, comprar, contactar, etc.)"},
|
|
||||||
{"colors", "¿Tienes colores de marca? Descríbelos o dime el estilo visual que prefieres. (Ej: moderno, minimalista, corporativo, colorido)"},
|
{"colors", "¿Tienes colores de marca? Descríbelos o dime el estilo visual que prefieres. (Ej: moderno, minimalista, corporativo, colorido)"},
|
||||||
{"user_phone", "¿Cuál es tu número de teléfono o WhatsApp de contacto?"},
|
{"user_phone", "¿Cuál es tu número de teléfono o WhatsApp de contacto?"},
|
||||||
{"user_email", "¿Cuál es tu correo electrónico de contacto?"},
|
{"user_email", "¿Cuál es tu correo electrónico de contacto?"},
|
||||||
{"user_website", "¿Tienes sitio web o redes sociales que quieras enlazar? (Escríbelos o escribe \"no\" si no tienes)"},
|
{"user_website", "¿Cuál es tu sitio web o portafolio?"},
|
||||||
|
{"social_media", "¿Qué redes sociales tienes? Comparte tus perfiles de Instagram, LinkedIn, YouTube, TikTok, etc. (Ej: Instagram: @tunombre, LinkedIn: /in/tunombre)"},
|
||||||
{"user_address", "¿Tienes una dirección física o ciudad donde operas?"},
|
{"user_address", "¿Tienes una dirección física o ciudad donde operas?"},
|
||||||
{"extras", "¿Hay algo más que quieras incluir? Testimonios, precios, galería, FAQ... (O escribe \"listo\" para generar ya tu landing)"},
|
{"extras", "¿Hay algo más que quieras incluir? Testimonios, precios, galería, FAQ... (O escribe \"listo\" para generar ya tu landing)"},
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func LandingChat(c *fiber.Ctx) error {
|
|||||||
msgs = append(msgs, models.LandingMessage{Role: "user", Content: req.Message})
|
msgs = append(msgs, models.LandingMessage{Role: "user", Content: req.Message})
|
||||||
|
|
||||||
// Guardar campo estructurado si corresponde
|
// Guardar campo estructurado si corresponde
|
||||||
contactFields := map[string]bool{"user_name": true, "user_phone": true, "user_email": true, "user_website": true, "user_address": true}
|
contactFields := map[string]bool{"user_name": true, "user_phone": true, "user_email": true, "user_website": true, "social_media": true, "user_address": true}
|
||||||
if userAnswersCount < len(landingQuestions) {
|
if userAnswersCount < len(landingQuestions) {
|
||||||
key := landingQuestions[userAnswersCount].Key
|
key := landingQuestions[userAnswersCount].Key
|
||||||
if contactFields[key] {
|
if contactFields[key] {
|
||||||
@@ -108,6 +108,8 @@ func LandingChat(c *fiber.Ctx) error {
|
|||||||
session.UserEmail = req.Message
|
session.UserEmail = req.Message
|
||||||
case "user_website":
|
case "user_website":
|
||||||
session.UserWebsite = req.Message
|
session.UserWebsite = req.Message
|
||||||
|
case "social_media":
|
||||||
|
session.SocialMedia = req.Message
|
||||||
case "user_address":
|
case "user_address":
|
||||||
session.UserAddress = req.Message
|
session.UserAddress = req.Message
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,7 @@ func LandingGetSession(c *fiber.Ctx) error {
|
|||||||
"user_job": session.UserJob,
|
"user_job": session.UserJob,
|
||||||
"user_website": session.UserWebsite,
|
"user_website": session.UserWebsite,
|
||||||
"user_address": session.UserAddress,
|
"user_address": session.UserAddress,
|
||||||
|
"social_media": session.SocialMedia,
|
||||||
"vcard_included": session.VcardIncluded,
|
"vcard_included": session.VcardIncluded,
|
||||||
}
|
}
|
||||||
// Solo exponer el HTML si la sesión está generada (preview con marca de agua siempre)
|
// Solo exponer el HTML si la sesión está generada (preview con marca de agua siempre)
|
||||||
|
|||||||
Reference in New Issue
Block a user