# Diferenciadores de comercio electrónico local (línea de tesis)

Este documento es distinto a `checklist-lanzamiento.md` y
`produccion-pagos-pendiente.md`: esos cubren qué falta para operar en
producción. Este cubre la línea de tesis "comercio electrónico local" —
funcionalidades que casi ningún sistema de e-commerce genérico (Shopify,
WooCommerce, plantillas extranjeras) trae de fábrica, porque responden a
normativa o hábitos de pago específicos del mercado peruano.

## 1. Libro de Reclamaciones Virtual — ✅ implementado

Conforme al Código de Protección y Defensa del Consumidor (D.S. N°
011-2011-PCM y su modificatoria D.S. N° 101-2022-PCM). Cualquier consumidor
registra un reclamo o queja en `/libro-de-reclamaciones` sin necesidad de
cuenta, recibe un código correlativo y una copia por correo, y el sistema
controla el plazo legal de respuesta de 15 días hábiles.

Detalle completo (campos, permisos, flujo) ya documentado en el manual
técnico, sección "5. Módulos construidos". Archivos clave:
`app/Models/Complaint.php`, `app/Http/Controllers/ComplaintController.php`
(público), `app/Http/Controllers/Admin/ComplaintController.php` (panel).

**Anti-abuso (agregado en la re-auditoría de seguridad del 2026-07-26):**
al no exigir cuenta, `consumer_email` no se verifica y el correo de
confirmación lleva nombre/detalle/descripción de texto libre — sin
protección, alguien podría usar el formulario como relay de spam o acoso
hacia un tercero real. Se agregó un tope de 3 reclamos por día **por correo
destino** (no por IP, así que no se evade rotando de IP) más un campo
trampa (honeypot) invisible en el formulario que descarta silenciosamente
los envíos de bots simples sin crear nada ni enviar correo.

## 2. Autocompletado de DNI/RUC vía RENIEC/SUNAT — ✅ implementado (sin proveedor contratado)

### Problema que resuelve

El sistema ya capturaba `document_type`/`document_number` en el checkout
(para la boleta/factura) y en el Libro de Reclamaciones, pero el cliente
escribía el número **y también su nombre** a mano. Si se equivocaba al
tipear el nombre, el comprobante SUNAT o el reclamo quedaban con un dato
incorrecto.

### Cómo funciona

1. El cliente escribe su DNI (8 dígitos) o RUC (11 dígitos) y sale del
   campo (evento `blur` en Alpine.js).
2. El frontend llama a un endpoint propio del sistema — nunca directo a un
   servicio externo desde el navegador, para no exponer la llave de API:
   `GET /api/documento/{tipo}/{numero}` (público, sin sesión, throttled a
   20/min).
3. Ese endpoint (`DocumentLookupController` → `DocumentLookupService`)
   consulta el proveedor externo configurado y devuelve JSON
   `{ "nombre": string|null }` (DNI) o
   `{ "nombre": string|null, "activo": bool|null, "habido": bool|null }`
   (RUC) — `activo`/`habido` reflejan si SUNAT considera la empresa con
   estado ACTIVO y condición HABIDO, respectivamente. Si el RUC está dado
   de baja o no habido, el checkout muestra un aviso junto a la razón
   social antes de que el cliente intente facturar — evita una factura que
   después rebota. Quedan en `null` si el proveedor no informa esos campos,
   nunca se asume nada que no se pueda confirmar.
4. El campo de nombre se autocompleta **solo si estaba vacío** (nunca
   sobrescribe algo que el cliente ya escribió a mano) — probado con
   Playwright en un navegador real.
5. **Sin `DOCUMENT_LOOKUP_TOKEN` configurado** (el estado por defecto),
   `isConfigured()` es `false` y `lookupName()` nunca llama a nada: el
   endpoint siempre responde `{ nombre: null }` de inmediato, y el cliente
   sigue escribiendo su nombre a mano exactamente como antes — probado
   explícitamente (`DocumentLookupTest.php` y en un navegador real, sin
   errores de consola). Cualquier falla del proveedor (timeout, error 500,
   respuesta inesperada) se captura y también degrada a `null`, nunca a un
   error visible para el cliente.

Se aplica en los dos formularios que ya existían: el checkout
(`document_type`/`document_number` → autocompleta `billing_name` solo
para RUC/factura) y el Libro de Reclamaciones
(`consumer_document_type`/`consumer_document_number` → autocompleta
`consumer_name` para DNI o RUC).

### Archivos clave

`app/Services/DocumentLookupService.php`, `app/Http/Controllers/DocumentLookupController.php`,
ruta `document-lookup.show` en `routes/web.php`, bloque `document_lookup` en
`config/services.php`, y el bloque `x-data` de Alpine.js en
`resources/views/checkout/index.blade.php` y
`resources/views/complaints/create.blade.php`.

### Proveedor: todavía no contratado

