# ADR-019 : Caisse hors ligne

**Statut :** Accepté
**Date :** 2026-10-26
**Contexte :** Caisse (`stock-management/pos`), sur le socle de la vente serveur (`PosOrderService`, prix calculés par `OrderItemPricer`), des paiements déclarés (ADR-020) et des lots (ADR-018)

---

## Contexte

Les coupures d'internet et les délestages sont fréquents en Afrique de l'Ouest. Aujourd'hui la caisse appelle le serveur à chaque vente : sans réseau, le vendeur ne peut plus encaisser, ou note les ventes sur un cahier puis les ressaisit (ou les oublie). C'est l'argument principal des caisses concurrentes.

Contraintes :
- Le serveur reste **seul maître des prix et des montants** (audit WES du 20/07/2026, `OrderItemPricer`) : un prix envoyé par le navigateur n'est jamais repris.
- Le jeton JWT dure 8 heures et n'est pas rafraîchi par le front (ADR-001) : une coupure longue peut finir avec un jeton expiré.
- Un navigateur peut vider son stockage local ; une vente enregistrée seulement dans le navigateur peut être perdue.
- L'ADR-001 interdit de coupler davantage de logique au mécanisme JWT.

## Décision

### 1. Périmètre

La **caisse** fonctionne hors ligne : recherche et ajout de produits, encaissement, ticket. Les autres écrans restent en ligne. L'application démarre sans réseau si elle a déjà été ouverte en ligne sur l'appareil.

### 2. Dans le navigateur

- **Service worker** (`public/sw.js`) : met en cache l'application (page, scripts, styles) ; les appels à l'API ne sont jamais servis depuis ce cache.
- **IndexedDB** (`gestionstock-pos`), par entreprise et par utilisateur :
  - **instantané de la caisse** (produits avec prix, taxes, clients, catégories, marques, modes de paiement, traductions), rafraîchi à chaque chargement en ligne ;
  - **file des ventes hors ligne**, et liste des ventes refusées à la synchronisation.
- Chaque encaissement reçoit un **identifiant généré par l'appareil** (`client_uuid`, UUID v4), envoyé aussi à `pos/save` en ligne. Si la réponse se perd après l'enregistrement, la vente part dans la file avec le même identifiant et le serveur la reconnaît : pas de doublon.
- Une vente gardée hors ligne reçoit un **numéro de ticket local** (`HL-<appareil>-<n°>`), imprimé sur le ticket avec la mention « facture définitive à la synchronisation ».
- Le navigateur demande un **stockage persistant** ; un bouton exporte les ventes en attente ou refusées dans un fichier, en filet de sécurité.
- Un indicateur affiche « En ligne / Hors ligne » et le nombre de ventes en attente. La synchronisation part au retour du réseau, au chargement de la caisse et toutes les minutes. Jeton expiré : rien n'est envoyé, la caisse demande une reconnexion, la file est conservée.
- **Jeton expiré sans réseau** : on ne peut pas se reconnecter. La caisse (et elle seule) reste ouverte au dernier utilisateur connecté tant que le navigateur est hors ligne ; ses ventes partiront après sa reconnexion.
- Les ventes partent sous le nom de l'utilisateur connecté qui synchronise : la file étant rangée par utilisateur et par entrepôt, chacun n'envoie que ses propres ventes.
- À la déconnexion, l'instantané (catalogue, clients) est effacé de l'appareil ; les ventes non envoyées restent.

### 3. Sur le serveur

- Endpoint `POST pos/offline-sales` (garde `check.feature:pos`, permission `pos_view`), par lots de 50 ventes au plus. Chaque vente est traitée **dans sa propre transaction** : une vente refusée n'empêche pas les autres.
- **Idempotence** : `orders.client_uuid`, unique par entreprise. Une vente déjà reçue renvoie « déjà synchronisée » avec son numéro de facture, sans rien recréer.
- La vente passe par `PosOrderService`, à sa **date réelle** (`sold_at`), avec son vendeur. Elle reçoit son numéro de facture officiel ; le numéro du ticket hors ligne est conservé (`orders.offline_reference`).
- **Refus** (renvoyés au navigateur, qui les garde dans « ventes refusées ») : vente datée de plus de **7 jours**, période de stock verrouillée (ADR-007), données invalides.
- **À renvoyer plus tard** (la vente reste en attente) : vente faite dans un autre entrepôt que l'entrepôt courant, erreur passagère du serveur.
- **Prix** : le serveur recalcule les lignes, puis la taxe de commande, la remise et les frais avec les taux actuels. Si le total diffère de celui du ticket (prix modifié entre-temps), la vente est enregistrée au total serveur et **signalée** (`orders.offline_issues`).
- **Paiement impossible à enregistrer tel quel** (référence déjà utilisée, manquante ou invalide — ADR-020 ; mode de paiement supprimé) : la vente est enregistrée **sans paiement** et signalée avec le montant et la référence déclarés, plutôt que perdue.
- **Client supprimé** depuis la vente : vendue au client de passage, signalée. **Horloge de l'appareil en avance** (plus de 10 minutes) : vente datée de sa synchronisation, signalée.
- Le blocage de la vente des périmés (ADR-018 §3) ne s'applique pas : la vente a déjà eu lieu.
- Les ventes signalées apparaissent dans les alertes (hors cache, pour apparaître et disparaître aussitôt), jusqu'à ce qu'un utilisateur ayant `payments_reconcile` les marque comme vérifiées.

