Manual técnico

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.

Lectura · 50 min
v1.5.0

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.

CLIENTE (PWA) BACKEND PHP EXTERNOS App Pasajero /passenger PWA · offline-first App Conductor /driver GPS · push · audio App Admin /admin SSE · canvas map php/ · REST API + SSE api.php auth.php wompi.php stripe.php kyc.php dian.php push.php legal.php cron.php sse.php MySQL Crypto Stripe / Wompi Truora KYC Facture · DIAN VAPID push

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


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
Al cambiar archivos del shell, bump del CACHE es obligatorio o los usuarios verán versiones cacheadas viejas. La PWA muestra un toast "Nueva versión disponible · recargar".

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

Métricas reales (drag rápido)
Antes: 2-4 paints/frame · ~6.000 allocations/s en drag estacionario · long-tasks ocasionales.
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.

TablaPropósitoClaves
usersCuenta universal (email · rol · tenant)email UNIQUE, role
sessionsTokens de sesión activostoken UNIQUE, expires_at
driversPerfil de conductoruser_id FK, status
passengersPerfil de pasajerouser_id FK
vehiclesVehículos asociados a conductoresdriver_id FK, plate
tripsViajes (request → completed)passenger_id · driver_id · status
servicesServicios extra (XL · Comfort · Premium)tier
zonesZonas de surge con multiplicadorcity_id, multiplier
promosCódigos promocionalescode UNIQUE
paymentsPagos + columnas DIANtrip_id FK, processor
notificationsNotificaciones in-app + pushuser_id, is_read
auditLog con hash chain firmadoat, action, prev_hash
tax_profilesPerfil fiscal (NIT · razón social)user_id FK
kyc_docsDocumentos KYC + Truoradriver_id, status
stripe_accountsCuenta Connect del conductordriver_id FK
support_ticketsTickets DSR + ayuda generaluser_id, type
dian_eventsWebhook log + idempotenciainvoice_id, cufe
truora_verificationsSesiones Truora (KYC CO)validation_id UNIQUE
truora_eventsWebhook log Truoravalidation_id
cities · city_servicesMulti-city + precios por ciudadid, (city_id, service_id) PK
wompi_transactions · wompi_eventsWompi mirror + webhook logreference UNIQUE
quests_claimsReclamaciones de retos (driver)(driver_id, quest_id, period_key) UNIQUE
loyalty_upgradesFree upgrades consumidos (passenger)passenger_id, month_key
loyalty_tier_historyAudit de subidas/bajadas de tierpassenger_id
referral_attributionReferrer → referee + payoutreferee_user_id UNIQUE
tenantsCatálogo de empresas SaaS (multi-tenant)slug UNIQUE, enabled
password_reset_tokensTokens recovery server-side (TTL 30min)token PK, user_id, expires_at
settingsKey/value settings (feature flags)k UNIQUE
_migrationsRegistro de migraciones aplicadasversion UNIQUE
Desde v1.4.0 el esquema completo vive en 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étodoURLAcción
GET/api.php?r=tripsList · filtros via query
GET/api.php?r=trips&id=tr_XGet one
POST/api.php?r=tripsCreate (cifra PII al guardar)
PATCH/api.php?r=trips&id=tr_XUpdate (merge parcial)
DELETE/api.php?r=trips&id=tr_XSoft-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)

  1. Crea/edita tenants en la misma vista (color de marca, slug, email de soporte por empresa).
  2. Asigna tenantId a cada usuario existente (admin/dispatcher/support/etc.). Datos legacy sin tenantId caen al tenant default (anden).
  3. Pulsa el switch a ON → botón Guardar → confirma el diálogo.
  4. Verifica que aparece el selector "Todos los tenants" en el topbar (sólo super_admin).
  5. (Remote-mode v1.5+) El backend ya filtra automáticamente. Persiste multi_tenant=true en {p}settings (lo hace Tenants.setEnabled() via API.settings()) y api.php aplicará WHERE tenant_id = :scope a las 8 tablas escopadas (users/drivers/passengers/vehicles/trips/zones/promos/payments) según el header X-Tenant-Id. Usuarios no privilegiados quedan confinados a su user.tenant_id aunque manden header.
  6. Opcional: configura subdominios (.htaccess reescribe host → header X-Tenant-Slug que api.php mapea a tenantId).

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)

  1. Switch a OFF → Guardar → confirmar. Todos los admins pasan a ver TODOS los datos (sin filtro).
  2. Los datos siguen llevando tenantId en BD — no se borra nada. Re-activar restaura el aislamiento al instante.
  3. 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

AspectoOFFON
Listados (users / drivers / trips)Sin filtroFiltrados por tenantId
Selector topbarOcultoVisible (super_admin)
Item "Tenants" en sidebarVisible (super_admin)Visible (super_admin)
Multi-tenant card en AjustesOculta a todosOculta a todos (vive en Tenants)
Branding por empresaGlobalPor tenant
Aislamiento Stripe/WompiCompartidoAislado 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
ProviderURL baseAuthBody shape
mockmock://CUFE pseudo-determinista local
factureapi.facture.co/v1Bearer= payload canónico
alegraapi.alegra.com/api/v1Basic (email:token){ client, items, stamp }
siigoapi.siigo.com/v1Bearer{ 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:

SLA Ley 1581 = 15 días hábiles
El cron 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

TablaCampoTipo
usersphonescalar
passengerssaved_places · payment_methodsjson
support_ticketssubject · bodyscalar (texto libre)
notificationsbodyscalar
paymentsprocessor_refscalar
kyc_docsnotesscalar
tax_profileslegal_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


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.

JobCadenciaQué hace
expire_reset_tokens60 minBorra tokens reset password > 30 min
purge_audit24 hBorra audit log > 90 días (excepto compliance)
kyc_reminders24 hNotifica drivers con docs pendientes/caducados
expire_kyc24 hMarca kyc_docs con expires_at < today
cancel_stale_requests5 minCancela peticiones huérfanas > 10 min
purge_dead_push24 hBorra suscripciones inactivas > 60 días
stripe_payouts60 minTransferencias a conductores con saldo > umbral
purge_pii24 hAnonimiza trips > 24 meses (Ley 1581/RGPD)
encrypt_pii_backfill24 hCifra filas legacy plaintext (idempotente)
dian_emit_auto60 minEmite factura electrónica para trips B2B sin CUFE
demo_refresh24 hTrim 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_claims con UNIQUE(driver_id, quest_id, period_key)
  • Auto-notificación al completar (memo cache anti-spam)
  • Suma al campo drivers.bonus_credits en 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_upgrades contabiliza consumo · loyalty_tier_history auditarí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_attribution con UNIQUE(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

  1. 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).

  2. Subir archivos

    Subir todo el repo a public_html/ (o subdominio). Permisos 755 en directorios, 644 en archivos. Excepción: php/storage/ y php/migrations/ a 775.

  3. Crear BD

    En cPanel: crear base + usuario + asignar permisos. Anota credenciales.

  4. Ejecutar wizard

    Abre install.html en el navegador. Te pide credenciales BD, primer super_admin, email/contraseña, y configura config.php + ejecuta schema.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.
  5. Configurar cron

    En cPanel → Cron Jobs → cada minuto: /usr/bin/php /home/user/public_html/php/cron.php

  6. Activar HTTPS

    El SW y WebPush requieren HTTPS. Activar Let's Encrypt en cPanel (gratis).

  7. 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-vapid genera 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