Fecha: 2026-05-22
Alcance: Backend (API pública + ERP), Frontend (módulo Sitios), Storefront (tienda)
Método: Revisión estática de código y rutas (sin QA manual en navegador en esta sesión).
Actualización: Los fixes P0/P1/P2 listados abajo fueron implementados en código (ver commit en rama actual).
1. Regresión del sprint anterior (baseline)
| Mitigación | Estado en código | Evidencia |
|---|
| Stock en carrito/checkout | Presente | INSUFFICIENT_STOCK en publicSiteController.js |
getOrderStatus protegido | Presente | canViewOrderStatus + JWT o viewToken |
| Link de pago storefront | Presente | generateStorefrontPaymentLink en paymentLinkService.js |
| Captcha obligatorio en prod | Presente | captcha.js L6–8; validateCriticalEnv.js |
| Webhook raw | Presente | server.js /webhooks/storefront con express.raw |
| Preview cookie | Presente | Storefront/middleware.ts abaco_preview_token |
| Switcher móvil | Presente | header.tsx block lg:hidden + CompanySwitcher compact |
| Toasts carrito | Presente | CartLines.tsx / MiniCartDrawer.tsx validan null |
| 409 creación sitio | Presente | sitios/page.tsx SiteHero + existingSiteId |
2. Resumen ejecutivo
| Severidad | Confirmados | Parciales | Descartados / mitigados |
|---|
| P0 | 3 | 0 | 0 |
| P1 | 10 | 2 | 1 |
| P2 | 9 | 1 | 2 |
Prioridad inmediata: B1 (tipo documento ERP), B2 (carrera de stock), S1 (carrito borrado antes del pago).
3. Backend — API y pagos
B1 — Tipo de documento por defecto incorrecto al pagar (P0) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | storefrontCheckoutService.js L136; documentInventoryService.js L51–58 |
| Actual | SITES_DEFAULT_DOC_TYPE por defecto '07'; comentario dice “cotización” pero 07 = nota de débito con reduce stock; 100 = cotización sin movimiento (type: 'none') |
| Esperado | Pedido web pagado → cotización (100) o factura (01) según negocio, no ND que reduce inventario por error |
| Repro | Completar pago vía webhook; revisar Document.type e inventario en ERP |
| Fix propuesto | Cambiar default a '100' o SITES_DEFAULT_DOC_TYPE=100 en .env.example; alinear comentario y validar con contabilidad |
B2 — Carrera de stock sin reserva (P0) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | publicSiteController.js checkout; storefrontCheckoutService.js |
| Actual | Stock validado en transacción de checkout; entre commit y pago no hay reserva; segundo comprador puede pasar checkout con la misma unidad |
| Repro | Producto con stock=1; dos sesiones checkout casi simultáneo; ambos pending_payment |
| Fix propuesto | Reserva temporal al pending_payment o revalidación atómica en confirmPayment antes de marcar paid |
B3 — Webhook paid sin exigir pending_payment (P0) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | storefrontPaymentWebhookController.js L37–42 |
| Actual | Acepta siteOrderId numérico (secuencial) o cartToken; no valida estado previo |
| Riesgo | Con HMAC filtrado: marcar pedidos en cart como pagados; enumeración de IDs |
| Fix propuesto | Rechazar si order.status !== 'pending_payment'; preferir lookup solo por cartToken + siteId |
B4 — Documentos ERP duplicados en webhooks concurrentes (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | storefrontCheckoutService.js confirmPayment L125–131 |
| Actual | Sin transacción ni SELECT FOR UPDATE; dos webhooks paralelos pueden crear dos Document |
| Fix propuesto | Transacción con lock en SiteOrder; idempotencia por paymentReference |
B5 — Secreto webhook = bypass CSRF global (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | csrfMiddleware.js L4–11 |
| Actual | x-abaco-storefront-secret acepta STOREFRONT_REVALIDATE_SECRET y STOREFRONT_WEBHOOK_SECRET |
| Fix propuesto | Separar secretos: solo REVALIDATE para CSRF; webhook solo en ruta /webhooks/* |
B6 — Bundles sin validación de stock de componentes (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | publicSiteController.js (sin referencias a bundle); ERP valida en documentController con parentLineId |
| Actual | Carrito solo valida is_inventoriable del producto padre; líneas planas en confirmPayment |
| Fix propuesto | Expandir bundle en checkout o rechazar bundles inventariables en storefront hasta soportarlos |
B7 — Orden por price en lugar de webPrice (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | publicSiteController.js listProducts L211–212 |
| Fix propuesto | Ordenar por expresión SQL COALESCE(webPrice, price) o post-procesar con webPriceOf |
B8 — Rate limit OTP en memoria (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | otpService.js rateBuckets = new Map() |
| Fix propuesto | Redis o rate limit en proxy |
B9 — JWT comprador sin revocación en logout (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | storefrontAuthController.js logoutStorefront; storefrontCustomerToken.js |
| Fix propuesto | tokenVersion en BD o denylist |
B10 — Carrito sin FOR UPDATE en add/update (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | addCartItem / updateCartItem vs checkout con LOCK.UPDATE |
| Fix propuesto | Lock en SiteOrder en mutaciones de carrito |
B11 — getCustomerTokenAction exportado (P1) — CONFIRMADO (bajo uso)
| Campo | Detalle |
|---|
| Archivos | Storefront/app/actions/auth-actions.ts L78–80 |
| Actual | Exportado; no importado en otros .tsx del repo (solo definición) |
| Fix propuesto | Eliminar export o restringir a uso interno documentado |
B12 — confirmPayment sin paid_pending_erp (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Actual | Si falla createDocumentFromPayload, pedido queda paid sin documentId; solo logger.warn |
| Fix propuesto | Estado intermedio + job de reintento |
B13 — /internal/domains/check sin token (P2) — CONFIRMADO (parcial)
| Campo | Detalle |
|---|
| Archivos | internalSiteController.js checkDomain — sin auth; reportCertIssued sí usa CADDY_INTERNAL_TOKEN |
| Nota | Puede ser intencional para Caddy on_demand_tls; riesgo = enumeración de dominios registrados |
| Fix propuesto | Token compartido Caddy↔API o restricción de red interna |
B14 — Rate limit OTP incluye purpose en clave (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | otpService.js L64 bucketKey(..., purpose) |
| Fix propuesto | Fijar purpose en rutas públicas o excluir de clave si no aplica |
B15 — Migración OTP: índices en catch vacío (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | 20260522140000-create-otp-challenge-and-storefront-customer.js L48–55 |
| Fix propuesto | logger.warn en catch; script de reparación de índices |
4. Frontend — ERP / Sitios
| Campo | Detalle |
|---|
| Archivos | sidebar.tsx L305 permission: null; rutas /sitios con requiredRoles={['owner','admin']} |
| Fix propuesto | permission: 'owner' o filtrar por rol como en header.tsx |
F2 — Cambio de empresa desmonta editor (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | sitios/page.tsx L111–112 fetchSiteForCompany() con showLoading default true |
| Fix propuesto | fetchSiteForCompany({ showLoading: false }) en CompanyChangeEvent o guardar borradores en sessionStorage |
F3 — pageId de URL tras cambio de empresa (P1) — PARCIALMENTE MITIGADO
| Campo | Detalle |
|---|
| Archivos | paginas/[pageId]/page.tsx resetea site/page y load() |
| Actual | Si la página no existe en la nueva empresa → error y redirect /sitios (OK); breve flash o petición con ID ajeno |
| Fix propuesto | router.replace('/sitios') inmediato al CompanyChangeEvent antes de load() |
F4 — SiteProductsPanel sin CompanyChangeEvent (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | SiteProductsPanel.tsx; solo useEffect([page]) |
| Mitigación actual | Padre muestra spinner al cambiar empresa |
| Fix propuesto | Suscribir CompanyChangeEvent y recargar lista |
F5 — Switcher no refetch si ya hay empresas (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | company-switcher.tsx L164–168 |
| Fix propuesto | Refetch ligero en subscribe o invalidar cache al emit desde settings |
| Campo | Detalle |
|---|
| Archivos | sidebar.tsx L359 pathname === link.href |
| Fix propuesto | pathname.startsWith('/sitios') para ítem Sitios |
| Campo | Detalle |
|---|
| Archivos | SiteSettingsForm.tsx — navigationJson validado con try/catch al guardar |
| Pendiente | Verificar si footerJson tiene la misma validación |
5. Storefront — Tienda pública
S1 — Carrito borrado antes del pago en pasarela (P0) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | cart-actions.ts L63 clearCartTokenCookie tras checkout exitoso; CheckoutForm.tsx redirect a N1co |
| Repro | Checkout con link de pago → cancelar en N1co → /checkout vacío; pedido sigue pending_payment |
| Fix propuesto | Diferir clearCart hasta paid o conservar cartToken en sesión hasta gracias |
S2 — /gracias sin view ni JWT en SSR (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | gracias/page.tsx solo pasa viewToken; no readCustomerTokenCookie + customerToken en getOrderStatus |
| Fix propuesto | En server component, leer cookie de cliente y pasar opts.customerToken |
S3 — Cookie carrito huérfana tras 404 (P1) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | layout.tsx L47–53 catch sin clearCartTokenCookie; cart-actions.ts devuelve null |
| Fix propuesto | clearCartTokenCookie en catch de layout y carrito |
S4 — Sin STOREFRONT_REVALIDATE_SECRET → 403 (P1) — CONFIRMADO (config)
| Campo | Detalle |
|---|
| Archivos | api.ts L21–27; csrfMiddleware.js |
| Fix propuesto | Documentar en deploy; fallar build si falta en prod |
S5 — listMyOrdersAction no limpia sesión en 401 (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | auth-actions.ts L71–75 vs L28–38 |
| Fix propuesto | Misma lógica ApiError 401/403 → clearCustomerTokenCookie |
S6 — Preview cookie zombie en error de red (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | middleware.ts L41–43 catch sin borrar cookie |
| Fix propuesto | Borrar abaco_preview_token en catch si había cookie |
S7 — Doble fetch preview (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | middleware.ts + site-context.ts resolvePreview |
| Fix propuesto | Confiar solo en headers del middleware en RSC |
S8 — safeInternalPath bypass anidado (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | safe-path.ts — permite /ruta?next=https://evil.com |
| Fix propuesto | Rechazar ? en path o sanitizar query next recursivamente |
S9 — Toast éxito antes de pasarela (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | CheckoutForm.tsx toastPromise + redirect externo |
| Fix propuesto | Mensaje “Redirigiendo al pago…” sin “Pedido registrado” hasta confirmación |
S10 — Limpiar cookie carrito en layout (P2) — CONFIRMADO (= S3)
Ver S3.
S11 — serverActions.allowedOrigins: ['*'] (P2) — CONFIRMADO
| Campo | Detalle |
|---|
| Archivos | Storefront/next.config.js L12 |
| Fix propuesto | Lista explícita de dominios storefront en prod |
6. Sprint de corrección — estado de implementación
| ID | Estado |
|---|
| B1 | Corregido — default SITES_DEFAULT_DOC_TYPE=100 |
| B2 | Mitigado — revalidación en confirmPayment + stockReservedAt en checkout |
| B3 | Corregido — solo cartToken + exige pending_payment |
| B4 | Corregido — transacción + lock en confirmPayment |
| B5 | Corregido — CSRF solo STOREFRONT_REVALIDATE_SECRET |
| B6 | Corregido — storefrontStockService valida bundles |
| B7 | Corregido — sort por COALESCE(webPrice, price) |
| B8 | Mitigado — rate limit OTP con Redis + fallback Map |
| B9 | Corregido — sessionTokenVersion + logout |
| B10 | Corregido — FOR UPDATE en carrito |
| B11–B15 | Corregido según informe |
| F1–F7, S1–S11 | Corregido según informe |
7. Checklist QA manual (complemento)
- [ ] Agregar al carrito > stock → error
INSUFFICIENT_STOCK - [ ] Checkout N1co →
paymentLinkUrl presente - [ ] Preview 3 páginas sin
?preview= en URL - [ ] Cambiar empresa en editor → sin datos ajenos persistentes
- [ ] Móvil <640px → switcher visible
- [ ] Cookie carrito inválida → error, no toast éxito
- [ ] OTP + checkout con secrets alineados → sin 403 CSRF
- [ ] Cancelar pago N1co → poder reintentar o ver pedido en cuenta
- [ ]
/gracias?token=&view= funciona sin sesión - [ ] Pago webhook → documento tipo correcto y stock coherente
Generado por auditoría estática. No modifica el plan de corrección en .cursor/plans/.