# checkout-m

Módulo de checkout embebible en JavaScript. No usa `iframe`, sesiones PHP, `includes/class.system.php`, la carpeta `modules` ni archivos de `OLD` en tiempo de ejecución.

## Requisitos

- PHP 8.1+ con `curl`, `mbstring` y `sodium`.
- HTTPS en producción.
- La carpeta `wao-co-8x4/private/storage` debe ser escribible por PHP y no debe servirse públicamente.
- Variables de entorno basadas en `wao-co-8x4/private/.env.example`; `wao-co-8x4/private/bootstrap.php` carga `wao-co-8x4/private/.env` si existe.
- `CHECKOUT_PUBLIC_BASE_URL` debe apuntar a la URL HTTPS pública del módulo; se usa para retornos y webhooks sin confiar en el encabezado `Host`.

Genere las llaves una sola vez:

```bash
php -r "echo base64_encode(random_bytes(32)), PHP_EOL;"
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
```

La primera salida se configura como `CHECKOUT_TOKEN_KEY_K2026`; la segunda como `CHECKOUT_STATE_SIGNING_KEY`.

## Puesta en marcha local con XAMPP

1. Confirme que Sodium está habilitado en el PHP de XAMPP:

```powershell
C:\xampp\php\php.exe -r "echo extension_loaded('sodium') ? 'Sodium OK' : 'Sodium NO';"
```

2. Genere una sola vez las dos llaves y guárdelas en un administrador de contraseñas. No las agregue al repositorio:

```powershell
C:\xampp\php\php.exe -r "echo base64_encode(random_bytes(32)), PHP_EOL;"
C:\xampp\php\php.exe -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
```

3. En el `VirtualHost` local de `C:\xampp\apache\conf\extra\httpd-vhosts.conf`, agregue las variables siguientes sustituyendo los valores:

```apache
SetEnv CHECKOUT_TOKEN_ACTIVE_KEY "k2026"
SetEnv CHECKOUT_TIMEZONE "America/Mexico_City"
SetEnv CHECKOUT_TOKEN_KEY_K2026 "LLAVE_BASE64_DE_32_BYTES"
SetEnv CHECKOUT_STATE_SIGNING_KEY "LLAVE_DE_FIRMA"
SetEnv CHECKOUT_PUBLIC_BASE_URL "https://localhost/plugins.waopay.app/checkout-m/"
SetEnv CHECKOUT_ALLOWED_ORIGINS "https://localhost"
SetEnv CHECKOUT_API_LOG "0"
SetEnv CHECKOUT_CREDENTIAL_CLAIMS_TTL_SECONDS "3600"
SetEnv YSV_API_URL "https://api.yosoyvendedor.com/merchant/"
```

Reinicie Apache desde XAMPP después de guardar. Si la página de prueba se abre con otro origen —por ejemplo `https://plugins.waopay.app`— use exactamente ese origen en `CHECKOUT_ALLOWED_ORIGINS`.

Clip no permite montar sus campos seguros dentro de una página servida por `http://`, incluido `http://localhost`. XAMPP incluye una configuración SSL en el puerto 443; abra primero `https://localhost`, acepte el certificado local si el navegador lo solicita y mantenga todo el recorrido bajo HTTPS.

4. Genere la credencial desde `wao-credential-vault` usando `module: "checkout"`. El modulo ya no acepta credenciales generadas localmente con `private/cli/encrypt-token.php`.

5. Copie la credencial `wco1...` resultante en `example.html`, inicie Apache y visite:

```text
https://localhost/plugins.waopay.app/checkout-m/example.html
```

El bearer token y las llaves maestras nunca deben colocarse en `embed.js` ni en `example.html`; ahí sólo se coloca la credencial cifrada.

### Log de Merchant API

El log de Merchant API está apagado por defecto. Con `CHECKOUT_API_LOG=1`, cada petición y respuesta sanitizada se registra en:

