Checkout para Moodle

Documentación técnica · versión 1.3.1 (2026100231) · local_tiendacursos + block_tiendacursos

1. Compatibilidad y requisitos

2. Arquitectura y por qué no se usa paygw + enrol_fee

Moodle trae un subsistema de pagos (core_payment, pasarelas paygw_*) y la matrícula por pago (enrol_fee). Ese diseño exige que el comprador ya sea usuario y esté autenticado: la «cuenta de pago» se asocia a un userid y la matrícula se hace sobre ese usuario. La tienda necesita lo contrario:

Por eso la tienda es un complemento local con su propia tabla de pedidos y adaptadores de pasarela (classes/gateway/*) con una interfaz común (start, verify, order_from_notification, test_connection). La matrícula usa la API estándar de enrol_manual (enrol_user, update_user_enrol), así que el resto de Moodle (informes, finalización, certificados) ve matrículas normales.

3. Flujo de un pedido

checkout.php ──► orders::create()            pedido "pending" + filas de estudiantes (transacción)
             └─► orders::start_payment()     $0 → mark_paid · transferencia → instrucciones · en línea → URL pasarela
pasarela ──server──► notify.php              NO_MOODLE_COOKIES, sin exigir user-agent; GET+POST+JSON
             └─► gateway::order_from_notification() → gateway::verify()   (consulta a la API, nunca confía en el aviso)
             └─► orders::apply_verification() estado + monto ≥ pedido + referencia propia
             └─► orders::mark_paid()          lock por pedido; idempotente
                    ├─ primera vez: estado paid, uso de cupón, evento order_paid, comprobante, aviso a ventas
                    └─ fulfil(): por estudiante → ensure_user() → enrol() / renovación → evento student_enrolled → bienvenida
result.php (regreso del navegador)            si sigue pendiente, vuelve a consultar la API; nunca matricula por sí mismo
task\reconcile_orders (cada 15 min)          consulta pedidos en línea pendientes (>5 min); expira: en línea 3 días, transferencia 30

4. Seguridad

RiesgoControl
Matrícula por un regreso falsificado del navegadorSolo apply_verification() con datos leídos de la API de la pasarela marca un pedido pagado.
Aviso falso a notify.phpEl aviso solo aporta un token o id; el estado, el monto y la referencia se consultan a la API firmada (Flow: HMAC-SHA256; MP: Bearer token). Se compara commerceOrder/external_reference y la moneda.
Pago por menosSi el monto informado es menor que el pedido, se registra amount_mismatch y no se matricula.
Avisos repetidos o concurrentesLock por pedido (\core\lock) + estado; matrícula idempotente por estudiante.
Avisos bloqueadosnotify.php no exige cookies ni user-agent; alerta de pagos pendientes >1 h; conciliación cada 15 min.
CSRF en el panelTodas las acciones que cambian datos son POST con sesskey.
Acceso al panelrequire_login + panel::require_section($seccion) al inicio de cada página y panel::require_section($seccion, 'edit') antes de cada cambio (no basta con ocultar botones). Usuarios del panel y Licencia: solo is_siteadmin(). Los roles y capacidades de Moodle no dan acceso.
Versión gratuitaLos límites se verifican en el servidor: shop::enabled_gateways() (1 medio), orders::max_students() (1), orders::quote() (sin cupón ni tramo), orders::create() rechaza un medio no ofrecido o más de 1 estudiante, panelusers::save() exige licencia, y Pasarelas rechaza activar un segundo medio.
ContraseñasLa contraseña temporal solo viaja en el correo de bienvenida; no se guarda en pedidos, cola ni registros. La contraseña SMTP se enmascara (texto y base64) en errores; el registro SMTP solo conserva líneas del servidor.
Secretos de pasarela y SMTPNunca se devuelven al navegador (campo vacío = conservar la guardada; casilla «Borrar la guardada»); los usuarios de solo lectura ven «Guardada (oculta)». La última prueba de conexión guarda solo ok/mensaje/fecha.
Catálogo en sitios externos (1.3)Solo con licencia y ajuste ext_enabled. CORS: se repite el Origin exacto solo si es un sitio autorizado activo (o el propio Moodle), nunca *; origen no autorizado o null → 403. Iframe: Content-Security-Policy: frame-ancestors 'self' + sitios autorizados (o solo el de la clave) y se quita X-Frame-Options solo en embed.php; allowframembedding no se toca. Sin cookies (NO_MOODLE_COOKIES), límite por IP y minuto, el feed contiene solo datos públicos de la vitrina y embed.js escribe todo con textContent y solo enlaza a URLs del mismo Moodle.
Entrada del visitanteRUT con dígito verificador, correo validado, textos limpiados y truncados.

5. Modelo de datos (XMLDB)

TablaContenidoÍndices
local_tiendacursos_courseOferta por curso: precio, precio normal, horas, días de acceso, visible, destacado, orden.courseid único
local_tiendacursos_couponCódigo, tipo percent|amount, valor, curso (0 = todos), usos máx./usados, vencimiento, activo.code único
local_tiendacursos_tierTramos: curso (0 = generales), mín., máx. (0 = abierto), %.courseid
local_tiendacursos_orderPedido: referencia, curso, comprador, cantidad, precio, subtotal, descuentos (grupal y cupón), monto, pasarela, ref. pasarela, estado, matriculado, renovación, fechas. 1.2: packid (0 = curso; entonces courseid = 0) y packcourses (ids de los cursos del pack al comprar).reference único; status+timecreated; gateway+gatewayref; userid; courseid; packid
local_tiendacursos_studentEstudiante del pedido: datos, userid, cuenta existente, renovación, fin de acceso, estado pending|enrolled|error, error.orderid; userid
local_tiendacursos_logHistorial del pedido (sin secretos).orderid
local_tiendacursos_puser (1.1)Usuarios del panel: userid (único), perms (JSON sección → none/read/edit), fechas y usermodified.userid
local_tiendacursos_pack (1.2)Pack: nombre, descripción, precio de oferta (0 = suma), oferta hasta, días de acceso, destacado, activo, orden. El precio normal no se guarda: es la suma de las ofertas de sus cursos. Imagen en el área packimage (contexto sistema, itemid = pack).—
local_tiendacursos_packcourse (1.2)Cursos de cada pack con su orden.packid+courseid único
local_tiendacursos_tpl (1.2)Plantillas de correo personalizadas: kind, asunto, título, cuerpo (NULL = cadena del idioma), activo, usermodified. Nunca contraseñas.kind único
local_tiendacursos_notice (1.2)Avisos de vencimiento enviados: userid, curso o pack, tipo (soonN / expired), fecha de término avisada, fecha de envío.userid+courseid+packid+kind+timeend único
local_tiendacursos_site (1.3)Sitios externos autorizados: nombre, origin (esquema://host[:puerto], normalizado), sitekey (24 hex, pública), activo, último uso (se escribe a lo más una vez por hora), fechas y usermodified.sitekey único; active
local_tiendacursos_mailqCola de reintento de correos (tipo, intentos, último error, próximo intento). Sin contraseñas.timenext

Las consultas del panel están paginadas (50 por página) y precargan cursos y conteos en una sola consulta (sin N+1). La exportación se transmite con \core\dataformat y un recordset.

6. Pasarelas

Flow (API v2)

Mercado Pago (Checkout Pro)

Transferencia

No es en línea: el pedido queda pendiente hasta que un administrador confirma el pago en el panel.

Cada pasarela guarda su modo en <gw>_mode (sandbox|production) y sus credenciales en <gw>_<campo>_sandbox y <gw>_<campo>_prod. El cliente HTTP (gateway\http) es reemplazable en pruebas.

7. Cuentas, matrícula y renovación

8. Correo

9. Integración con Moodle

ElementoDetalle
PanelPáginas propias con el formato del panel de Registro de avances (templates/panel*.mustache, classes/local/ui.php, AMD tables y navscroll): sales, courses, coupons, tiers, gateways, mail, config, users, activate, docs; 1.2: packs y templates. panel.php abre la primera sección permitida. En Administración del sitio quedan solo enlaces al panel, la licencia y la documentación.
Permisosclasses/local/panel.php sobre la clase común norteduc_panel_perms. Secciones y nivel de un usuario nuevo: sales read, courses read, packs read, coupons read, tiers read, gateways none, mail none, templates read, config none. Actualización a 1.2: templates = edit si mail era edit, si no read; packs = nivel de courses. La capacidad local/tiendacursos:manage de 1.0 se eliminó; en la actualización sus titulares (no administradores) pasan a usuarios del panel con «Solo lectura» en todo. Bloque: addinstance, myaddinstance.
Eventos\local_tiendacursos\event\order_paid, student_enrolled, credentials_sent, panel_user_updated (alta, cambio o baja de un usuario del panel, con sus permisos).
Tareasreconcile_orders (*/15), retry_mail (*/10), check_updates (diaria), send_expiry_notices (diaria 9:00).
Observadoresuser_deleted (acceso al panel y avisos), course_deleted (sale de los packs y se borra su oferta).
Archivoslocal_tiendacursos_pluginfile() sirve la imagen de los packs (pública, como la vitrina).
Hookscore\hook\navigation\primary_extend (menú), core\hook\output\before_footer_html_generation (catálogo en portada).
Estado del sistemacore\check «Checkout para Moodle» (código no válido, varios medios en versión gratuita, pasarelas ofrecidas, modo, última prueba, cron, pendientes, correo, nueva versión).
PrivacidadProveedor completo: metadatos de pedidos, estudiantes, usuarios del panel, avisos de vencimiento, plantillas editadas (usermodified) y envío a Flow/MP; exporta, anonimiza los pedidos (los montos se conservan para contabilidad) y borra el acceso al panel. El bloque no guarda datos.
Plantillalocal_tiendacursos/course_cards, compartida por vitrina, bloque y portada.
Idiomases y en completos.
Copia de seguridad de cursosLa oferta, los tramos por curso y los pedidos viven en tablas de la tienda y no viajan en el respaldo del curso: al restaurar en otro sitio, vuelve a configurar la oferta. Las matrículas sí viajan como matrículas manuales normales.
DesinstalaciónSolo borra sus tablas y ajustes; las cuentas y matrículas creadas se conservan.

