Scopes *
-
+
diff --git a/rest/controllers/hermes_spec_controller.go b/rest/controllers/hermes_spec_controller.go
index 5cbcdf2..65fa887 100644
--- a/rest/controllers/hermes_spec_controller.go
+++ b/rest/controllers/hermes_spec_controller.go
@@ -433,6 +433,24 @@ func AdminApiSpec(c *fiber.Ctx) error {
{"method": "POST", "path": "/api/v2/pasarelas/paypal/save", "desc": "Guardar config PayPal"},
},
},
+ {
+ "nombre": "VCard (QR, VCF y cobros)",
+ "desc": "Requiere el scope 'vcard'. Mismos controladores que /api/v1, pero con Bearer + IP en vez de cookie de sesión.",
+ "endpoints": []fiber.Map{
+ {"method": "POST", "path": "/api/v2/vcard/qr-tmp", "desc": "QR temporal de contacto → {\"url\": \"...\"}"},
+ {"method": "POST", "path": "/api/v2/vcard/qr", "desc": "QR de contacto — devuelve imagen image/webp, NO JSON"},
+ {"method": "POST", "path": "/api/v2/vcard/qr-url", "desc": "QR de una URL → {\"url\": \"...\"}"},
+ {"method": "POST", "path": "/api/v2/vcard/vcf", "desc": "Generar archivo .vcf → {\"success\": true, \"url\": \"...\"}"},
+ {"method": "POST", "path": "/api/v2/vcard/dlocal/planes", "desc": "Crear plan de suscripción"},
+ {"method": "GET", "path": "/api/v2/vcard/dlocal/planes", "desc": "Listar planes"},
+ {"method": "GET", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Ver un plan"},
+ {"method": "PATCH", "path": "/api/v2/vcard/dlocal/planes/:planID", "desc": "Actualizar un plan"},
+ {"method": "PATCH", "path": "/api/v2/vcard/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar", "desc": "Desactivar una suscripción"},
+ {"method": "GET", "path": "/api/v2/vcard/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId", "desc": "Ver una ejecución de cobro"},
+ {"method": "POST", "path": "/api/v2/vcard/dlocal/pagos", "desc": "Crear un pago"},
+ {"method": "POST", "path": "/api/v2/vcard/rapyd/wallets", "desc": "Crear wallet Rapyd"},
+ },
+ },
{
"nombre": "Query Runner",
"desc": "Ejecutar queries SQL directamente contra las conexiones DB configuradas.",
diff --git a/rest/routes/hermes.go b/rest/routes/hermes.go
index 05d6462..9a384ea 100644
--- a/rest/routes/hermes.go
+++ b/rest/routes/hermes.go
@@ -3,6 +3,7 @@ package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/rest/controllers"
+ apiControllers "github.com/sujit-baniya/fiber-boilerplate/rest/controllers/api"
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
)
@@ -377,6 +378,33 @@ func AdminApiRoutes(api fiber.Router) {
qr.Get("/history", controllers.GetHistory)
qr.Get("/columns", controllers.GetTableColumnsHandler)
+ // ─── VCard: QR, VCF y cobros ─────────────────────────────────────────────
+ // Los mismos controladores que /api/v1, montados acá para que una
+ // integración externa pueda autenticarse con Bearer + IP en vez de la
+ // cookie de sesión (ver docs/api-v1-contrato.md, punto 7).
+ //
+ // El scope "vcard" acota la llave a esto y nada más: sin él, la
+ // integración de VCard tendría acceso a los otros 300 endpoints de v2.
+ //
+ // /api/v1 se mantiene funcionando tal cual para no romper lo que ya está
+ // instalado; esto es el camino nuevo, no un reemplazo forzado.
+ vcard := h.Group("/vcard", middlewares.RequireScope("vcard"))
+ vcard.Post("/qr-tmp", apiControllers.CreateQrTmp)
+ vcard.Post("/qr", apiControllers.CreateQr) // responde image/webp, no JSON
+ vcard.Post("/qr-url", apiControllers.CreateUrlQr)
+ vcard.Post("/vcf", apiControllers.CreateVcf)
+
+ // dLocal y Rapyd bajo el mismo scope: son las operaciones de cobro que
+ // acompañan a la VCard, y quien integra una necesita la otra.
+ vcard.Post("/dlocal/planes", apiControllers.CreatePlan)
+ vcard.Get("/dlocal/planes", apiControllers.SeePlanes)
+ vcard.Get("/dlocal/planes/:planID", apiControllers.SeePlan)
+ vcard.Patch("/dlocal/planes/:planID", apiControllers.UpdatedPlan)
+ vcard.Patch("/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar", apiControllers.DeactivatePlan)
+ vcard.Get("/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId", apiControllers.SeeSubscription)
+ vcard.Post("/dlocal/pagos", apiControllers.CreatePago)
+ vcard.Post("/rapyd/wallets", apiControllers.MakeWallet)
+
// ─── Hostinger ───────────────────────────────────────────────────────────
h.Get("/hostinger/vps", controllers.GetHostingerVPS)
h.Get("/hostinger/domains", controllers.GetHostingerDomains)
diff --git a/rest/routes/hermes_spec_helper_test.go b/rest/routes/hermes_spec_helper_test.go
new file mode 100644
index 0000000..a8aabed
--- /dev/null
+++ b/rest/routes/hermes_spec_helper_test.go
@@ -0,0 +1,13 @@
+package routes
+
+import "os"
+
+// leerSpecComoTexto lee el fuente del spec en vez de invocar el handler, que
+// necesitaría un contexto de Fiber y la configuración cargada.
+func leerSpecComoTexto() (string, error) {
+ b, err := os.ReadFile("../controllers/hermes_spec_controller.go")
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
diff --git a/rest/routes/hermes_vcard_test.go b/rest/routes/hermes_vcard_test.go
new file mode 100644
index 0000000..e813539
--- /dev/null
+++ b/rest/routes/hermes_vcard_test.go
@@ -0,0 +1,72 @@
+package routes
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+// Las rutas de VCard en /api/v2 son el camino que va a usar una integración
+// externa. Un error de tipeo en un path no lo detecta el compilador — se
+// descubre cuando el integrador recibe un 404. Esto lo fija.
+func TestRutasVcardRegistradas(t *testing.T) {
+ app := fiber.New()
+ AdminApiRoutes(app)
+
+ registradas := map[string]bool{}
+ for _, capa := range app.Stack() {
+ for _, r := range capa {
+ registradas[r.Method+" "+r.Path] = true
+ }
+ }
+
+ esperadas := []string{
+ "POST /v2/vcard/qr-tmp",
+ "POST /v2/vcard/qr",
+ "POST /v2/vcard/qr-url",
+ "POST /v2/vcard/vcf",
+ "POST /v2/vcard/dlocal/planes",
+ "GET /v2/vcard/dlocal/planes",
+ "GET /v2/vcard/dlocal/planes/:planID",
+ "PATCH /v2/vcard/dlocal/planes/:planID",
+ "PATCH /v2/vcard/dlocal/planes/:planId/suscripciones/:subscriptionId/desactivar",
+ "GET /v2/vcard/dlocal/suscripciones/:subscriptionId/ejecuciones/:invoiceId",
+ "POST /v2/vcard/dlocal/pagos",
+ "POST /v2/vcard/rapyd/wallets",
+ }
+ for _, e := range esperadas {
+ if !registradas[e] {
+ t.Errorf("falta la ruta %q", e)
+ }
+ }
+}
+
+// El spec de /api/v2 se mantiene a mano, así que se desincroniza en silencio.
+// Este chequeo obliga a que cada ruta nueva de vcard aparezca documentada.
+func TestSpecDocumentaLasRutasVcard(t *testing.T) {
+ app := fiber.New()
+ AdminApiRoutes(app)
+
+ var paths []string
+ for _, capa := range app.Stack() {
+ for _, r := range capa {
+ if strings.HasPrefix(r.Path, "/v2/vcard/") && r.Method != "HEAD" {
+ paths = append(paths, "/api"+r.Path)
+ }
+ }
+ }
+ if len(paths) == 0 {
+ t.Fatal("no se registró ninguna ruta de vcard")
+ }
+
+ spec, err := leerSpecComoTexto()
+ if err != nil {
+ t.Skipf("no se pudo leer el spec: %v", err)
+ }
+ for _, p := range paths {
+ if !strings.Contains(spec, p) {
+ t.Errorf("la ruta %q está registrada pero no figura en el spec de /api/v2", p)
+ }
+ }
+}