```text
checkout-m/wao-co-8x4/private/logs/merchant-api-AAAA-MM-DD.log
```

El bearer token, credenciales de pago y datos personales se redactan. Mantenga `CHECKOUT_API_LOG=0` en producción y actívelo sólo durante diagnóstico.

## Instalación en Ubuntu 24.04 con HestiaCP

Los ejemplos usan `USUARIO=admin`, `DOMINIO=plugins.ejemplo.com` y el webroot `/home/admin/web/plugins.ejemplo.com/public_html`. Sustituya esos valores por los reales de Hestia.

### 1. Crear el dominio y habilitar HTTPS

Desde HestiaCP cree o edite el dominio del módulo, active **SSL Support** y **Let's Encrypt Support**, y compruebe que `https://DOMINIO` abre sin advertencias. HTTPS es obligatorio para Clip.

### 2. Instalar y verificar PHP

Ubuntu 24.04 utiliza normalmente PHP 8.3. Instale las extensiones para la misma versión PHP-FPM seleccionada en Hestia:

```bash
sudo apt update
sudo apt install php8.3-cli php8.3-fpm php8.3-common php8.3-curl php8.3-mbstring
sudo phpenmod -v 8.3 sodium
sudo systemctl restart php8.3-fpm

php8.3 -r "echo PHP_VERSION, PHP_EOL;"
php8.3 -r "foreach (['curl','mbstring','sodium'] as \$e) echo \$e, '=', extension_loaded(\$e) ? 'OK' : 'NO', PHP_EOL;"
```

Si el dominio usa otra versión, reemplace `8.3` en paquetes, comandos y servicio.

### 3. Publicar únicamente el módulo nuevo

No despliegue `OLD`; no forma parte del módulo ni tiene dependencias runtime:

```bash
sudo mkdir -p /home/USUARIO/web/DOMINIO/public_html/checkout-m
sudo rsync -a --exclude='OLD/' /ruta/al/repositorio/checkout-m/ /home/USUARIO/web/DOMINIO/public_html/checkout-m/
sudo chown -R USUARIO:USUARIO /home/USUARIO/web/DOMINIO/public_html/checkout-m
sudo find /home/USUARIO/web/DOMINIO/public_html/checkout-m -type d -exec chmod 755 {} \;
sudo find /home/USUARIO/web/DOMINIO/public_html/checkout-m -type f -exec chmod 644 {} \;
```

En producción sólo son necesarios `public/` y `wao-co-8x4/`. Los ejemplos y este README pueden omitirse.

### 4. Crear almacenamiento privado

Los checkouts, intentos y logs deben quedar fuera de `public_html`, especialmente cuando Nginx sirve archivos estáticos. HestiaCP protege `/home/USUARIO/conf`, por lo que no debe usarse ni modificarse para el almacenamiento del módulo. Use la carpeta privada del dominio:

```bash
sudo mkdir -p /home/USUARIO/web/DOMINIO/private/checkout-m/storage
sudo mkdir -p /home/USUARIO/web/DOMINIO/private/checkout-m/logs
sudo chown -R USUARIO:USUARIO /home/USUARIO/web/DOMINIO/private/checkout-m
sudo chmod 700 /home/USUARIO/web/DOMINIO/private/checkout-m
sudo chmod 700 /home/USUARIO/web/DOMINIO/private/checkout-m/storage
sudo chmod 700 /home/USUARIO/web/DOMINIO/private/checkout-m/logs
```

PHP-FPM debe ejecutarse como el usuario propietario del dominio y tener escritura en ambas carpetas. La carpeta `private` está fuera de la raíz pública del sitio. No use permisos `777` ni cambie los permisos o atributos de `/home/USUARIO/conf`.

### 5. Generar las llaves maestras

Genérelas una sola vez y guárdelas en un administrador de secretos o respaldo cifrado:

