# Auditoría pre-lanzamiento — SysFarma Móvil

> Fecha: 2026-07-24 · Alcance: `mobile/` (app Flutter) + `modules/MobileApp/` (API v1),
> más rutas y modelos del backend que la app consume.
> Método: auditoría en 6 frentes con verificación adversarial de cada hallazgo contra el
> código real. 89 hallazgos confirmados, 4 descartados por no sostenerse.
> Los marcados **[verificado a mano]** se comprobaron además línea por línea.

**Veredicto: no se puede subir a Play Store todavía.** Cuatro cosas impiden que el AAB entre
a Play, y tres defectos corrompen dinero o comprobantes fiscales en producción. La arquitectura
está sana: son arreglos puntuales, no hay que rehacer nada.

Estimación hasta producción: **3 a 4 semanas** (los 14 días de prueba cerrada corren en paralelo).

---

## 1. Bloqueantes

### 1.A — Impiden que Google Play acepte el archivo

#### B1 — La app se firma en modo debug y el build termina bien igual
`mobile/android/app/build.gradle.kts:51-59` cae a `signingConfigs.getByName("debug")` cuando no
existe `key.properties`. Como el debug.keystore sí existe, `flutter build appbundle --release`
**termina con éxito** y produce un archivo que parece válido; Play lo rechaza recién al subirlo
("signed in debug mode"). `mobile/README.md:32` documenta el fallback como aceptable.

**Riesgo de negocio:** la clave de subida y la decisión de activar Play App Signing son
**irreversibles** tras la primera publicación. Perder el `.jks` sin Play App Signing activo =
la app **nunca más se puede actualizar**.

**Arreglo:** generar keystore de subida, crear `mobile/android/key.properties` (ya gitignoreado
en `mobile/android/.gitignore:12-14`), respaldo redundante del `.jks` fuera del repo, activar
Play App Signing, y cambiar el fallback de `build.gradle.kts:53-57` para que **falle** en release.
Actualizar `README.md:20-32` a `flutter build appbundle` (Play exige AAB, no APK).

#### ✅ B1 — RESUELTO (2026-07-27, commit `0e0c1fda`)
`build.gradle.kts` ya no cae a la firma `debug`: `gradle.taskGraph.whenReady` corta el build con
mensaje claro si falta `key.properties` y se está compilando una variante Release.
**Probado en ambos sentidos**: sin `key.properties`, `assembleRelease` falla con el mensaje
("Falta android/key.properties..."); con `key.properties` presente, `assembleDebug` sigue
construyendo igual — el cambio no toca el flujo de desarrollo local.

Keystore de subida generado: RSA 2048, **validez 30 años (vence 2056-07-19)**, alias
`sysfarma-upload`, fuera del repo (`C:/Users/Sistemax/keystores/`, con `key.properties` apuntando
ahí por ruta absoluta). **Gotcha real encontrado al generarlo**: un primer intento con
`Out-File -Encoding utf8` de PowerShell dejó un carácter BOM invisible al inicio del password —
se detectó, se descartó ese keystore (nunca se usó) y se regeneró limpio con `/dev/urandom`.
Si se regenera un keystore alguna vez, **no usar `Out-File` de PowerShell para la contraseña**.

Verificado con `apksigner`/`jarsigner` sobre el APK **y** el AAB de release: firmados con
`SHA-256 BA:23:C1:00:9B:61:53:03:6A:4D:6E:8D:0F:88:53:20:A1:6B:EF:FE:CF:B9:F3:BB:98:3F:CE:09:E5:12:A5:F9`
(la del keystore nuevo, no la de debug).

**⚠️ Pendiente que NO hace Claude Code:** respaldo redundante del `.jks` (hoy vive en un solo
lugar: `C:\Users\Sistemax\keystores\sysfarma-upload.jks`) y activar **Play App Signing** al crear
la app en Play Console — recién ahí perder este archivo deja de ser catastrófico.

#### B2 — Librerías nativas incompatibles con Android 15 (16 KB page size) — ✅ RESUELTO 2026-07-27 (`2bbb05da`)
`mobile/pubspec.lock:551-558` fijaba `mobile_scanner 5.2.3`, que arrastraba MLKit 17.2.0 y
CameraX 1.3.3. Verificado empíricamente sobre el APK ya construido: `libbarhopper_v3.so` y
`libimage_processing_util_jni.so` alineados a 4 KB, manifest fusionado con `targetSdkVersion=36`.
Play **bloquea en la subida** (no advierte) todo AAB nuevo con targetSdk 35/36 y libs de 4 KB.

**Arreglo aplicado:** subir `mobile_scanner` a `^7.4.0` (`mobile/pubspec.yaml:25`). La API que usa
`barcode_scanner_screen.dart` (`MobileScanner(onDetect: ...)`, `Barcode.rawValue`) **no cambió**
entre 5.x y 7.x — verificado contra el código fuente del paquete (`controller` y `onDetect` siguen
siendo parámetros opcionales en v7.4.0), así que no hizo falta tocar el Dart. `minSdk`/`targetSdk`
ya venían de los defaults de Flutter (24/36), por encima del mínimo 23 que pide mobile_scanner 7.x.

**Verificado, no solo asumido:** `llvm-readelf -l` sobre las `.so` del AAB compilado confirma
alineamiento `0x4000` (16 KB) en las cuatro libs de terceros (antes 4 KB con la 5.2.3).
`flutter analyze` limpio, `flutter test` 3/3, `flutter build appbundle --release` exitoso y
firmado (verificado con `jarsigner -verify` contra el keystore real de B1).

#### B3 — No existe política de privacidad — ✅ RESUELTO 2026-07-27 (`faf511f0`)
Búsqueda sin resultados en `mobile/lib/`, `routes/` y `resources/views/`. Ya estaba anotado como
pendiente en `mobile/docs/ROADMAP.md:218`.

