Documentación técnica · versión 1.3.1 (2026100231) · local_tiendacursos + block_tiendacursos
$plugin->supported = [405, 501]), PHP 8.1 o superior, PostgreSQL, MySQL o MariaDB.www.flow.cl, sandbox.flow.cl,
api.mercadopago.com y licencias.norteduc.cl (activación única).styles.css con variables del tema.local_tiendacursos ≥ 2026100221 (bloque 1.2.0).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.
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
| Riesgo | Control |
|---|---|
| Matrícula por un regreso falsificado del navegador | Solo apply_verification() con datos leídos de la API
de la pasarela marca un pedido pagado. |
| Aviso falso a notify.php | El 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 menos | Si el monto informado es menor que el pedido, se registra amount_mismatch y no se matricula. |
| Avisos repetidos o concurrentes | Lock por pedido (\core\lock) + estado; matrícula idempotente por estudiante. |
| Avisos bloqueados | notify.php no exige cookies ni user-agent; alerta de pagos pendientes >1 h; conciliación cada 15 min. |
| CSRF en el panel | Todas las acciones que cambian datos son POST con sesskey. |
| Acceso al panel | require_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 gratuita | Los 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ñas | La 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 SMTP | Nunca 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 visitante | RUT con dígito verificador, correo validado, textos limpiados y truncados. |
| Tabla | Contenido | Índices |
|---|---|---|
local_tiendacursos_course | Oferta por curso: precio, precio normal, horas, días de acceso, visible, destacado, orden. | courseid único |
local_tiendacursos_coupon | Código, tipo percent|amount, valor, curso (0 = todos), usos máx./usados, vencimiento, activo. | code único |
local_tiendacursos_tier | Tramos: curso (0 = generales), mín., máx. (0 = abierto), %. | courseid |
local_tiendacursos_order | Pedido: 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_student | Estudiante del pedido: datos, userid, cuenta existente, renovación, fin de acceso, estado pending|enrolled|error, error. | orderid; userid |
local_tiendacursos_log | Historial 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_mailq | Cola 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.
https://sandbox.flow.cl/api, producción https://www.flow.cl/api.nombre+valor, HMAC-SHA256 con la Secret Key, en
s.POST /payment/create (commerceOrder = referencia, urlConfirmation, urlReturn, paymentMethod 9) →
redirección a url?token=….GET /payment/getStatus (token) o /payment/getStatusByCommerceId. Estados 1 pendiente,
2 pagado, 3 rechazado, 4 anulado.getStatusByCommerceId de una orden inexistente (solo lectura).POST /checkout/preferences con external_reference, back_urls, auto_return y notification_url.sandbox_init_point; producción init_point.type=payment&data.id) o IPN (topic=payment&id) → GET /v1/payments/{id};
sin id se busca en /v1/payments/search?external_reference=.GET /users/me.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.
12345678-5), auth manual, idnumber = RUT con formato,
institución = empresa, auth_forcepasswordchange.local\access): activo, vencido (timeend ≤ ahora), suspendido (matrícula o instancia
deshabilitada con fecha vigente) o sin matrícula; considera cualquier método de matriculación.update_user_enrol(instancia existente, ACTIVE, ahora, ahora + días), habilita la instancia si
estaba deshabilitada y reasigna el rol estudiante si faltaba. No crea matrículas duplicadas.orders::enrol_until()): la fecha de término del estudiante se fija una vez
(student.timeend) y se aplica a cada curso del snapshot packcourses: vencido → se renueva la misma
matrícula; vigente → se conserva y solo se extiende si terminaba antes (nunca se acorta; sin término queda sin término); sin
matrícula → matrícula manual; suspendido → error del estudiante (reintentable desde Ventas). Reintentar da las mismas fechas.
Validación previa: suspendido en algún curso o vigente en todos = no puede comprar.offerprice > 0 y (sin término o ahora ≤ offeruntil), si no la suma.
Tramos: los generales (courseid 0). Cupones: solo los «para todos los cursos» (coupon_notpack).packs::sellable(), verificado
también en orders::create()).local\transport: email_to_user (respeta smtphosts, noreply, divertallemailsto) o SMTP propio con
moodle_phpmailer (Date, Message-ID con el dominio del remitente, HTML + texto, Reply-To).local\mailer: plantillas con marca del sitio (logo, nombre y brandcolor del tema; respaldo Boost).
Si el envío falla, cola con reintento escalonado (15 min × intentos, máx. 6 h, 12 intentos).local\templates (1.2): 9 tipos (welcome_new, welcome_existing, renewal,
receipt, transfer, admin_sale, admin_pending, expiry_soon,
expired) con destinatario, si es opcional y si requiere licencia. Sin fila en _tpl se usa la cadena del
idioma del destinatario (get_string_manager()->get_string(..., $lang)), escrita con variables
{nombre} al pasarle un objeto de marcadores. Variables permitidas por tipo y obligatorias validadas al guardar
(texto limpio con clean_text); los valores se escapan con s(); un href que termina en una variable
(también %7Bvar%7D que deja el editor) recibe el valor exacto. Los opcionales desactivados no se envían ni se
reintentan.local\expiry + tarea send_expiry_notices (diaria 9:00, solo con licencia): candidatos =
estudiantes matriculados por la tienda con término entre hace 3 días y hoy + máx(días); el término real se lee de
user_enrolments (en packs, el menor de sus cursos). Envía el umbral más pequeño alcanzado y registra todos los
mayores; «vencido» solo dentro de 3 días; registro único por persona, curso/pack, tipo y término (idempotente). Sin cola: si
falla, se intenta al día siguiente.| Elemento | Detalle |
|---|---|
| Panel | Pá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. |
| Permisos | classes/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). |
| Tareas | reconcile_orders (*/15), retry_mail (*/10), check_updates (diaria),
send_expiry_notices (diaria 9:00). |
| Observadores | user_deleted (acceso al panel y avisos), course_deleted (sale de los packs y se borra su
oferta). |
| Archivos | local_tiendacursos_pluginfile() sirve la imagen de los packs (pública, como la vitrina). |
| Hooks | core\hook\navigation\primary_extend (menú), core\hook\output\before_footer_html_generation
(catálogo en portada). |
| Estado del sistema | core\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). |
| Privacidad | Proveedor 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. |
| Plantilla | local_tiendacursos/course_cards, compartida por vitrina, bloque y portada. |
| Idiomas | es y en completos. |
| Copia de seguridad de cursos | La 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ón | Solo borra sus tablas y ajustes; las cuentas y matrículas creadas se conservan. |
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.
| Plan | Payload | Comportamiento |
|---|---|---|
| Gratis | sin código, o código inválido / de otro dominio / anual vencida tras la gracia | Nunca 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. |
| Anual | plan=anual, exp=AAAA-MM-DD | Todo; 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. |
| Perpetua | plan=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ó).
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 entrada | Qué 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.js | Script 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=T | Portada pública de un curso o pack a la venta
(sirve aunque el sitio use forcelogin), caché pública inmutable. |
{
"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.
sites::decide())disabled; frame-ancestors 'self'.Origin: null → 403.k: debe ser la clave de un sitio activo; si llega Origin, debe ser ese sitio (con o sin «www.», mismo
esquema y puerto).k y ext_requirekey → 403. Sin k ni exigencia: con Origin, debe ser un sitio
activo; sin Origin (servidor, curl) se responde.sites_test: normalización de direcciones, «www.»,
decisión CORS, claves y exigencia, versión gratuita, frame-ancestors, contenido del feed sin cupones, tramos, cursos ocultos
ni compradores, ETag, 304, límite por IP, validación al guardar, permisos de sección y códigos generados). Prueba real con un sitio
en otro origen (feed, código para pegar, iframe) y compra hasta confirmar el pago; un origen no autorizado queda bloqueado por CORS
y frame-ancestors. Actualización 1.2 → 1.3 en 4.5 y 5.0.dev/, fuera del ZIP.