```bash
php8.3 -r "echo base64_encode(random_bytes(32)), PHP_EOL;"
php8.3 -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
```

- Primera salida: `CHECKOUT_TOKEN_KEY_K2026`.
- Segunda salida: `CHECKOUT_STATE_SIGNING_KEY`.

No pierda la primera llave: sin ella no podrán descifrarse las credenciales ya emitidas. Nunca guarde estas llaves en Git o JavaScript.

### 6. Configurar variables en PHP-FPM

Hestia crea normalmente el pool del dominio en `/home/USUARIO/conf/web/DOMINIO/php-fpm.conf`. Dentro del bloque del pool agregue:

```ini
env[CHECKOUT_TIMEZONE] = "America/Mexico_City"
env[CHECKOUT_PUBLIC_BASE_URL] = "https://DOMINIO/checkout-m/"
env[CHECKOUT_ALLOWED_ORIGINS] = "https://tienda1.com,https://www.tienda1.com"
env[CHECKOUT_TOKEN_ACTIVE_KEY] = "k2026"
env[CHECKOUT_TOKEN_KEY_K2026] = "LLAVE_BASE64_DE_32_BYTES"
env[CHECKOUT_STATE_SIGNING_KEY] = "LLAVE_DE_FIRMA"
env[CHECKOUT_STORAGE_PATH] = "/home/USUARIO/web/DOMINIO/private/checkout-m/storage"
env[CHECKOUT_API_LOG_PATH] = "/home/USUARIO/web/DOMINIO/private/checkout-m/logs"
env[CHECKOUT_API_LOG] = "0"
env[CHECKOUT_CREDENTIAL_CLAIMS_TTL_SECONDS] = "3600"
env[YSV_API_URL] = "https://api.yosoyvendedor.com/merchant/"
env[WAOPAY_ORDER_WEBHOOK_URL] = "https://minegocio.waopay.app/sections/webhook-correo.php"
env[CLIP_API_BASE_URL] = "https://api.payclip.com"
env[APLAZO_API_BASE_URL] = "https://api.aplazo.mx/api"
env[APLAZO_STATUS_URL] = "https://merchant.aplazo.mx/api/v1/loan/status"
```

`CHECKOUT_ALLOWED_ORIGINS` contiene los dominios de las tiendas que insertan el script, no el dominio del módulo. No use `*` en producción.

```bash
sudo systemctl restart php8.3-fpm
```

Hestia puede regenerar `php-fpm.conf`. Para conservar la configuración, coloque las directivas en una plantilla personalizada bajo `/usr/local/hestia/data/templates/web/php-fpm/` y selecciónela como **Backend Template**. No edite la plantilla predeterminada.

### 7. Bloquear rutas privadas en Nginx

Añada estas ubicaciones al bloque `server` de una plantilla Nginx personalizada del dominio:

```nginx
location ^~ /checkout-m/wao-co-8x4/private/ {
    return 404;
}

location ^~ /checkout-m/OLD/ {
    return 404;
}
```

Las plantillas están bajo `/usr/local/hestia/data/templates/web/nginx/`. Asígnela como **Proxy Template**, guarde/reconstruya el dominio y valide:

```bash
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl reload apache2 2>/dev/null || true
```

### 8. Generar la credencial del comercio

Genere la credencial desde `wao-credential-vault` con `module: "checkout"`, el Bearer Token del comercio, sus `origins` permitidos y la expiracion deseada. No use el CLI local `private/cli/encrypt-token.php` para credenciales nuevas.

Entregue al comercio unicamente la salida `wco1.k2026...` emitida por el vault.

### 9. Insertar el script