### Révision du 27/10/2026 : ventes à vérifier depuis la caisse

Le test en navigateur, sur staging en HTTPS, a montré que les alertes intelligentes relèvent des rapports (`check.feature:reports`). La formule PME inclut pourtant la caisse sans les rapports : la notification « voir les alertes » renvoyait vers une page refusée, et personne ne pouvait vérifier les ventes signalées.

- Endpoint `GET pos/offline-sales/to-check` (garde `check.feature:pos`, permission `payments_reconcile`) : les ventes signalées non vérifiées de l'entrepôt.
- La caisse affiche « N vente(s) à vérifier » à qui a `payments_reconcile`, avec le même tableau que l'onglet des alertes (composant partagé `OfflineSaleChecks`). L'onglet reste en place pour les formules qui ont les rapports.
- La notification de synchronisation ne renvoie plus vers les alertes : « à faire vérifier par un responsable ».
- Le même test a montré que le service worker cassait le bip d'ajout d'article, en ligne comme hors ligne : un son est demandé par plages d'octets, et la réponse partielle (206) ne peut pas être mise en cache. Le service worker laisse passer ces demandes, garde le fichier entier, le précharge dès l'installation, et ne met plus en cache que les réponses 200. Il ignore aussi `Vary: Accept-Encoding` (envoyé par Apache) pour retrouver le fichier gardé : le lecteur audio demande `Accept-Encoding: identity`, et sans cela le bip restait introuvable hors ligne sur staging (le serveur local n'envoie pas cet en-tête).
- Au retour du réseau, une caisse démarrée sur l'instantané recharge son catalogue : les prix redeviennent ceux du serveur sans recharger la page.

### Révision du 27/10/2026 : fonctionnalité de l'abonnement

La caisse hors ligne devient une fonctionnalité de plan, `plans.has_offline_pos` (`Feature::OfflinePos`), réglable par le superadmin. Elle **suppose la caisse** (dépendance dans `FeatureGate`). Au départ, elle est incluse dans les formules Business et Entreprise, pas dans la formule PME (décision produit : argument de montée en gamme). Aucune vente hors ligne n'existait en production au moment du changement.

- **Serveur** : `POST pos/offline-sales` passe sous `check.feature:offline_pos`. La liste et la vérification des ventes signalées restent sous `check.feature:pos` : une vente signalée avant un changement de formule doit pouvoir être vérifiée.
- **Navigateur** : sans la fonctionnalité (`offline_pos_enabled`), la caisse ne garde ni instantané (catalogue, clients, modes de paiement) ni vente sur l'appareil. Une vente sans réseau affiche un message au lieu d'être gardée. La synchronisation automatique ne démarre pas. Les ventes déjà gardées avant un changement de formule restent affichées, avec l'export et un message : le serveur refuserait leur envoi.
- Le service worker reste enregistré pour tous : il ne garde que l'application et ses fichiers, sans donnée du tenant.

## Alternatives Considérées

### Application mobile native avec base locale
Plus robuste pour un usage hors ligne prolongé. Rejetée pour cette version : nouvelle application à maintenir, alors que la caisse web existe et sert déjà les clients.

### Reprendre les prix du ticket hors ligne
Le ticket imprimé et la facture seraient toujours identiques. Rejeté : un navigateur modifié pourrait fabriquer n'importe quel montant, ce que l'audit du 20/07/2026 a précisément corrigé. L'écart est signalé à la place.

### Mettre en cache les réponses de l'API dans le service worker
Plus simple à brancher. Rejeté : les réponses dépendent de l'utilisateur connecté (jeton), et une caisse partagée servirait hors ligne les données d'un autre vendeur. L'instantané IndexedDB est rangé par entreprise et par utilisateur.

## Conséquences

- Nouvelles colonnes `orders.client_uuid`, `orders.offline_reference`, `orders.offline_issues`, `orders.offline_issues_checked_at`, `orders.offline_issues_checked_by`.
- Le ticket d'une vente hors ligne peut différer de la facture si un prix a changé avant la synchronisation : l'écart est visible dans la caisse (et dans les alertes pour les formules qui ont les rapports).
- Ventes hors ligne sans contrôle de stock ni de blocage des périmés.
- Premiers tests JavaScript du projet (Vitest), pour la file et la synchronisation.
- Une vente reste dans le navigateur tant qu'elle n'est pas synchronisée : un appareil perdu ou un stockage vidé la perd. L'export manuel et l'indicateur de ventes en attente limitent ce risque.
- L'instantané contient la liste des clients de l'entrepôt, gardée sur l'appareil jusqu'à la déconnexion.
- **Écart constaté, hors périmètre** : en ligne, `pos/save` reprend encore de l'écran le total de l'en-tête (taxe de commande, remise, frais) ; seules les lignes sont reprixées par le serveur. La synchronisation hors ligne, elle, recalcule tout. Aligner `pos/save` sur ce calcul est un chantier à part (changement de comportement visible à la caisse).

## Quand Réviser

- Si des boutiques restent hors ligne plusieurs jours : application native, ou délai de 7 jours à revoir.
- Si plusieurs caisses hors ligne vendent le même stock : alerte de stock négatif à la synchronisation.
- Si la migration vers Sanctum (ADR-001) a lieu : vérifier la reprise de session après une longue coupure.