**Arreglo aplicado:** página `/politica-de-privacidad` en el sitio de marketing
(`resources/views/system/public/legal_privacidad.blade.php`, ruta en `routes/web.php`, controller
`PublicSiteController::privacidad()`), enlazada en el footer del sitio y desde
`settings_screen.dart` (item "Política de privacidad" con `url_launcher`). Contenido redactado a
partir del inventario verificado de B4 (qué dato, de quién, para qué, con quién se comparte:
APIPeru, Sentry, Firebase). Distingue el rol de Sistemax (responsable de la cuenta) del rol de
cada botica (responsable de los datos de sus propios clientes, Sistemax como encargado del
tratamiento) — es un borrador técnico grounded en el código, **no reemplaza una revisión legal
formal si se quiere blindar frente a Ley 29733**.

Verificado: `curl` a la página local devuelve 200 con el título correcto, aparece en
`/sitemap.xml`, `flutter analyze` limpio.

**Desplegado a producción 2026-07-27** (`deploy-enterfarmaplus`, head `20dd37b3`) — verificado
`https://sysfarma.pe/politica-de-privacidad` responde 200 en prod. Sin pendientes.

#### B4 — Formulario de Seguridad de Datos (Data Safety) — ✅ COMPLETADO 2026-07-27 (guardado, falta enviar a revisión)
Declaración incompleta o falsa = rechazo, y si se detecta después, suspensión de la ficha.

**Completado en Play Console** (Contenido de la app → Seguridad de los datos): 7 tipos de datos
declarados (Nombre, Email, Dirección, Teléfono, Otra información/DNI-RUC, Registros de fallas,
Dispositivo u otros IDs) con recopilación/uso compartido, ¿efímero?, necesario/opcional y
finalidad por cada uno — ver detalle abajo. También completados como prerrequisitos: **Anuncios**
(no tiene), **Detalles de acceso** (cuenta demo admin@gmail.com / RUC 00000000000, ver Play
Console → Detalles de acceso — credenciales reales, no placeholder), **Público objetivo**
(Mayores de 18 años) y **Política de Privacidad** (URL cargada).

**Pendiente (no código):** el botón "Guardar" solo deja el cambio guardado en el borrador de la
ficha — falta el paso final "Enviar a revisión" desde Descripción general de la publicación, que
normalmente se hace junto con el resto de la ficha (screenshots, descripción) al preparar el
primer release. `/politica-de-privacidad` ya está en producción (B3 resuelto), así que esto ya no bloquea.

**Inventario real de lo que sale del dispositivo (verificado):**
- Nombre / DNI-RUC / teléfono / email / dirección del cliente — `persons_screen.dart:248-257` → `api_client.dart:543`
- Consulta DNI/RUC contra APIPeru/SUNAT — `api_client.dart:522`
- Email + password del trabajador — `api_client.dart:82`
- RUC de la botica contra `https://sysfarma.pe` — `api_client.dart:69`
- RUC, nombre de empresa y establecimiento a Sentry — `observability.dart:78-88`

**A declarar:** Nombre, Email, Teléfono, Dirección, "Otros ID" (DNI/RUC), Historial de compras,
ID de dispositivo, y **Diagnósticos compartidos con un tercero (Sentry)**. Cifrado en tránsito:
sí (`api_client.dart:49-56` fuerza https). No hay pantalla de registro → el requisito de
"eliminación de cuenta in-app" no aplica.

#### B5 — El applicationId es de una marca abandonada, y es IRREVERSIBLE
Conviven tres marcas:
- `applicationId = "com.enterfarmaplus.mobile"` — `build.gradle.kts:19` y `:33`
- La app se llama **SysFarma** en pantalla — `AndroidManifest.xml:10`, `enter_farma_plus_app.dart:40`
- Sentry reporta como `sistemax-mobile` — `observability.dart:29`
- `pubspec.yaml:1-2` todavía dice `enter_farma_plus_mobile`, descripción "mobile app scaffold"

Publicado el primer AAB, el applicationId **no se puede cambiar nunca**: habría que crear ficha
nueva y perder instalaciones, reseñas y ranking.

**Arreglo:** decidir la marca ANTES de subir nada. Si es SysFarma, cambiar el applicationId,
renombrar el árbol Kotlin y `MainActivity.kt:1`, y unificar `observability.dart:29` y `pubspec.yaml:1-2`.
**Es una decisión de negocio, no código — y traba B6 (14 días de calendario).**

#### ✅ B5 — RESUELTO (2026-07-27, commit `91186c26`)
Decisión: **`pe.sistemax.sysfarma`** — deja el esquema `pe.sistemax.*` libre para otros productos
(ej. emito). `namespace`/`applicationId` (Android) y `PRODUCT_BUNDLE_IDENTIFIER` (iOS, 6 ocurrencias
incl. `.RunnerTests`) actualizados. Árbol Kotlin movido a `.../kotlin/pe/sistemax/sysfarma/`.
Cascada mecánica del nombre del paquete Dart (`enter_farma_plus_mobile` → `sysfarma_mobile`,
198 ocurrencias en 61 archivos) + widget raíz `EnterFarmaPlusApp` → `SysFarmaApp` +
`observability.dart:29`. Verificado: `flutter analyze` limpio, `flutter test` 4/4.
**La versión (`5.161.118+...`) no se tocó** — es un hallazgo aparte, ya documentado en la
sección 3 (el número coincide con la IP del server, pegado por error).

**Ya no traba B6** — B1 y B2 resueltos (firma real + escáner). App creada en Play Console
2026-07-27 (`pe.sistemax.sysfarma`, id consola `4972459326579710498`, Play App Signing
aceptado). Siguiente: B3 + B4 (política de privacidad + Data Safety) antes de poder subir
el primer AAB al track de prueba cerrada.