```html
<button class="js-add-product" data-product-id="55350" data-product-qty="2">Agregar</button>
<button class="js-open-cart">Carrito</button>

<script>
window.WAOCheckoutConfig = {
  credential: 'wco1.k2026...',
  addTrigger: '.js-add-product',
  openTrigger: '.js-open-cart',
  floatingCart: { show: true, horizontal: 'right', vertical: 'bottom' },
  prefetchCart: false,
  returnUrl: location.href,
  theme: {
    primary: '#e8291c',
    buttonHover: '#c91f15',
    secondary: '#111111',
    accent: '#1565c0'
  }
};
</script>
<script async src="https://DOMINIO/checkout-m/public/embed.js?v=20260621-3"></script>
```

### 10. Verificación posterior

```bash
php8.3 -r "echo extension_loaded('sodium') ? 'Sodium OK' : 'Sodium NO';"
curl -I "https://DOMINIO/checkout-m/public/embed.js?v=20260621-3"
curl -I "https://DOMINIO/checkout-m/wao-co-8x4/private/config.php"
```

El script debe responder `200`; la ruta privada debe responder `403` o `404`.

Luego valide el flujo completo: cantidad y carrito, impuestos, código postal, envío/sucursal, pagos sandbox, registro en Merchant API y `order_webhook.status=delivered`.

Durante diagnóstico puede activar `CHECKOUT_API_LOG=1`; vuelva a `0` al terminar y nunca mueva `/home/USUARIO/web/DOMINIO/private/checkout-m/logs` dentro de `public_html`.

## Credencial para un comercio

El bearer token nunca se coloca directamente en `embed.js`. Genere una credencial cifrada desde `wao-credential-vault` usando `module: "checkout"`. El modulo siempre valida esa credencial contra el vault antes de recuperar el Bearer Token real.

Ejemplo de payload para el vault:

````json
{
  "action": "issue",
  "token": "BEARER_TOKEN",
  "merchant_id": 123,
  "module": "checkout",
  "origins": ["https://tienda.ejemplo.com"],
  "expires": "2027-12-31"
}
```

## Inserción

```html
<button class="js-add-product" data-product-id="55350" data-product-qty="1">Agregar</button>
<button id="open-cart">Carrito</button>
<script>
window.WAOCheckoutConfig = {
  credential: 'wco1.k2026...',
  addTrigger: '.js-add-product',
  openTrigger: '#open-cart',
  floatingCart: { show: true, horizontal: 'right', vertical: 'bottom' },
  prefetchCart: false,
  returnUrl: location.href,
  messageTargetOrigin: location.origin,
  onComplete: function (result) { console.log(result.order_id, result); },
  onError: function (result) { console.error(result.error); }
};
window.addEventListener('wao:checkout:complete', function (event) {
  console.log('Checkout completado', event.detail);
});
</script>
<script async src="https://plugins.waopay.app/checkout-m/public/embed.js?v=20260621-3"></script>
```

Los eventos son delegados, por lo que los botones creados después de cargar el script también funcionan. También están disponibles `WAOCheckout.open()`, `WAOCheckout.checkout()` y `WAOCheckout.add(productId, quantity)` y `WAOCheckout.loadAndCheckout(items)`.

Cuando el pago o registro del pedido termina correctamente, el modulo entrega una respuesta normalizada por tres vias: WAOCheckoutConfig.onComplete(result), el evento global wao:checkout:complete con event.detail, y window.postMessage({ type: 'wao.checkout.complete', payload: result }, messageTargetOrigin). Configure messageTargetOrigin con el origen exacto del sitio anfitrion; si no se define, se usa location.origin.

Payload publico esperado:

```js
{
  status: 'approved',
  action: 'complete',
  checkout_id: '...',
  attempt_id: '...',
  order_id: '...',
  payment_method: 'clip|paypal|waopay_clabe|aplazo',
  amount: 123.45,
  currency: 'MXN',
  order: {},
  spei: {}
}
```
### Checkout rapido por JSON

Para tiendas que ya tienen su propio carrito en JavaScript, use `quickCheckoutTrigger` y `cartJsonProvider`. El provider se ejecuta solo cuando el cliente hace clic en el boton, por lo que el modulo no consulta producto por producto durante la navegacion.

```html
<button id="wao-fast-checkout">Pagar ahora</button>
<script>
window.WAOCheckoutConfig = {
  credential: 'wco1.k2026...',
  quickCheckoutTrigger: '#wao-fast-checkout',
  cartJsonProvider: function () {
    return [
      {
        id: 55350,
        qty: 2,
        attributes: { talla: 'M', color: 'Negro' },
        instructions: 'Sin etiqueta'
      },
      {
        product_id: 190331,
        quantity: 1,
        extras: [{ name: 'Grabado', value: 'Omar' }]
      }
    ];
  },
  requestTimeoutMs: 45000,
  returnUrl: location.href
};
</script>
```

El JSON solo debe enviar datos no autoritativos: ID del producto, cantidad, `attributes`, `extras`, `options` e `instructions`. `checkout-m` completa `cart`, `item_title` y `price` desde WAOPay con `item-data` antes de llamar `addto-shoppingcart`; no acepta precios ni titulos enviados por el navegador. Si WAOPay devuelve precio `0`, vacio o invalido, el producto se rechaza antes de agregarse al carrito o iniciar pago.

`cart.load` reemplaza el carrito activo para evitar duplicados por doble clic. Antes de vaciar el carrito actual valida todos los productos, agrupa lineas iguales, conserva lineas separadas cuando cambian los atributos y suma inventario por producto aunque los atributos sean distintos. Para evitar que el carrito se atore, el modulo bloquea doble clic, usa timeout de peticion y limita la carga rapida a 100 lineas y 50 productos unicos.

Tambien puede llamarse manualmente:

````js
WAOCheckout.loadAndCheckout([{ id: 55350, qty: 1, options: { color: 'Rojo' } }]);
```
### Botones de producto