10. Licencia, planes y actualizaciones

Código común de los complementos Norteduc (norteduc_license, norteduc_panel_perms, norteduc_updates, copias idénticas verificadas con shared/sync.py --check). Código de activación Ed25519 de un solo dominio (producto tc), activado una vez en línea con comprobante firmado.

PlanPayloadComportamiento
Gratissin código, o código inválido / de otro dominio / anual vencida tras la graciaNunca cerrada: 1 medio de pago (el primero activo en el orden Flow, Mercado Pago, transferencia), 1 estudiante por compra, sin cupones ni tramos, solo el administrador del sitio. Los datos de pago se conservan inactivos.
Anualplan=anual, exp=AAAA-MM-DDTodo; aviso en el panel desde 30 días antes y correo a get_admins() 30, 15, 3 y 1 día antes (tarea diaria check_updates, una vez por hito y fecha: nlic_reminded_<exp>_<n>; solo el más cercano si se saltaron), 15 días de gracia; actualizaciones mientras esté vigente.
Perpetuaplan=perpetua o sin plan (códigos TC-… anteriores a 1.1)Todo, sin vencimiento; actualizaciones para siempre.

Las funciones bloqueadas se muestran con lock_html() («Comprar licencia» → https://www.norteduc.cl/tienda/#complementos, fija en el código). «Contactar a soporte» (support_button(), mailto contacto@norteduc.cl con asunto y datos técnicos) va en el pie del panel y en Licencia, en todas las versiones. La tarea check_updates lee https://licencias.norteduc.cl/version/tc.json y el panel muestra el aviso de nueva versión (descarga POST con el código si la licencia está vigente; enlace download público en la gratuita; «renueva» si la anual venció).

11. Catálogo para sitios externos (1.3)

Clase \local_tiendacursos\local\sites (política de acceso, claves, feed, caché, límite) y la sección del panel sites.php (permiso de sección sites, nuevo = «Sin acceso»; la 1.3 no migra niveles). Función de pago sites en license::PAID_FUNCTIONS.

Punto de entradaQué hace
/local/tiendacursos/feed.php[?k=CLAVE]JSON público (GET/HEAD/OPTIONS; otro método → 405). Cabeceras: ETag, Last-Modified, Cache-Control: public, max-age=120, Vary: Origin, X-Content-Type-Options: nosniff, X-Robots-Tag: noindex; 304 con If-None-Match / If-Modified-Since. Errores JSON: 404 disabled, 403 origin / badkey / keyrequired, 429 ratelimit (Retry-After: 60).
/local/tiendacursos/embed.jsScript estático sin dependencias: lee los atributos data- (key, container, type, category, featured, limit, color, text, bg, font, radius, minwidth, target, button, heading), espera a que el bloque esté cerca de verse (IntersectionObserver), pide el feed con credentials: 'omit' y pinta tarjetas en un shadow root. Colores y fuentes se validan (sin url(, ; ni llaves). Si el feed falla, muestra un enlace a la vitrina y un aviso en la consola.
/local/tiendacursos/embed.php[?k=&cat=&type=&featured=&limit=&target=_top]Vitrina con pagelayout('embedded'), sin cookies; enlaces con target=_blank rel=noopener (o _top). Publica su alto al padre con postMessage({tiendacursos:'height', h}); el código del iframe solo escucha mensajes cuyo origin es el Moodle.
/local/tiendacursos/image.php?course=ID|pack=ID&v=TPortada pública de un curso o pack a la venta (sirve aunque el sitio use forcelogin), caché pública inmutable.

Formato del feed (versión 1)

{
  "version": 1,
  "site": {"name": "...", "url": "https://aula.example.com/", "showcase": ".../local/tiendacursos/index.php",
           "currency": "CLP", "locale": "es-CL"},
  "labels": {"buy": "Comprar", "pack": "Pack", "hours": "{n} horas", "days": "Acceso por {n} días", ...},
  "items": [
    {"type": "pack", "id": 1, "name": "...", "category": "", "summary": "texto plano (≤300)", "hours": null,
     "access_days": 90, "price": {"currency": "CLP", "normal": 189700, "offer": 149900, "offer_until": "2026-12-31T23:59:00-03:00",
     "current": 149900, "formatted": "$149.900", "formatted_normal": "$189.700"}, "savings": 39800, "featured": true,
     "courses": [{"id": 5, "name": "..."}], "image": ".../image.php?pack=1&v=...", "url": ".../checkout.php?pack=1"},
    {"type": "course", "id": 3, "category_id": 2, ...}
  ]
}

Primero los packs (como en la vitrina) y luego los cursos; los packs traen category_ids (categorías de sus cursos) para filtrar igual que la vitrina. El feed y el iframe usan siempre el idioma del sitio (force_current_language($CFG->lang)), así la caché compartida nunca queda en el idioma de un visitante. Caché MUC feed (60 s, clave por idioma) que se purga al guardar ofertas, packs o los ajustes; el ETag depende solo del contenido y Last-Modified es la hora en que cambió ese ETag (ajuste ext_lm_feed_<idioma>), así también cambia al borrar un curso, renombrar una categoría o cambiar una portada. Caché MUC ratelimit: una entrada por IP (ventana:contador, ventana de un minuto, sobrescrita; no crece); el conteo no es atómico (tolerancia aceptable para un límite anti-abuso). Detrás de un proxy o CDN hay que configurar getremoteaddrconf/reverseproxy para que getremoteaddr() entregue la IP real. El 429 se envía después de las cabeceras CORS para que la página autorizada pueda leerlo.

Reglas de acceso (sites::decide())

  1. Publicación apagada o sin licencia → 404 disabled; frame-ancestors 'self'.
  2. Origin: null → 403.
  3. Con k: debe ser la clave de un sitio activo; si llega Origin, debe ser ese sitio (con o sin «www.», mismo esquema y puerto).
  4. Sin k y ext_requirekey → 403. Sin k ni exigencia: con Origin, debe ser un sitio activo; sin Origin (servidor, curl) se responde.

12. Pruebas y calidad