#### B6 — Prueba cerrada obligatoria: 12 testers × 14 días corridos
Cuenta de desarrollador **Personal** → debe correr prueba cerrada con mínimo de testers inscritos
de forma continua antes de pedir acceso a producción. No hay nada preparado
(`mobile/README.md:20-32` y `mobile/docs/ROADMAP.md:213-225` no mencionan tracks ni testers).

Descubrirlo el día del lanzamiento = **2 semanas de calendario perdidas**.
Confirmar el requisito exacto en Play Console (las condiciones cambian).

### 1.B — Defectos que corrompen dinero o datos fiscales

#### B7 — Anular un comprobante anula OTRO comprobante **[verificado a mano]**
`app/Models/Tenant/Document.php:773`:
```php
return $query->where('user_id', $user->id)->orWhere('seller_id', $user->id)->latest();
```
El `OR` sin agrupar, combinado con `findOrFail($id)`, hace que MySQL evalúe
`user_id = X OR (seller_id = X AND id = ?)` — y el `latest()` devuelve **siempre el último
documento del usuario, ignorando el id pedido**.

**Consecuencia:** el vendedor toca "anular" en la boleta B001-10 y el sistema anula su última
boleta emitida. `anular()` (`modules/MobileApp/.../DocumentController.php:92-152`) devuelve el
stock al inventario ítem por ítem. Corrupción de datos fiscales **y** de inventario.
Los `admin` no se ven afectados (líneas 769-771 no aplican filtro).
El mismo `OR` anula filtros de fecha y sede en reportes (`ReportController.php:324/334`),
inflando cifras.

**Arreglo (1 línea):** envolver en closure:
```php
->where(function($q) use ($user){ $q->where('user_id',...)->orWhere('seller_id',...); })->latest()
```

#### ✅ B7 — CORREGIDO Y DESPLEGADO (2026-07-26, commit `83951a08`)

**Forense: se buscó daño histórico y dio CERO. No hay nada que reparar.**
Queda escrito para que nadie repita la investigación.

Paso 1 — censo de exposición (¿qué tenants tienen usuarios afectados?). Los `admin` son
inmunes porque el scope hace early-return para ellos, así que solo importan los no-admin:

```sql
-- por cada schema farmacia_*
SELECT COALESCE(type,'(null)') t, COUNT(*) c FROM <tenant>.users GROUP BY type;
```
Resultado: **9 de 19 tenants no tienen ningún usuario no-admin** → inmunes por definición.
Los 8 con vendedores: boticabienestar (8), cuidarte (6), demo (5), lizfarma (2), adela (2),
santdofar (1), biosalud (1), imperial (1).

Paso 2 — anulaciones sobre esos 8, comparando vendedores contra `admin` como grupo de control
(la tasa de los admin es la línea base de "anular el último por razones normales", que es el caso
legítimo más común):

```sql
SELECT u.type AS tipo, COUNT(*) AS anuladas,
       SUM(CASE WHEN d.id = (SELECT MAX(d2.id) FROM <tenant>.documents d2
              WHERE (d2.user_id = d.user_id OR d2.seller_id = d.user_id)
                AND d2.created_at <= d.updated_at) THEN 1 ELSE 0 END) AS era_ultimo,
       SUM(CASE WHEN d.has_cdr = 1 THEN 1 ELSE 0 END) AS con_cdr_sunat
FROM <tenant>.documents d JOIN <tenant>.users u ON u.id = d.user_id
WHERE d.state_type_id IN ('11','13') GROUP BY u.type;
```
Resultado: **cero filas en los 8 tenants.** No hizo falta el grupo de control porque el
numerador es cero.

Paso 3 — validación de que el cero no fuera un falso negativo por buscar códigos de estado
equivocados:

| Tenant | Documentos | Estados reales | En 11/13 |
|---|---|---|---|
| cuidarte | 240 (238 de vendedores) | `05`=233, `01`=6, `09`=1 | **0** |
| lizfarma | 105 (todos de vendedores) | `05`=105 | **0** |
| boticabienestar | 0 (tabla vacía, pese a 8 vendedores) | — | — |
| adela | 0 | — | — |

Los códigos son correctos (`05` = aceptado domina); `11`/`13` no aparecen en ningún tenant, y
`voided` está en 0 filas en todos. **Ningún vendedor anuló nunca un comprobante**, así que el bug
no tuvo ocasión de corromper nada.

**Por qué el cero es esperable (y no significa "no usan la función"):** en Perú lo correcto sobre
un comprobante ya aceptado por SUNAT es la **nota de crédito**, no la anulación. Con `05`
dominando en los dos tenants con actividad real, el cero dice "corrigen por el camino fiscal
correcto", no "no corrigen ventas".

#### ⚠️ B7 — consecuencia práctica del fix: los reportes de los vendedores cambiaron de números

El `OR` sin agrupar no solo rompía la anulación. Se comía **cualquier filtro encadenado después**
del scope: el SQL quedaba `user_id = X OR (seller_id = X AND <resto de filtros>)`, así que para un
vendedor **todo documento propio pasaba, ignorando los filtros de fecha y de establecimiento**.

Con el fix, esos filtros ahora sí se aplican → **para usuarios tipo vendedor, los listados y
reportes devuelven MENOS filas (las correctas) que antes del 2026-07-26.** Los `admin` no ven
ningún cambio (early-return, nunca pasaban por el bug).

Si un vendedor compara contra una captura previa al deploy va a creer que se rompió algo: no, antes
las cifras estaban infladas.

**Superficie afectada — ~19 llamadores confirmados de `Document::whereTypeUser()`:**
`Api/AppController.php:963,968` · `ContingencyController.php:44` ·
`modules/Document/.../DocumentController.php:81` ·
`DocumentRegularizeShippingController.php:57,68` ·
`MobileApp/.../DashboardController.php:55,169` ·
`MobileApp/.../DocumentController.php:75,175,203,255,467,825,1066,1286,1420` ·
`MobileApp/.../QuickSummaryController.php:39` · `MobileApp/.../ReportController.php:45,324`