`checkout-m` funciona s?lo con productos de un cat?logo cargado en WAOPay. No crea cat?logos ni productos desde el sitio anfitri?n; antes de agregar, consulta el producto en WAOPay usando el ID enviado por el bot?n. Si el producto no existe o no pertenece al comercio de la credencial, no podr? subirse al carrito.

Cada bot?n que agrega un producto debe incluir:

| Elemento | Requerido | Uso |
| --- | --- | --- |
| `class` | S? | Debe coincidir con `addTrigger`, por ejemplo `js-add-product`. |
| `data-product-id` | S? | ID num?rico del producto en el cat?logo WAOPay. |
| `data-product-qty` | No | Cantidad inicial sugerida. Si se omite, el m?dulo usa `1`. |

Ejemplo:

```html
<button class="js-add-product" data-product-id="55350" data-product-qty="1">Agregar producto</button>
```

Antes de agregar un producto, el módulo consulta sus datos actuales en la API y abre una confirmación con imagen, título, precio y selector de cantidad. `data-product-qty` o el segundo argumento de `WAOCheckout.add()` definen la cantidad inicial; el producto sólo se agrega después de que el cliente confirma. Los productos sin precio vendible no abren el flujo de compra.

El selector limita visualmente la cantidad segun `available` y `unlimited`; ademas, el backend vuelve a validar el inventario con `item-data` al agregar al carrito y antes de iniciar pagos en `checkout.confirm` o `paypal.create-order`. Si la suma de lineas del mismo producto supera el stock disponible, el checkout rechaza la operacion antes de crear el intento de pago.

`floatingCart` controla el botón circular del carrito. Se muestra por defecto abajo a la derecha, incluso si se omite la configuración. `horizontal` admite `left` o `right`; `vertical` admite `top` o `bottom`. Para ocultarlo use `floatingCart: { show: false }` o `floatingCart: false`. El botón usa `theme.primary` y `theme.buttonHover`; su insignia muestra la suma de unidades agregadas y se oculta cuando el carrito está vacío. `prefetchCart: true` permite precargar el carrito guardado al cargar la página; por defecto el módulo evita esa llamada y refresca el carrito cuando el cliente lo abre. El modal de checkout muestra en el encabezado el monto estimado a pagar y lo actualiza al cambiar opciones como el envío seleccionado.

