Andén Mobility · Arquitectura y operación
Documentación técnica para integradores, devs y sysadmins. Diseño local-first, PWA con backend PHP opcional, todo pensado para shared hosting sin dependencias exóticas.
Arquitectura general
Andén es una PWA local-first con backend PHP opcional.
Sin backend, todo funciona contra localStorage (modo demo).
Con backend, los mismos endpoints API se sirven desde PHP + MySQL.
Las 3 apps comparten /shared (JS + CSS) y se sirven como rutas estáticas. El SW (sw.js) cachea el shell con estrategia stale-while-revalidate para assets y network-first para HTML.
Stack técnico
Frontend
- JavaScript vanilla ES2020 (no framework)
- CSS variables · tokens centralizados
- Bootstrap 5.3 (utilities only, opcional)
- Bootstrap Icons v1.13 + Phosphor (docs)
- Canvas API (mapa custom · sin Mapbox)
- Service Worker · IndexedDB · WebPush
Backend
- PHP 8.0+ (recomendado 8.2)
- MySQL 5.7.8+ / MariaDB 10.3+
- PDO con prepared statements
- curl puro (sin Composer ni SDKs)
- OpenSSL para crypto AES-256-GCM
- SSE (Server-Sent Events) para tiempo real
Infraestructura
- Diseñado para hosting compartido (cPanel)
- No requiere root · no requiere Node
- Logs en
php/storage/logs/ - Migrations file-based con runner propio
- HTTPS obligatorio para SW + WebPush
Externos
- Stripe Connect Express (multi-país)
- Wompi CO (PSE · Nequi · Daviplata)
- Truora KYC (cédula CO + face)
- Facture · Alegra · Siigo (DIAN)
- VAPID Web Push
Estructura del proyecto
Ander/
├── admin/ ← App SaaS de operación (super_admin · ops · support)
│ ├── index.html
│ ├── css/
│ └── js/admin.js ← ~4800 LOC; renderTrips/Drivers/Settings/…
├── auth/ ← Login compartido (las 3 apps redirigen aquí)
├── driver/ ← App PWA del conductor
├── passenger/ ← App PWA del pasajero
├── shared/ ← Código compartido por las 3 apps
│ ├── css/
│ │ ├── tokens.css ← Design system v2 (dark/light)
│ │ ├── base.css ← Reset + tipografía + body
│ │ ├── layout.css ← Grids · stacks · clusters
│ │ ├── components.css
│ │ ├── features.css
│ │ └── map.css
│ └── js/
│ ├── api.js ← Local-first store (localStorage) + remote bridge
│ ├── auth.js
│ ├── boot.js
│ ├── cities.js
│ ├── crypto.js ← AES-GCM via WebCrypto
│ ├── geo.js ← Geofencing 3-zonas
│ ├── i18n.js ← ES · EN · FR · IT auto-detect
│ ├── legal.js ← Habeas Data flow
│ ├── map.js ← Canvas map (theme-aware + rAF debounce)
│ ├── permissions.js ← Role tree
│ ├── pricing.js ← Tarifa motor (factores por currency)
│ ├── push-client.js
│ ├── seed.js ← Demo data versionada (SEED_VERSION)
│ ├── stripe-client.js
│ ├── tenants.js ← Multi-tenant feature flag
│ ├── truora-client.js
│ ├── ui.js ← Toasts · modals · sheets · loaders
│ └── wompi-client.js
├── php/
│ ├── api.php ← REST CRUD whitelist (16 recursos)
│ ├── auth.php
│ ├── cron.php ← 10 jobs (KYC · payouts · DIAN · PII backfill · …)
│ ├── dian.php
│ ├── install.php
│ ├── kyc.php ← Truora endpoints
│ ├── legal.php ← DSR + tax_profiles
│ ├── push.php
│ ├── sse.php
│ ├── stripe.php
│ ├── wompi.php
│ ├── upload.php
│ ├── upgrade.php ← Runner de migraciones
│ ├── version.json
│ ├── schema.sql
│ ├── migrations/ ← v1.0.0.sql · v1.1.0.sql · … · v1.5.0.sql
│ └── lib/
│ ├── DB.php ← PDO wrapper con prefix dinámico
│ ├── Crypto.php ← AES-256-GCM + nitDV() + backfillTable()
│ ├── Truora.php
│ ├── Wompi.php
│ ├── Stripe.php
│ ├── Dian.php ← PT-agnostic (facture · alegra · siigo · mock)
│ ├── WebPush.php
│ ├── Audit.php ← Hash chain (audit firmado)
│ ├── Upgrader.php
│ └── Installer.php
├── docs/ ← Esta documentación
├── legal/ ← Términos · Privacidad · Habeas Data PDFs
├── index.html ← Landing principal (selector de app)
├── manifest.webmanifest
├── sw.js ← Service Worker (cache + push handler)
├── offline.html
└── README.md
Convenciones de código
- JS: vanilla ES2020. IIFE por archivo.
constpor defecto. - CSS: BEM-light con prefijos
.btn-*,.card-*. Variables CSS con prefijo--. - PHP: PSR-12 friendly (sin estricto). Constante
ANDEN_BOOTobligatoria para evitar acceso directo a libs. - SQL: prefijo dinámico
{p}. PDO con bind named params. - Hash IDs: prefijo de 2-3 chars (
tr_trips,dr_drivers,au_audit, etc.). - Audit: cada acción sensible → fila en
{p}auditcon hash chain (ver §audit).
PWA & Service Worker
Cada app es instalable. El manifest.webmanifest declara start_url, scope, icons (192px + 512px maskable) y display "standalone".
Estrategia de cache
// sw.js
const CACHE = 'anden-v17'; // bump en cada release para invalidar
const APP_SHELL = [
'./', 'index.html', 'offline.html',
'shared/css/tokens.css', ...
];
// HTML → network-first con fallback a offline.html
// CSS/JS/IMG → stale-while-revalidate (sirve cache, actualiza en bg)
// Fuentes Google → cache-first 30 días (FONT_CACHE separado)
// Cross-origin (excepto fuentes) → bypass al network
Lifecycle
install → cache.addAll(APP_SHELL) → skipWaiting()
activate → claim() + borrar caches viejos
fetch → routing por tipo de recurso
message → 'SKIP_WAITING' fuerza activación
push → notification API
Design tokens
Todo el sistema visual está centralizado en shared/css/tokens.css. Dos modos completos: light y dark. Cambio en vivo vía atributo [data-theme] en <html>.
:root {
/* Brand */
--brand-500: #10b981;
--brand-600: #059669;
/* Surfaces (dark default) */
--bg-0: #0b0d12; /* page */
--bg-1: #11141c; /* card */
--bg-2: #161a25; /* hover */
--bg-3: #1d2230; /* input */
/* Text con contraste WCAG AA */
--text-0: #f4f5fa;
--text-1: #c8cdda;
--text-2: #8a91a3;
/* Status duales */
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
}
[data-theme="light"] {
--bg-0: #ffffff;
--bg-1: #fafbfc;
/* … */
}
Mapas (canvas custom · 60 fps)
El mapa NO usa Mapbox/Leaflet/Google Maps. Es un canvas 2D propio con proyección equirectangular centrada. Esto da ahorro de coste (sin tiles externos) y control total sobre POIs, rutas y rendering.
Optimizaciones clave
- rAF dedupe:
_scheduleDraw()colapsa N mutaciones del mismo frame en 1 sólo paint. - Grid cache:
_getStreetNet()memoiza porcenter(4 decimales ≈ 11 m). Drag estacionario = 0 allocations. - Theme-aware:
_palette()+ MutationObserver en[data-theme]repinta al cambiar tema. - 14 colores brand por tipo de POI (aeropuerto azul · hospital rojo · …).
- imageSmoothingQuality = 'high' +
lineCap/Join = 'round'globales. - dpr recalculado en cada
_resize()(multimonitor + DPI dinámico).
Ahora: 1 paint/frame máximo · ~0 allocations en drag estacionario · 0 long-tasks.
i18n
Sistema simple basado en diccionarios JS por idioma. 4 idiomas: ES (CO/ES) · EN · FR · IT.
// shared/js/i18n.js
I18n.AVAILABLE = ['es', 'en', 'fr', 'it'];
I18n.setLang('es');
I18n.t('common.confirm'); // → "Confirmar"
// boot.js auto-detect
const guess = navigator.language.slice(0, 2);
if (I18n.AVAILABLE.includes(guess)) {
I18n.setLang(guess);
localStorage.setItem('anden.langAutoSet', '1');
}
Modelo de datos
El núcleo del esquema: ~20 tablas. Cada tabla con prefijo dinámico {p} resuelto en runtime por DB.php.
| Tabla | Propósito | Claves |
|---|---|---|
users | Cuenta universal (email · rol · tenant) | email UNIQUE, role |
sessions | Tokens de sesión activos | token UNIQUE, expires_at |
drivers | Perfil de conductor | user_id FK, status |
passengers | Perfil de pasajero | user_id FK |
vehicles | Vehículos asociados a conductores | driver_id FK, plate |
trips | Viajes (request → completed) | passenger_id · driver_id · status |
services | Servicios extra (XL · Comfort · Premium) | tier |
zones | Zonas de surge con multiplicador | city_id, multiplier |
promos | Códigos promocionales | code UNIQUE |
payments | Pagos + columnas DIAN | trip_id FK, processor |
notifications | Notificaciones in-app + push | user_id, is_read |
audit | Log con hash chain firmado | at, action, prev_hash |
tax_profiles | Perfil fiscal (NIT · razón social) | user_id FK |
kyc_docs | Documentos KYC + Truora | driver_id, status |
stripe_accounts | Cuenta Connect del conductor | driver_id FK |
support_tickets | Tickets DSR + ayuda general | user_id, type |
dian_events | Webhook log + idempotencia | invoice_id, cufe |
truora_verifications | Sesiones Truora (KYC CO) | validation_id UNIQUE |
truora_events | Webhook log Truora | validation_id |
cities · city_services | Multi-city + precios por ciudad | id, (city_id, service_id) PK |
wompi_transactions · wompi_events | Wompi mirror + webhook log | reference UNIQUE |
quests_claims | Reclamaciones de retos (driver) | (driver_id, quest_id, period_key) UNIQUE |
loyalty_upgrades | Free upgrades consumidos (passenger) | passenger_id, month_key |
loyalty_tier_history | Audit de subidas/bajadas de tier | passenger_id |
referral_attribution | Referrer → referee + payout | referee_user_id UNIQUE |
tenants | Catálogo de empresas SaaS (multi-tenant) | slug UNIQUE, enabled |
password_reset_tokens | Tokens recovery server-side (TTL 30min) | token PK, user_id, expires_at |
settings | Key/value settings (feature flags) | k UNIQUE |
_migrations | Registro de migraciones aplicadas | version UNIQUE |
php/schema.sql
(consolidado). El Installer registra todas las migraciones del disco
como aplicadas tras un fresh install para evitar re-aplicar ALTERs ya incluidos.
v1.5.0 añade la tabla tenants, la columna
tenant_id en los 8 recursos multi-tenant y la tabla
password_reset_tokens para recovery cross-device.
Ver §Migraciones y §Multi-tenant.
Auth & sesiones
Auth basado en token bearer con sessions en BD. Sin JWT (mantiene revocación inmediata).
POST /php/auth.php?action=login
Body: { email, password }
→ { token, user: { id, role, name, email, tenant_id } }
// Cliente almacena token en localStorage 'anden.session'
// Cada request siguiente: header
Authorization: Bearer <token>
Roles y permisos
El árbol de roles vive en shared/js/permissions.js. Roles compuestos (passenger-group, driver-group) se expanden a sus sub-roles para chequeos de Auth.require().
Permissions.appOf('passenger_vip') // → 'passenger'
Permissions.expand('passenger-group') // → ['passenger', 'passenger_vip', 'passenger_business']
Auth.require(['admin-group']) // bloquea acceso si no
API REST
api.php expone un CRUD whitelist sobre 16 recursos con la misma forma.
| Método | URL | Acción |
|---|---|---|
| GET | /api.php?r=trips | List · filtros via query |
| GET | /api.php?r=trips&id=tr_X | Get one |
| POST | /api.php?r=trips | Create (cifra PII al guardar) |
| PATCH | /api.php?r=trips&id=tr_X | Update (merge parcial) |
| DELETE | /api.php?r=trips&id=tr_X | Soft-delete |
PII se cifra automáticamente al guardar y se descifra al leer. Lista en _sensitiveFields() (ver §crypto).
Multi-tenant
Andén soporta múltiples empresas operadoras en una sola instalación. Default OFF para simplificar desarrollo. El flag global vive en API.settings().multi_tenant; lo persiste el módulo shared/js/tenants.js.
Dónde se activa/desactiva
Sólo el rol super_admin ve la sidebar item Tenants. Dentro de esa vista vive la master card "Modo Multi-tenant (SaaS)" con switch + Guardar + Guía de despliegue. El switch opera en modo pendiente: hasta que pulsas Guardar no se persiste nada (evita toggles accidentales). El resto de roles del panel admin (dispatcher · ops_manager · fleet_manager · finance · support) no ven nunca esta tarjeta — incluidas todas las cuentas demo.
Workflow ON (single → SaaS)
- Crea/edita tenants en la misma vista (color de marca, slug, email de soporte por empresa).
- Asigna
tenantIda cada usuario existente (admin/dispatcher/support/etc.). Datos legacy sintenantIdcaen al tenant default (anden). - Pulsa el switch a ON → botón Guardar → confirma el diálogo.
- Verifica que aparece el selector "Todos los tenants" en el topbar (sólo super_admin).
- (Remote-mode v1.5+) El backend ya filtra automáticamente. Persiste
multi_tenant=trueen{p}settings(lo haceTenants.setEnabled()viaAPI.settings()) yapi.phpaplicaráWHERE tenant_id = :scopea las 8 tablas escopadas (users/drivers/passengers/vehicles/trips/zones/promos/payments) según el headerX-Tenant-Id. Usuarios no privilegiados quedan confinados a suuser.tenant_idaunque manden header. - Opcional: configura subdominios (
.htaccessreescribe host → headerX-Tenant-Slugqueapi.phpmapea atenantId).
Backend tenant scoping (v1.5+)
El filtrado en api.php se hace en 3 capas:
// php/api.php (resumen)
_multiTenantEnabled($db) // lee settings.multi_tenant; null si OFF
_tenantScopedTables() // ['users','drivers','passengers','vehicles',
// 'trips','zones','promos','payments']
_resolveTenantScope($db, $user)
→ null si OFF o super_admin con header 'all'
→ 'anden' default si privilegiado sin header
→ user.tenant_id forzado para non-priv (no pueden bypassear)
→ header X-Tenant-Id sanitizado, si privilegiado y válido
// En _listResource:
if ($tenantScope !== null && in_array($table, _tenantScopedTables())) {
$where[] = "(tenant_id = :tenant OR tenant_id IS NULL)";
$params[':tenant'] = $tenantScope;
}
El OR tenant_id IS NULL existe como red de seguridad para datos legacy que aún no se hayan backfileado. La migración v1.5.0 hace backfill a 'anden' automáticamente, así que en instalaciones nuevas no debería haber NULLs.
Workflow OFF (SaaS → single)
- Switch a OFF → Guardar → confirmar. Todos los admins pasan a ver TODOS los datos (sin filtro).
- Los datos siguen llevando
tenantIden BD — no se borra nada. Re-activar restaura el aislamiento al instante. - El selector de topbar desaparece. La nav vuelve al estado simple.
API programática
// Activar / desactivar
Tenants.setEnabled(true); // dispara evento 'tenants:toggle'
Tenants.isEnabled(); // boolean
// Filtrado declarativo en queries
Tenants.filter(API.list('trips')); // no-op cuando OFF; aplica tenantId actual cuando ON
// Selector activo (super_admin)
Tenants.current(); // 'all' | tenantId
Tenants.setCurrent('taxis24'); // dispara 'tenants:change'
// Helpers
Tenants.belongsTo(row); // true si row.tenantId coincide con el actual (o si OFF)
Tenants.label(); // nombre legible del tenant activo
Comportamiento por estado
| Aspecto | OFF | ON |
|---|---|---|
| Listados (users / drivers / trips) | Sin filtro | Filtrados por tenantId |
| Selector topbar | Oculto | Visible (super_admin) |
| Item "Tenants" en sidebar | Visible (super_admin) | Visible (super_admin) |
| Multi-tenant card en Ajustes | Oculta a todos | Oculta a todos (vive en Tenants) |
| Branding por empresa | Global | Por tenant |
| Aislamiento Stripe/Wompi | Compartido | Aislado por tenantId |
Audit firmado (hash chain)
Cada acción sensible (login · pago · DSR · KYC · push broadcast · etc.) se persiste en {p}audit con un campo hash que encadena con el prev_hash de la fila anterior. Imposible alterar el log sin reescribir todo.
// php/lib/Audit.php
$prev = $db->fetch("SELECT hash FROM {p}audit ORDER BY at DESC LIMIT 1");
$payload = $action . '|' . $who . '|' . $detail . '|' . $at;
$hash = hash_hmac('sha256', ($prev['hash'] ?? '') . $payload, $authSecret);
$db->run("INSERT INTO {p}audit (id, action, who, detail, at, hash, prev_hash) VALUES (…)");
Desde admin → Auditoría → botón "Verificar cadena" recalcula y reporta cualquier divergencia.
Pagos (Stripe · Wompi)
Andén soporta 2 procesadores en paralelo. La app pasajero elige el correcto en función de la moneda activa y métodos disponibles.
Stripe (multi-país)
- Connect Express para payouts a conductores
- Multi-currency (EUR · USD · COP · MXN)
- Webhook firmado HMAC-SHA256
- Apple Pay / Google Pay
Wompi (Colombia)
- PSE · Nequi · Daviplata · Bancolombia · tarjetas
- Widget integrity (HMAC para validar montos)
- Webhook firmado
events_secret - Rate-limit: 20 widgets/10 min · 10 tx/10 min
Flujo de pago (Wompi · pasajero)
1. passenger.js → POST /php/wompi.php?action=widget
Body: { trip_id, amount, currency: 'COP', email }
2. wompi.php → genera signature integrity:
sha256(reference + amount + currency + integritySecret)
3. Respuesta { reference, integrity, publicKey }
4. Frontend abre WidgetCheckout(reference, integrity, publicKey)
con Nequi/PSE/Daviplata/tarjeta
5. Wompi → callback URL → wompi.php?action=callback
Verifica signature, lee /transactions/{id}, marca payment.captured
6. SSE → app pasajero recibe trip.paid
KYC (Truora)
Verificación automática de cédula CO + selfie + RUNT en flujo hosted. Skeleton listo, requiere cuenta real Truora.
// admin → Ajustes → Truora card
truora_api_key = "tk_test_…"
truora_client_id = "…" // HMAC webhook
truora_account_id = "acc_…"
truora_sandbox = true
truora_enabled = false // toggle global
// Flow conductor
1. driver.js POST /php/kyc.php?action=start-validation
2. Truora → redirect_url → driver completa el flujo
3. Webhook → kyc.php?action=webhook (HMAC verify)
4. Mapea status → kyc_docs.status (approved/rejected/pending/expired)
DIAN e-invoicing
Cliente PT-agnostic con 4 adapters. Default mock para dev sin coste. Producción: Facture · Alegra · Siigo.
// Diagrama de flujo
trip.completed
↓
cron dian_emit_auto (cada 60 min · B2B only)
↓
Dian::buildPayloadFromTrip(trip, payment, tax_profile)
↓
Dian::emitInvoice(payload) // adapter pattern
↓
Proveedor Tecnológico → DIAN
↓
{ cufe, pdf_url, status }
↓
UPDATE payments SET dian_cufe, dian_invoice_id, dian_status, dian_pdf_url
| Provider | URL base | Auth | Body shape |
|---|---|---|---|
mock | mock:// | — | CUFE pseudo-determinista local |
facture | api.facture.co/v1 | Bearer | = payload canónico |
alegra | api.alegra.com/api/v1 | Basic (email:token) | { client, items, stamp } |
siigo | api.siigo.com/v1 | Bearer | { document.id, customer, items } |
Push notifications
WebPush con VAPID keys generadas durante la instalación. Backend en php/push.php.
// Cliente (shared/js/push-client.js)
PushClient.subscribe() // pide permission + crea subscription
PushClient.localNotify(opts) // toast in-app sin push real
PushClient.broadcast({ payload, role: 'passenger_vip' })
// Backend
push.php?action=subscribe → guarda { endpoint, p256dh, auth }
push.php?action=send → dispatch a all/role/userId
push.php?action=unsubscribe
Habeas Data (Ley 1581 + RGPD)
Pipeline DSR completo en php/legal.php:
- GET /me/data-export → bundle JSON con perfil + trips + payments + tickets + kyc_docs
- POST /dsr → crea un ticket de solicitud (export · rectify · delete)
- POST /dsr/{id}/process → admin ejecuta la acción + audita
- POST /me/anonymize → reemplaza nombre/email/phone con valores genéricos en TODAS las tablas
purge_pii anonimiza trips > 24 meses automáticamente. DSRs > 12 días pendientes generan badge rojo en admin.
Crypto / PII encryption
PII se cifra en BD con AES-256-GCM (prefijo v1.) usando php/lib/Crypto.php. La clave maestra deriva de config.php → auth_secret.
Campos cifrados
| Tabla | Campo | Tipo |
|---|---|---|
users | phone | scalar |
passengers | saved_places · payment_methods | json |
support_tickets | subject · body | scalar (texto libre) |
notifications | body | scalar |
payments | processor_ref | scalar |
kyc_docs | notes | scalar |
tax_profiles | legal_id (NIT/CC) | scalar (vía legal.php) |
API
Crypto::init($cfg['auth_secret']);
$blob = Crypto::encrypt('1234567890'); // → "v1.AbC123…"
$clear = Crypto::decrypt($blob); // → "1234567890"
// Backfill rows plaintext legacy (idempotente)
$r = Crypto::backfillTable($db, 'users', 'phone', 'scalar', 200);
// → { ok, scanned, encrypted, errors }
// Helpers safe (no rompen si Crypto no inicializado)
$db_val = Crypto::encryptField($value);
$plain = Crypto::decryptField($db_val);
// Cron job 'encrypt_pii_backfill' lo corre nightly
Validadores fiscales CO
Crypto::nitDV('900123456'); // → 1 (DV de DIAN)
Crypto::isValidCC('1098765432'); // → true
Crypto::isValidDNI('12345678Z'); // → true (DNI español)
Crypto::redact('1098765432', 2); // → "10••••••32"
Crypto::blindIndex($value, $ns); // → HMAC-SHA256 (search-able)
CSRF & rate-limit
- CSRF: cada session genera un token único expuesto en cookie HttpOnly. Las requests POST verifican
X-CSRF-Tokenheader contra cookie. - Rate-limit:
php/lib/RateLimit.phpcon buckets por IP + endpoint. Wompi widget 20/10min, transaction 10/10min, auth 10 fallos/15min. - Bcrypt: passwords con cost 12.
- Constant-time:
hash_equals()en todas las verificaciones de HMAC/tokens.
Cron jobs
10 jobs registrados en php/cron.php. Llamar el archivo cada minuto desde cron Linux:
* * * * * /usr/bin/php /home/user/public_html/php/cron.php > /dev/null 2>&1
El runner interno decide qué jobs ejecutar según su every (minutos) y su última ejecución registrada.
| Job | Cadencia | Qué hace |
|---|---|---|
expire_reset_tokens | 60 min | Borra tokens reset password > 30 min |
purge_audit | 24 h | Borra audit log > 90 días (excepto compliance) |
kyc_reminders | 24 h | Notifica drivers con docs pendientes/caducados |
expire_kyc | 24 h | Marca kyc_docs con expires_at < today |
cancel_stale_requests | 5 min | Cancela peticiones huérfanas > 10 min |
purge_dead_push | 24 h | Borra suscripciones inactivas > 60 días |
stripe_payouts | 60 min | Transferencias a conductores con saldo > umbral |
purge_pii | 24 h | Anonimiza trips > 24 meses (Ley 1581/RGPD) |
encrypt_pii_backfill | 24 h | Cifra filas legacy plaintext (idempotente) |
dian_emit_auto | 60 min | Emite factura electrónica para trips B2B sin CUFE |
demo_refresh | 24 h | Trim trips antiguos en modo demo |
Migraciones
Archivos v*.sql en php/migrations/. Discovery automático por php/lib/Upgrader.php, ordenado por semver, registrado en {p}_migrations.
// Aplicar migraciones pendientes (idempotente)
GET /php/upgrade.php?action=status // ver pendientes
GET /php/upgrade.php?action=preview // ver SQL sin ejecutar
POST /php/upgrade.php?action=apply // ejecutar + snapshot
POST /php/upgrade.php?action=rollback // restaurar último snapshot
UI gráfica: /upgrade.html — para superadmin.
Schema consolidado (v1.4.0+)
php/schema.sql ya contiene todas las columnas y tablas que
las migraciones añadieron incrementalmente. En un fresh install, el
Installer ejecuta schema.sql y luego registra cada migración
del disco como aplicada (sin ejecutar su SQL).
// php/lib/Installer.php (resumen)
1. runScript(schema.sql) // crea todo el esquema
2. INSERT IGNORE {p}_migrations // marca v1.0.0..v1.5.0 como aplicadas
3. resto del wizard (admin, settings, demo, config.php, install.lock)
En v1.5.0 la migración utiliza un procedimiento defensivo
({p}_v150_add_tenant_id) que consulta information_schema.COLUMNS
antes de cada ALTER TABLE ADD COLUMN. Esto permite re-aplicar la migración
sobre instalaciones que ya tengan parte del esquema (compatible con MySQL 5.7+
y MariaDB 10.3+ donde ADD COLUMN IF NOT EXISTS no existe nativamente).
Al final del procedimiento se hace backfill: cualquier fila con
tenant_id IS NULL queda asignada al tenant default 'anden'.
Esto evita el bug clásico de ALTER TABLE ADD COLUMN sobre columnas
que ya existen, típico cuando schema.sql y las migraciones
terminan duplicando DDL. Las migraciones en disco se conservan exclusivamente
para upgrades incrementales desde instalaciones legacy (<v1.4.0).
Engagement layer (v1.4.0+)
Tres motores que comparten ciclo de vida y persistencia. Computo client-side
(instantáneo offline-first) y reflejo server-side para auditoría + sync
cross-device. Feature-flags por defecto:
quests_enabled=1, loyalty_enabled=1, referral_enabled=1
en {p}settings.
Quests · conductor
- Retos diarios + semanales con reward en EUR/COP
quests_claimsconUNIQUE(driver_id, quest_id, period_key)- Auto-notificación al completar (memo cache anti-spam)
- Suma al campo
drivers.bonus_creditsen cada claim
Loyalty · pasajero
- 4 tiers: Bronze · Silver · Gold · Platinum
- Umbrales por trips + spend en los últimos 90 días
- Descuento automático + free upgrades mensuales por tier
loyalty_upgradescontabiliza consumo ·loyalty_tier_historyauditaría
Referidos
- Código único por usuario en
users.referral_code - Welcome bonus al registrarse (referee)
- Payout al referrer en el primer trip completado
referral_attributionconUNIQUE(referee_user_id)(un solo referrer)
Admin panel
- Sección Engagement con 3 tabs (Retos · Loyalty · Referidos)
- Ajustes editables sin tocar código (valores en
settings) - Confetti CSS al subir de tier · toasts en claim de reto
- Modal de desglose de bonus en wallet del pasajero
Quests · modelo de datos
// shared/js/quests.js · API client-side
Quests.list(driverId) // retos vigentes (diario + semanal)
Quests.progress(driverId, questId) // { current, target, pct }
Quests.claim(driverId, questId) // marca reclamado + suma bonus_credits
// Backend: php/api.php · mutate quests_claims
POST /php/api.php?action=mutate
{ resource: 'quests_claims', op: 'create',
payload: { driver_id, quest_id, period: 'daily'|'weekly', period_key, reward, metadata } }
period_key sigue el formato YYYY-MM-DD (daily) o YYYY-WNN
(weekly). El UNIQUE compuesto bloquea dobles claims en
escenarios multi-device + race.
Loyalty · cómputo del tier
// shared/js/loyalty.js
Loyalty.tier(passengerId) // { tier, trips, spend, nextTier, progressPct }
Loyalty.discount(tier, fare) // 0% Bronze · 5% Silver · 10% Gold · 15% Platinum
Loyalty.canFreeUpgrade(passengerId, monthKey) // true si quedan upgrades del mes
Loyalty.consumeUpgrade(passengerId, tripId, fromTier, toTier, savedAmount)
// Umbrales editables en {p}settings (loyalty_silver_min_trips, …)
Ventana móvil: por defecto 90 días (loyalty_window_days). El
tier se recalcula al cargar el dashboard del pasajero y al cierre de
cada trip completado.
Referidos · flujo
1. user.signup() → si trae ?ref=CODE en URL/query:
POST /php/api.php?action=mutate
{ resource: 'referral_attribution', op: 'create',
payload: { referrer_user_id, referee_user_id, referrer_code,
welcome_amount: 5, status: 'pending' } }
2. Backend acredita welcome_amount al referee:
UPDATE {p}passengers SET wallet_balance = wallet_balance + 5 WHERE user_id = :referee
3. trip.completed → cron 'referral_payouts' (cada 60min):
por cada attribution status='pending' con trip completed:
UPDATE {p}users SET referral_credits_earned += payout_amount, referral_payout_at = NOW()
UPDATE {p}referral_attribution SET status = 'paid', payout_at = NOW()
welcome_amount y payout_amount se leen de
referral_referee_welcome / referral_referrer_payout en settings
(EUR por defecto, configurable desde admin).
Instalación
-
Requisitos
PHP 8.0+ (8.2 recomendado), MySQL 5.7.8+ o MariaDB 10.3+, extensiones:
pdo_mysql · openssl · curl · mbstring · json · gd(opcional para QR). -
Subir archivos
Subir todo el repo a
public_html/(o subdominio). Permisos 755 en directorios, 644 en archivos. Excepción:php/storage/yphp/migrations/a 775. -
Crear BD
En cPanel: crear base + usuario + asignar permisos. Anota credenciales.
-
Ejecutar wizard
Abre
install.htmlen el navegador. Te pide credenciales BD, primer super_admin, email/contraseña, y configuraconfig.php+ ejecutaschema.sql+ aplica migraciones.El wizard genera auth_secret aleatorio (32 bytes hex). Es la clave maestra de Crypto. Guárdalo: si la pierdes, los PII cifrados son irrecuperables. -
Configurar cron
En cPanel → Cron Jobs → cada minuto:
/usr/bin/php /home/user/public_html/php/cron.php -
Activar HTTPS
El SW y WebPush requieren HTTPS. Activar Let's Encrypt en cPanel (gratis).
-
Configurar integraciones (opcional)
Desde admin → Ajustes:
- Stripe: secret_key + publishable_key + webhook secret
- Wompi: public_key + private_key + integrity_secret + events_secret
- Truora: api_key + client_id (HMAC) + account_id
- DIAN: provider (Facture/Alegra/Siigo) + API key + NIT + webhook_secret
- VAPID:
php/install.php?action=generate-vapidgenera el par
Troubleshooting
"Pantalla en blanco al login"
Limpia SW antiguo: DevTools → Application → Service Workers → Unregister + Storage → Clear site data. Recarga Ctrl+Shift+R.
"Migración pendiente bloquea login"
Abrir /upgrade.html como super_admin → Aplicar pendientes. Si _migrations no existe, ejecutar install.html?action=migrations-only.
"Wompi widget no abre"
Verifica integrity_secret en settings vs el del dashboard Wompi. La firma se calcula sha256(reference+amount+currency+integrity).
"Truora webhook rechaza"
El HMAC usa el client_id, no la api_key. Verifica que client_id está correctamente guardado en settings.
"PII no se descifra"
Falta auth_secret en config.php. Sin él, Crypto::isReady() === false y los blobs v1.* se devuelven crudos.
"Push no llegan"
VAPID keys configuradas en config.php. El usuario debe haber tocado un botón antes (gesture-required en muchos navegadores).
Logs
Cada componente tiene su logger:
php/storage/logs/api.log ← REST API
php/storage/logs/auth.log ← login / register / reset
php/storage/logs/wompi.log ← Wompi widget + webhook
php/storage/logs/stripe.log ← Stripe webhook
php/storage/logs/kyc.log ← Truora flow
php/storage/logs/dian.log ← DIAN emit + webhook
php/storage/logs/cron.log ← jobs nightly
php/storage/logs/push.log ← VAPID dispatch