Probablemente también (usan el scope pero no se verificó el modelo):
`modules/Report/Traits/ReportTrait.php:156,162`, `MassiveDownloadTrait.php:76`,
`ReportItemController.php:373,390`, `ReportUserCommissionController.php:114,132`.

**No afecta a otros modelos:** `Cash`, `Purchase`, `Item`, `Dispatch`, `User`, `Expense` y
`OrderNote` definen su propio `scopeWhereTypeUser` y ninguno tiene el `OR` sin agrupar
(verificado: usan un `where('user_id')` simple).

#### B8 — Doble toque en "Cobrar" = dos comprobantes a SUNAT **[verificado a mano]**
`mobile/lib/src/features/sales/presentation/sales_screen.dart:2781`:
```dart
onPressed: _cart.isEmpty ? null : () => _submitSale(bundle),
```
Falta `_isSubmitting`. El botón queda habilitado durante toda la llamada de red de
`_ensureCashOpen()` (línea 1798); el flag recién se activa en la 1803. Cada disparo genera su
propia clave de idempotencia (`_nextIdempotencyKey`, línea 1925, usa microsegundos), así que la
protección del backend (`DocumentController.php:1094-1105`) no dedupe: dos documentos
correlativos, stock descontado dos veces, caja descuadrada. Hay que emitir nota de crédito a mano.

El botón del otro sheet (línea 2461) **sí** tiene el guard.

**Arreglo:** `onPressed: (_cart.isEmpty || _isSubmitting) ? null : ...` y mover
`setState(() => _isSubmitting = true)` al inicio de `_submitSale()` con `try/finally`. ~2 horas.

#### B9 — `POST /api/item` sin autenticación: cualquiera cambia precios **[verificado a mano]**
`routes/api.php:16-18` registra la ruta **fuera** de todo grupo con auth (único middleware:
throttle/bindings). `app/Http/Requests/Tenant/ItemRequest.php:25-28` hace `return true` en
`authorize()`. El controlador hace upsert ciego: `Item::firstOrNew(['id' => $id])`
(`ItemController.php:312`) + `fill($request->all())` (`:344`) + `save()` (`:392`), con
`sale_unit_price`, `barcode`, `stock` y `description` en el `$fillable`.

Quien conozca el subdominio del tenant (obtenible sin login vía `POST /api/mobile/v1/resolve-ruc`)
puede **sobrescribir un producto existente enviando su id**: precio en 0, catálogo corrupto, sin
rastro de usuario. Y como Laravel resuelve por orden de registro, esta ruta insegura le gana a la
versión autenticada de `routes/api.php:87`.

**Arreglo (15 min):** mover la ruta al grupo `auth:api` que ya existe en `routes/api.php:28` y
corregir el `authorize()`. **Es una vulnerabilidad viva en producción, no solo un tema de Play Store.**

#### ✅ B9 — CORREGIDO Y DESPLEGADO (2026-07-26, commit `83951a08`)
Se eliminó el bloque de `routes/api.php:16-18`. `POST item` ahora lo resuelve la ruta autenticada
de la línea 87 (`AppController@item`), que antes era inalcanzable. Verificado en vivo:
`POST https://demo.sysfarma.pe/api/item` sin token → **401**.
`Tenant\Api\ItemController@store` quedó sin ninguna ruta que lo alcance (era la única).
El móvil **no estaba afectado**: usa `POST /api/mobile/v1/items`, otro módulo y otro controlador.

`ItemRequest::authorize()` pasó de `return true` a `auth()->check() || auth('api')->check()`.
Se aceptan **los dos guards a propósito**: este FormRequest lo comparten el panel web
(`Tenant\ItemController@store:683`, `ItemSetController@store:119`, guard `web`) y los controladores
de API (guard `api`) — chequear uno solo rompería el otro.

**Pendiente relacionado (va a la sesión 4, con I11):** esto cerró "cualquiera en internet" pero
dejó "cualquiera con cuenta". Un cajero autenticado puede crear y sobrescribir productos, incluidos
precios. La pregunta de si un vendedor debería poder hacerlo es de **permisos**, no de
autenticación, y sigue abierta.

---

## 2. Importantes

#### I1 — No hay forma de cortar el acceso a un celular robado o extrabajador
`modules/MobileApp/.../AuthController.php:34-37` solo genera token si no existe, nunca lo rota.
`PasswordController.php:36-37` cambia la contraseña **sin tocar el token**. No existe ruta
`auth/logout` en `modules/MobileApp/Routes/api.php` — el "Cerrar sesión" del móvil es puramente
local (`mobile_shell_scaffold.dart:243-248`). El token se guarda en texto plano en
`users.api_token` y el guard lo acepta por query string (`config/auth.php:44-47`) → termina en
logs de Apache.

**Arreglo:** endpoint `POST auth/logout` con `$user->updateToken()->save()`, rotar también al
cambiar contraseña, y llamarlo desde el móvil antes de limpiar la sesión.

#### I2 — Reintentar una venta fallida puede emitir el comprobante dos veces
**Cliente:** `sales_screen.dart:1805` genera clave de idempotencia **nueva en cada intento**;
en la rama de error no-red (`:1849`) el carrito no se limpia → el cajero reintenta y el backend
no reconoce el reintento.
**Backend:** `DocumentController.php:1231-1235` **re-ejecuta** un registro de idempotencia en
estado `failed`, y el `catch` de `:1169` marca `failed` incluso cuando el documento ya se guardó
(el legacy hace commit en `:1139`, antes de `findRecord`/`transformRecord`).

**Arreglo:** generar la clave una sola vez por carrito y conservarla en el reintento; en el
backend, antes de re-ejecutar un `failed`, verificar si ya existe documento asociado.