Los modificadores recibidos en `attributes`, `extras` u `options` se normalizan y se muestran en el carrito, confirmación y detalle final. También se conservan en el snapshot de pago, Aplazo y el webhook del pedido.

### Personalización visual

El tema se aplica únicamente al contenedor de `checkout-m`, sin modificar las variables o estilos del sitio anfitrión:

```javascript
theme: {
  primary: '#e8291c',
  buttonHover: '#c91f15',
  secondary: '#111111',
  accent: '#1565c0'
}
```

- `primary`: botones principales y paso activo.
- `buttonHover`: estado hover de los botones principales.
- `secondary`: encabezados y acciones secundarias.
- `accent`: foco de campos y opciones seleccionadas.

También se admiten opcionalmente `success`, `text`, `muted`, `surface` y `border`.

## Pagos

- Clip: `value1` es la Clave API usada por el SDK y como bearer de `/payments`; `value2` es la Clave Secreta, permanece exclusivamente en PHP y nunca se entrega al navegador.
- Aplazo: `value1` es `merchantId`; `value2` es `apiToken`. Ambos permanecen en PHP.
- SPEI / WAOPay CLABE: `payment_method` debe ser `waopay_clabe` y `value1` es la CLABE que se muestra solo en el resumen final del pedido registrado. Si Merchant API entrega beneficiario en un campo publico compatible, tambien se muestra como beneficiario. Este metodo registra la transaccion con pago pendiente y el webhook incluye `checkout.spei`.
- Un retorno del navegador o webhook nunca aprueba por sí solo un pedido. El módulo consulta el estado directamente a la pasarela y compara referencia, moneda y monto cuando la integración los proporciona.
- Cada intento se persiste antes de llamar a la pasarela y el registro de la transacción usa bloqueo e idempotencia para reducir duplicados.

Para añadir otra pasarela, implemente `GatewayInterface` y regístrela en `api.php` y `private/payment-bootstrap.php`. Sólo exponga en `checkout.payment-methods` los valores públicos indispensables.

## Webhook de pedido completado

Después de que la pasarela confirma el pago y Merchant API registra la transacción, `checkout-m` envía `order.completed` a `WAOPAY_ORDER_WEBHOOK_URL`. El valor predeterminado es:

```text
https://minegocio.waopay.app/sections/webhook-correo.php
```

El payload conserva los campos que consume WAOPay e incluye productos, cliente, entrega, importes, impuestos, referencia de pasarela y transacción. Los encabezados `X-WAO-Idempotency-Key` y `X-WAO-Signature` permiten deduplicar y validar la petición.

La entrega se persiste dentro del intento como `order_webhook`. Un fallo del correo no revierte un pago aprobado ni oculta el pedido al cliente; queda marcado como `failed` para reintentarlo de forma segura. El mismo servicio está preparado para métodos sin pasarela: una vez registrada su transacción, dispara el webhook con estado de pago pendiente.

## Persistencia

Los checkouts e intentos se almacenan inicialmente como JSON con bloqueo exclusivo en `private/storage`. Es persistencia del lado de `checkout-m`, no del navegador. Para varias instancias del módulo, almacenamiento compartido o alto volumen, debe sustituirse `FileStore` por un repositorio MySQL conservando operaciones atómicas e índices únicos para `attempt_id`, referencia de pasarela e `idempotency_key`.

## Prueba

Configure las variables en Apache/PHP, habilite `extension=sodium`, reinicie Apache, genere una credencial y reemplácela en `example.html`. El producto del ejemplo es el ID `55350`; puede cambiarse por uno perteneciente al comercio de la credencial.
