From 52ce8638a803ae61f82b10382460d5564e41e5cb Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 28 May 2026 20:49:35 -0500 Subject: [PATCH] replace cta with social_media question; add SocialMedia field to landing_session --- BOLD_ENLACES_PAGO.md | 133 +++++++++++++++++++++ pkg/models/landing_session.go | 15 +-- pkg/services/query_runner_service.go | 96 +++++++++++++++ rest/controllers/api/landing_controller.go | 9 +- 4 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 BOLD_ENLACES_PAGO.md diff --git a/BOLD_ENLACES_PAGO.md b/BOLD_ENLACES_PAGO.md new file mode 100644 index 0000000..360dae5 --- /dev/null +++ b/BOLD_ENLACES_PAGO.md @@ -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: +``` + +```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: +``` + +### 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. diff --git a/pkg/models/landing_session.go b/pkg/models/landing_session.go index 00e380d..9f873aa 100644 --- a/pkg/models/landing_session.go +++ b/pkg/models/landing_session.go @@ -12,13 +12,14 @@ import ( type LandingSession struct { gorm.Model Token string `json:"token" gorm:"column:token;uniqueIndex;not null"` - UserName string `json:"user_name" gorm:"column:user_name"` - UserEmail string `json:"user_email" gorm:"column:user_email"` - UserPhone string `json:"user_phone" gorm:"column:user_phone"` - UserCompany string `json:"user_company" gorm:"column:user_company"` - UserJob string `json:"user_job" gorm:"column:user_job"` - UserWebsite string `json:"user_website" gorm:"column:user_website"` - UserAddress string `json:"user_address" gorm:"column:user_address"` + UserName string `json:"user_name" gorm:"column:user_name"` + UserEmail string `json:"user_email" gorm:"column:user_email"` + UserPhone string `json:"user_phone" gorm:"column:user_phone"` + UserCompany string `json:"user_company" gorm:"column:user_company"` + UserJob string `json:"user_job" gorm:"column:user_job"` + UserWebsite string `json:"user_website" gorm:"column:user_website"` + UserAddress string `json:"user_address" gorm:"column:user_address"` + SocialMedia string `json:"social_media" gorm:"column:social_media"` // Historial de mensajes almacenado como JSON AnswersJSON string `json:"-" gorm:"column:answers_json;type:text"` HTMLContent string `json:"html_content,omitempty" gorm:"column:html_content;type:text"` diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go index e29b0d6..cb44614 100644 --- a/pkg/services/query_runner_service.go +++ b/pkg/services/query_runner_service.go @@ -866,6 +866,100 @@ func mongoStripComments(text string) string { 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 { start := time.Now() 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) { // Eliminar comentarios y líneas vacías query = mongoStripComments(query) + // Convertir sintaxis shell de Mongo (new Date(), ObjectId, etc.) a Extended JSON + query = mongoShellToExtJSON(query) m := mongoQueryRe.FindStringSubmatch(query) if m == nil { diff --git a/rest/controllers/api/landing_controller.go b/rest/controllers/api/landing_controller.go index 37145e4..391fe21 100644 --- a/rest/controllers/api/landing_controller.go +++ b/rest/controllers/api/landing_controller.go @@ -52,11 +52,11 @@ var landingQuestions = []struct { {"target", "¿A quién va dirigida tu landing page? ¿Cuál es tu cliente ideal?"}, {"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?"}, - {"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)"}, {"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_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?"}, {"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}) // 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) { key := landingQuestions[userAnswersCount].Key if contactFields[key] { @@ -108,6 +108,8 @@ func LandingChat(c *fiber.Ctx) error { session.UserEmail = req.Message case "user_website": session.UserWebsite = req.Message + case "social_media": + session.SocialMedia = req.Message case "user_address": session.UserAddress = req.Message } @@ -163,6 +165,7 @@ func LandingGetSession(c *fiber.Ctx) error { "user_job": session.UserJob, "user_website": session.UserWebsite, "user_address": session.UserAddress, + "social_media": session.SocialMedia, "vcard_included": session.VcardIncluded, } // Solo exponer el HTML si la sesión está generada (preview con marca de agua siempre)