#### I3 — La cola de ventas offline puede perder una venta ya cobrada
`sales_screen.dart:1877` toma una foto de la cola, postea cada venta (hasta 20 s por request) y
en `:1901` hace `replacePendingSales(remaining)` escribiendo esa foto vieja. Si durante ese lapso
el cajero cobra otra venta y se encola (`offline_pos_store.dart:53`, read-modify-write sin lock),
esa venta **se borra**: cobrada, sin comprobante, sin registro, sin mensaje. Mismo patrón en
`pending_sales_sync_service.dart:36-65`, que además puede correr en paralelo desde `/pending-sync`.

**Arreglo:** borrar/actualizar fila por fila con `removePendingSale`/`updatePendingSale`
(ya existen, `offline_pos_store.dart:63` y `:77`) en vez de reescribir la lista, y serializar
los accesos al store.

#### I4 — "Descartar" borra para siempre una venta cobrada, sin confirmar
`pending_sync_screen.dart:89-93` llama directo a `discardOne(key)` → `removePendingSale` →
borrado definitivo. El botón está pegado a "Reintentar", del mismo tamaño (`:148-154`).
La tarjeta **no dice de qué venta se trata**: muestra `Key: mob-1753534891234567-...`,
"Intentos: N", fecha ISO cruda y error técnico (`:125-136`) — aunque cliente, total e ítems
están dentro de `row['payload']`. El patrón correcto ya existe en
`purchases_screen.dart:1497-1515`.

#### I5 — El modo offline no sirve para lo que promete
- Solo se cachean **10 productos y 10 clientes** (`sales_screen.dart:110-115` pide `perPage: 10`,
  `:155-160` guarda eso; `_searchItems`/`_loadMoreItems` traen 40 por página y nunca persisten).
  Con padrones de 1.500-2.000 productos es inutilizable — y el mensaje al usuario promete lo
  contrario (`:217`).
- El cliente se va **sin ticket**: la impresión pasa por el servidor (`:1585-1587` pide el payload
  ESC/POS al backend), aunque la impresora Bluetooth no necesita internet.
- La venta encolada **no descuenta el stock cacheado** → la última unidad se vende dos veces y al
  sincronizar esas ventas se rechazan.

**Recomendación:** NO construirlo antes de lanzar. Cambiar el copy para que no prometa lo que no
hace, y dejar el offline real para v1.1.

#### I6 — Los montos de pago se arrastran de la venta anterior
`_initPaymentTilesIfNeeded` solo se invoca al abrir el sheet de opciones (`sales_screen.dart:270`)
y el bloque de limpieza post-venta (`:1858-1863`) resetea todo **menos** los controles de monto.
Venta de S/ 50 → venta de S/ 10 sin abrir Opciones → payload con total 10 y pago 50 (el backend
lo salva registrando excedente como vuelto). **El caso inverso NO está mitigado: venta de S/ 50
con pago arrastrado de S/ 10 se registra como 10 cobrados.** El desglose por método de pago queda
falseado en todos los casos.

#### I7 — Siete reportes dicen "todo bien" cuando la API falló
`critical_stock_report_screen.dart:29-37` hace `_data = response.data ?? const {}` y descarta
`response.success` → un fallo de red pinta *"Sin productos críticos — Todo el catálogo está por
encima del mínimo"* (`:113-118`). Idéntico en lotes por vencer, vendedor, categoría, hora, visión
de lotes e inventario.

Para una farmacia es información **falsa**: se deja de reponer stock agotado y no se retiran
medicamentos por vencer. El patrón correcto ya existe en `general_report_screen.dart:180-192`.

#### I8 — La actualización automática reinicia la app sola
`app_update_service.dart:28-33` encadena `startFlexibleUpdate()` + `completeFlexibleUpdate()` sin
confirmación, convirtiendo la actualización "flexible" en inmediata; se dispara en cada arranque
(`enter_farma_plus_app.dart:24-26`). El carrito vive solo en memoria (`sales_screen.dart:44`) →
si la descarga termina con el carrito armado, **se pierde la venta**. Existe
`hasUnsavedSaleCartProvider` (`unsaved_work_provider.dart:7`) y no se consulta.
Solo se activa una vez publicada la app → invisible en pruebas locales.

#### I9 — Las notificaciones push están muertas y nadie se entera
No existe `google-services.json` ni `firebase_options.dart` → `Firebase.initializeApp()` falla y
el `catch (_) {}` de `main.dart:17-20` se lo traga; ídem en `push_notification_service.dart:28-49`.
Y aunque se configurara: el **único** consumidor de `MobilePushDevice` en todo el repo es el
controlador que guarda el token — no hay job ni cliente FCM que envíe nada. El enrutamiento por
tipo de notificación es código muerto.

**Decisión de producto:** cerrar el circuito (stock crítico, lotes por vencer, cierre de caja,
fallo de sync) o sacar la dependencia del primer release.

#### I10 — Un empleado puede ver y cerrar la caja de otro
`CashController.php:149-151` hace `Cash::query()->findOrFail($id)` sin filtrar por usuario →
devuelve saldo inicial, ingresos, saldo final, denominaciones contadas y diferencial de arqueo.
Peor: `close()` delega en el legacy, que en `app/Http/Controllers/Tenant/CashController.php:345`
tampoco verifica propietario → **cualquier usuario autenticado puede cerrar la caja de un
compañero y escribirle el arqueo**. El filtro correcto ya existe en `status()`, líneas 23-26.

#### I11 — Tres agujeros de aislamiento multi-sede
- Cualquier vendedor ve reportes de **todas** las sedes con `establishment_id=all`
  (`MobileEstablishmentAccess.php:61-68`, sin comprobar `$user->type`).
- Un usuario de Sede A puede **registrar movimientos de stock en el almacén de Sede B** pasando
  `warehouse_id` (`MobileEstablishmentAccess.php:88-93`, `InventoryController.php:137` solo valida
  que sea entero).