No hay una API gratuita ni oficial de RENIEC para consulta directa por
terceros — requiere convenio, o pasar por un revendedor. El servicio se
construyó contra el formato de respuesta más común de este tipo de
proveedor (candidatos a cotizar: apis.net.pe, factiliza.com,
decolecta.com — no hay uno recomendado de forma cerrada, hay que comparar
precio/límites al momento de contratar), pero **los nombres exactos de los
campos de la respuesta no están verificados al 100% sin una cuenta real**
— ver la advertencia en `DocumentLookupService::parseDni()`/`parseRuc()`,
mismo estilo que las advertencias ya existentes en `CulqiService`/
`IzipayService` para lo no verificable sin credenciales reales. Ajustar
esos dos métodos si el proveedor real devuelve otra forma. Mientras no se
configure `DOCUMENT_LOOKUP_TOKEN`, el sistema funciona exactamente igual
que antes de esta mejora.

### Riesgos / consideraciones

- **Dependencia externa**: ya mitigado — cualquier falla degrada a
  "escribir a mano", nunca a un error.
- **Costo recurrente**: a diferencia del Libro de Reclamaciones (gratis,
  solo trabajo propio), esto tendrá un costo variable por consulta una vez
  se contrate un proveedor real.
- **Privacidad**: al activarlo, se envía el número de documento del
  cliente a un tercero — falta mencionarlo en la política de privacidad
  (`StoreSetting::DEFAULT_PRIVACY_POLICY`) si se llega a configurar un
  token real.
- **Abuso como proxy gratuito (mitigado en la re-auditoría del
  2026-07-26)**: el endpoint es público y el throttle de la ruta (20/min)
  es solo por IP, evadible rotando de IP — alguien podría agotar la cuota
  pagada del proveedor o cosechar datos DNI→nombre a escala. Se agregó un
  tope diario **global**, compartido entre todas las IPs
  (`DOCUMENT_LOOKUP_DAILY_LIMIT`, 500 por defecto, configurable, 0 = sin
  tope) en `DocumentLookupService::consumeDailyBudget()`.

## 3. Dirección por ubigeo + envío diferenciado por zona — ✅ implementado

### Problema que resuelve

El checkout pedía la ciudad en un campo de texto libre, y el envío se
cobraba con una única tarifa nacional plana sin importar el destino real —
ningún sistema de e-commerce genérico modela la división política del Perú
ni las diferencias reales de costo logístico entre Lima, el resto de la
costa/sierra, y la selva.

### Cómo funciona

1. El checkout pide **Departamento → Provincia → Distrito** en combos en
   cascada (cada uno se puebla vía `GET /api/ubigeo/...` al elegir el
   anterior), en vez del campo "Ciudad" de antes.
2. Los ~1840 departamentos/provincias/distritos son datos oficiales del
   Perú (dataset MIT `joseluisq/ubigeos-peru`, copiado en
   `database/data/ubigeo/*.json` y sembrado por `UbigeoSeeder`). Ese
   dataset no incluye la Provincia Constitucional del Callao (código INEI
   07) — se agregó a mano con sus 7 distritos oficiales, verificados
   contra fuentes públicas.
3. Cada provincia tiene una `shipping_zone` (simplificación propia, no del
   dataset): **Lima Metropolitana** = provincia Lima + toda la provincia de
   Callao; **Selva** = departamentos íntegros de Loreto, Ucayali, Madre de
   Dios, San Martín y Amazonas; el resto, **Costa/Sierra**.
4. Si en Configuración → Envío por zona se configura una tarifa para la
   zona del destino, se usa esa; si no, se usa la "Tarifa de envío
   nacional" de siempre — **sin configurar nada, el sistema cobra
   exactamente igual que antes** de esta mejora.
5. El total (envío + impuesto + total) se recalcula en vivo al elegir la
   provincia, llamando a `POST /checkout/estimar-envio`, que usa el mismo
   `ShippingCalculator`/`TaxCalculator` que el pedido real al confirmarse
   — nunca hay dos lugares con la lógica de cálculo duplicada, así que la
   vista previa nunca puede desincronizarse del cobro real.

### Archivos clave

`app/Models/UbigeoDepartment.php`/`UbigeoProvince.php`/`UbigeoDistrict.php`,
`database/seeders/UbigeoSeeder.php`, `app/Services/ShippingCalculator.php`,
`app/Http/Controllers/UbigeoController.php` (combos en cascada),
`CheckoutController::estimateShipping()`, y el bloque `x-data` de
`resources/views/checkout/index.blade.php`.

### Riesgos / consideraciones

- **Clasificación de zona es una simplificación propia**, no una fuente
  oficial — documentada en el propio `UbigeoSeeder` para poder ajustarla
  si el negocio real usa otro criterio (p. ej. provincias específicas de
  sierra con recargo por altura, no solo departamento).
- **Dataset de terceros (MIT)**: si INEI actualiza la división política
  (nuevos distritos), hay que volver a generar/actualizar los JSON — no es
  automático.
- Sin costo recurrente: a diferencia del autocompletado DNI/RUC, esto no
  depende de ningún servicio externo de pago.

## Estado

| Mejora | Estado | Costo recurrente |
|---|---|---|
| Libro de Reclamaciones Virtual | ✅ Implementado, probado y re-auditado (commits `049516c`, `8b66170`, `ee14ece`, `e69965e`) | Ninguno |
| Autocompletado DNI/RUC (+ activo/habido) | ✅ Código implementado, probado y re-auditado; falta contratar un proveedor real (commit `e69965e`) | Sí (API de terceros, al contratar) |
| Dirección por ubigeo + envío por zona | ✅ Implementado y probado (commit `f5a757a`) | Ninguno |