- Inventario suma el stock de todas las sedes (`InventoryController.php:66-68`, default
  `warehouse_id = 'all'`) mientras el reporte sí filtra (`ReportController.php:473-478`) → el mismo
  producto muestra dos stocks distintos en dos pantallas. Y el "ticket promedio" divide el monto de
  una sede entre las operaciones de todas (`ReportController.php:43-65`,
  `DashboardController.php:53-63`) — sub-reportado ~3x en un tenant de 3 locales.
- Cambiar de sede en el celular se lo cambia también a la web y otros dispositivos: se persiste en
  la columna compartida `users.establishment_id` (`SessionController.php:30-31`).

**Censo de exposición (2026-07-26): afecta a UN solo cliente real.**
```sql
-- por cada schema farmacia_*
SELECT COUNT(*) FROM <tenant>.establishments;
```
| Tenants | Sedes |
|---|---|
| **1 cliente real** (`cuidarte`: PUENTE \| BAYOVAR) | 2 |
| `demo` (tenant de pruebas, no cliente) | 2 |
| Los otros 17 | 1 sola |

Con una sola sede, los tres agujeros son inertes: no hay "otra sede" cuya información filtrar.
El único expuesto es **cuidarte**, que además es de los dos con vendedores operando de verdad
(6 vendedores, 240 comprobantes) — así que el hueco es real, pero **acotado a un cliente**.

**Alcance del arreglo: aplicar la regla binaria que YA existe (`type === 'admin'`) en los endpoints
donde hoy no se aplica.** Días de trabajo, sin cambiar el modelo. NO confundir con el rediseño de
permisos, que es un proyecto aparte y post-lanzamiento — ver la sección al final del documento.

#### I12 — El respaldo automático de Android puede dejar la app rota
`mobile/android/app/src/main/AndroidManifest.xml:9-12` no declara `android:allowBackup="false"` ni
reglas de extracción → Android sube a Drive el almacenamiento de la app, incluida la cola
`offline.pos.pending_sales` y el token (`offline_pos_store.dart:14-17`,
`tenant_session_store.dart:23-28`). Al restaurar en otro equipo la clave de cifrado no viaja: bucle
de login y **ventas offline pendientes ilegibles**. Se arregla con una línea.

#### I13 — Permisos que asustan y no se usan
`AndroidManifest.xml:7` declara `ACCESS_FINE_LOCATION` y no hay una línea de código que use
ubicación (verificado por grep; ningún plugin la aporta — la impresora usa dispositivos ya
emparejados). Además `BLUETOOTH_SCAN` (línea 6) sin `neverForLocation`, y
`BLUETOOTH`/`BLUETOOTH_ADMIN` (3-4) sin `maxSdkVersion="30"`.
No bloquea la subida, pero obliga a declarar ubicación en Data Safety (sería falso) y muestra en
la ficha un permiso de "ubicación precisa" injustificado.

#### I14 — El artefacto publicado no está endurecido y puede salir sin monitoreo
No hay `isMinifyEnabled`, `isShrinkResources` ni ProGuard en el bloque release
(`build.gradle.kts:50-58`), ni `--obfuscate`, ni `ndk { debugSymbolLevel = "FULL" }` (`:32-40`):
el APK se decompila trivialmente y los crashes nativos llegan como direcciones hexadecimales.
En paralelo, Sentry queda **apagado** si el build no pasa `--dart-define=SENTRY_DSN`
(`observability.dart:22-31`) y no hay script ni CI que lo inyecte → el primer AAB masivo podría
salir sin ningún reporte de fallos.

#### I15 — Vender deja el stock en negativo, en silencio (hallazgo de campo, 2026-07-26)

**No salió de la auditoría automática: salió de la primera venta real hecha con una cuenta de
vendedor.** Vale como recordatorio de que el camino no-admin no se había ejercitado nunca.

Reproducción: usuario `seller` en `farmacia_demo` (sede 2), emite `B002-1` con un producto que
no tenía existencias en ese almacén. Resultado: `item_warehouse.stock = -1` **y**
`items.stock = -1`. Sin error, sin advertencia, sin bloqueo. Y con `stock_control = 1` activado.

Causa: `modules/MobileApp/Http/Controllers/Api/V1/DocumentController.php:230` hace una resta
ciega al confirmar la venta:
```php
$iw->stock = (float) $iw->stock - $delta;   // sin comprobar que alcance
```
La única defensa es **cosmética**: `ItemController.php:201-210` filtra el catálogo para *mostrar*
solo productos con stock cuando `stock_control` está activo. Eso no impide nada — basta con que el
ítem llegue al carrito por otra vía (búsqueda, caché offline, o que tuviera stock al listarse y
cambiara en el ínterin) para que la venta pase igual.

El contraste deja claro que es un olvido y no una decisión: el **movimiento manual de inventario sí
valida** (`InventoryController.php`, "La cantidad no puede ser mayor a la que se tiene en el
almacén") y la web también (`store_transaction`). Solo el camino de venta no.

**Impacto:** corrompe el dato que a una farmacia más le importa. Stock negativo rompe reposición,
valorización de inventario y cualquier reporte de existencias. Además el cliente configuró
`stock_control = 1` esperando justamente que esto no pase.

**Arreglo:** validar dentro de la transacción antes de descontar, devolviendo un error accionable
("Stock insuficiente de X: quedan N"). Ojo con acumular por ítem y no por línea — ver en la
sección 3 el caso de sobreventa con el mismo producto dos veces en el carrito con presentaciones
distintas, que es la misma familia de defecto.

**Relacionado:** `validate_stock_add_item = 0` en `inventory_configurations` de demo. Conviene
definir qué significa exactamente cada uno de los dos flags, porque hoy `stock_control` solo
filtra el catálogo y no controla nada.

---

## 3. Mejoras (v1.1, salvo la ortografía)

### Presentación y confianza
- **~130 textos sin tildes ni eñes**, empezando por `labelText: 'Contrasena'`
  (`login_screen.dart:606`). `change_password_screen.dart` tiene 13; la barra dice "Cambiar
  contraseña" y el cuerpo "Cambiar contrasena", ambos visibles a la vez.
  **Hacerlo ANTES de tomar las capturas de la ficha.**
- **Contraste:** botón "Cobrar" (naranja `#F97316`, `app_tokens.dart:9`) da 2,80:1 vs 4,5:1 que
  exige WCAG AA; stock crítico 2,15:1. Aparece en el Pre-Launch Report de Play.
- **Áreas táctiles:** los +/− de cantidad del carrito miden 36 dp en vez de 48, y son un
  `GestureDetector` sin feedback visual (`sales_screen.dart:3396-3421`) — el control más repetido.
- El aviso "Sin conexión" se dibuja debajo de la barra de estado y queda tapado
  (`mobile_shell_scaffold.dart:164-171`, sin `SafeArea`).
- Recortes de texto en 320 dp o fuente "Grande": `reports_screen.dart:85-90`,
  `settings_screen.dart:100`, `inventory_screen.dart:1222-1239` (sin `Flexible`).
- Ícono del launcher a ~35% del lienzo por doble margen (`ic_launcher.xml:7` + PNG ya recortado),
  y sin capa monocromática para iconos temáticos de Android 13+.
- **Tres versiones distintas:** login v0.1.0 (`login_screen.dart:408`), perfil v1.0.0
  (`profile_screen.dart:12`), bundle 5.161.118 (`pubspec.yaml:4`). Leer de `package_info_plus`.

### Flujo de cobro y funciones que faltan
- **Método de pago y vuelto escondidos** detrás de un ícono de tuerca sin etiqueta
  (`sales_screen.dart:2616-2620`). Cobrar en Yape/Plin cuesta 4-5 toques extra y si no se hace
  **la venta queda como efectivo**. El vuelto aparece en un solo lugar (`:485`), dentro de ese
  sheet. **Es la mejora de UX con más retorno.**
- **No hay descuentos en el POS:** `total_discount` fijo en 0 (`sales_screen.dart:1995`).
- **No hay movimientos manuales de caja** → el arqueo siempre marca faltante.
  Ya reconocido en `ROADMAP.md:236`.
- **Cuentas por cobrar/pagar:** backend completo (`PendingController.php:19-31`, rutas
  `api.php:110-111`), métodos en `api_client.dart:908` y `:923`… con **cero llamadas**.
  Falta además `POST documents/{id}/payments`.
- Un admin multi-sede no puede ver el consolidado: ningún método de reporte de `api_client.dart`
  acepta `establishmentId`, aunque el backend ya lo soporta.

### Defectos acotados
- Sobreventa con el mismo producto dos veces en el carrito con presentaciones distintas: stock
  validado por línea, no acumulado (`sales_screen.dart:1023-1031`).
- Coma decimal: "20,50" se toma como 0 sin avisar (`sales_screen.dart:817-820`,
  `dashboard_screen.dart:332-334`). Ya se normaliza bien en `stocktake_session_screen.dart:563`.
- "Registrar compra" sin guard de doble envío y con **fallo silencioso**
  (`purchases_screen.dart:1030-1109`).
- Nota de crédito parcial: dos líneas del mismo producto se pisan
  (`sales_history_screen.dart:660-672`).
- Ventas rechazadas se reintentan para siempre; el contador se escribe y nunca se consulta
  (`pending_sales_sync_service.dart:54-62`).
- Conteo físico: cantidad vacía o mal tipeada se graba como 0
  (`stocktake_session_screen.dart:559-566`).
- Sesión vencida en Dashboard/Inventario/Reportes: sin redirect, muestra
  `FormatException: No hay una sesion o URL base configurada` (`api_client.dart:1284-1288`).

### Higiene técnica
- **24 `print()` activos en release** (`api_client.dart:1159/1168/1173/1175` + 6 archivos) que
  vuelcan a logcat la URL del tenant, cada endpoint con parámetros (incluidas búsquedas por DNI)
  y el cuerpo de las respuestas de error. El token no se imprime.
- **Mensajes técnicos crudos al usuario:** 13 puntos devuelven `$exception->getMessage()` tal cual
  (`DocumentController.php:1169-1180` y otros) — SQLSTATE, rutas, "Too Many Attempts.".
- **Rendimiento:** N+1 en lotes por vencer (`ReportController.php:289-290`, ~800 consultas),
  listado de compras (`PurchaseResource.php:22`), catálogo (`Item.php:2767-2782`); paginación en
  PHP en `PendingController.php:112-118`; `ReportController.php:199-261` carga todos los productos
  antes de recortar.
- **Sin tests sobre nada que mueva plata:** 4 archivos triviales, y `.github/workflows/qa.yml`
  no menciona Flutter → no se ejecutan en CI.
- **IGV fijo al 18%** escrito tres veces a mano (`sales_screen.dart:2112-2116`).
- **`scopeWhereTypeUser` ordena además de filtrar** (`app/Models/Tenant/Document.php:773`,
  hallazgo agregado 2026-07-24 al corregir B7). El scope termina en `->latest()`, así que **impone
  un `ORDER BY id DESC` a toda query que lo use** — y son muchas (`DocumentController.php:1629`,
  `CashController.php:137/1308`, `PurchaseController.php:104-112`, `ContingencyController.php:44`,
  `AppController.php:963/968`, entre otras). Quien lo encadene esperando solo el filtro de permisos
  se lleva un orden que no pidió, y cualquier `orderBy` propio anterior queda pisado.
  Es la misma clase de bomba que causó B7: un scope que hace más de lo que su nombre dice.
  **No se tocó en el hotfix** porque sacarlo cambia el orden de varios listados en producción y
  necesita su propia pasada de regresión.
- **PDFs de comprobantes públicos sin login** (`routes/web.php:59-65`, fuera del grupo `auth`):
  con el UUID —que la API móvil devuelve en cada listado— cualquiera descarga la boleta con DNI,
  dirección y detalle de medicamentos. Pre-existente de la web, no regresión del móvil.
- **Rutas construidas y nunca usadas:** `dashboard/notifications`, `quick/summary`,
  `documents/{id}/consult-cdr`, y un `POST cash` duplicado.
- **`mobile/docs/ROADMAP.md` miente:** 5 ítems marcados pendientes ya están hechos (reportes por
  categoría/hora, Sentry, In-App Update, paginación de inventario, endpoint de notificaciones).

---

# Modelo de permisos y alcance de datos

> **REVISADO 2026-07-27.** La versión anterior de esta sección decía "no ejecutar antes de
> publicar". **Se partió en dos**, porque el paso caro no era el que parecía: el trabajo grande no
> es agregar el tercer nivel de alcance, sino **dejar de derivar el alcance del `type`**. Eso es
> mecánico y verificable, y va **ahora** (sesión 4-bis en `docs/plan-lanzamiento.md`).
>
> - **AHORA (sesión 4-bis):** eje 1 completo + eje 2 fase A (`users.data_scope` con `own`/`all` +
>   resolver único). Requiere la Parte B de la sesión 1 (tests de alcance) hecha antes.
> - **POST-LANZAMIENTO:** el tercer valor `establishment` del resolver. Sin evidencia de que el
>   caso exista (17 de 18 clientes con una sola sede); se diseña con los datos de la prueba cerrada.
>
> **Sigue sin ser I11.** I11 es *un hueco de seguridad* que se cierra aplicando la regla binaria
> que ya existe. Esto es *cómo está construida* la regla. Y —verificado— **el refactor NO cierra
> I11 solo**: los huecos de I11 viven en `MobileEstablishmentAccess` (eje establecimiento, por
> parámetro de request), no en los scopes `whereTypeUser` (eje usuario, sobre el query). Cablear el
> resolver ahí es trabajo adicional deliberado.
>
> ⚠️ **Y lo más importante:** los permisos que el móvil guarda sirven para **dibujar la interfaz,
> no para autorizar**. Ya se comprobó que el guard del router es solo cliente y la API no lo
> replica. **La autorización real va en el backend, endpoint por endpoint.** Esconder el botón es
> UX; sin verificación del lado del servidor no es seguridad.

## El hallazgo: conviven tres sistemas que no se hablan

| | Qué controla | Cómo decide |
|---|---|---|
| **Permisos web** (`ModuleLevel` / `ModuleLevelUser`) | a qué módulos y niveles entra el usuario | **configurable por usuario** ✅ |
| **Alcance de datos** (`whereTypeUser`, 22 modelos) | qué filas ve | hardcodeado: `type === 'admin'` |
| **Móvil** (`isSeller`) | qué pestañas y pantallas ve | hardcodeado: `userType == 'seller'` |

El módulo de permisos existe y funciona: middlewares `redirect.module` y `redirect.level`
(`app/Http/Kernel.php:74-75`), modelos en `modules/LevelAccess/Models/`, con `$user->getLevel()`
y `getLevels()`.

**Y el backend ya le manda los permisos al móvil** (`modules/MobileApp/Support/MobileAppPayload.php:46-49`):
```php
'permissions' => [
    'levels' => $user->getCurrentModuleLevelByTenant()->pluck('module_level_id')->values(),
]
```
**La app los descarta.** `mobile/lib/src/core/models/tenant_session.dart` no tiene campo para
guardarlos: solo persiste `userType`, y de ahí deriva un booleano (`isSeller`, línea 20) que decide
todo en 5 lugares — pestañas del menú (`mobile_shell_scaffold.dart:105,313`), guard del router
(`app_router.dart:52`), acciones rápidas del inicio (`dashboard_screen.dart:781,1253`) y opciones
de "Más" (`settings_screen.dart:18`).

**Consecuencia:** si se le da a un vendedor acceso al módulo de Compras desde la web, entra por la
web (los permisos funcionan), **no lo ve en el móvil** (guard con lista fija de 5 rutas) y **sigue
viendo solo sus datos** (el scope mira `type`, no permisos). El permiso funciona en un tercio del
sistema.

## Son dos ejes, no uno

Unificar todo bajo "permisos" no alcanza:
- **Permiso** responde: *¿puede abrir la pantalla de Ventas emitidas?*
- **Alcance** responde: *¿qué filas ve ahí — solo las suyas, las de su sucursal, o todas?*

Hoy el segundo eje es un binario sin punto medio, y **"todo lo de mi sucursal" —lo que necesitaría
un encargado— no existe como opción.**

## Por qué NO diseñarlo ahora

1. **No hay evidencia de que el caso exista.** El eje propio/sede/todo asume la figura del encargado
   que ve su sucursal completa. Censo 2026-07-26: **17 de 18 clientes reales tienen UNA sola sede**;
   el único multi-sede es cuidarte. Y de los tenants con vendedores, ninguno configuró comisiones.
   Sería diseñar un modelo de tres niveles a partir de un caso hipotético — el camino habitual
   hacia un sistema más complejo que el problema real.
2. **El instrumento para diseñarlo bien está en el calendario y todavía no se usó:** los 14 días de
   prueba cerrada (B6) con cajeros reales de cuidarte y lizfarma. Ahí se va a ver qué piden, qué no
   encuentran y qué les sobra. Diseñar el modelo después de eso cuesta la mitad y acierta el doble.
3. **Riesgo de calendario:** es un refactor de 22 scopes + el modelo de sesión del móvil + sus 5
   sitios de uso, sobre código sin tests, en el camino crítico del lanzamiento.

## Dirección propuesta (para cuando toque)

1. Que el móvil consuma `permissions.levels` que **ya recibe**, en vez de derivar todo de `isSeller`.
2. Agregar el eje de alcance como configuración explícita (propio / sede / todo), reemplazando el
   hardcodeo de `type` en los 22 scopes `whereTypeUser`.

**Prerrequisito:** haber cerrado I11 con la regla actual, y tener los resultados de la prueba
cerrada.
