feat: Version 3.6.2 - Correctifs tâches #17-20

- #17: Amélioration gestion des secteurs et statistiques
- #18: Optimisation services API et logs
- #19: Corrections Flutter widgets et repositories
- #20: Fix création passage - détection automatique ope_users.id vs users.id

Suppression dossier web/ (migration vers app Flutter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 14:11:15 +01:00
parent 7b78037175
commit 232940b1eb
196 changed files with 8483 additions and 7966 deletions

View File

@@ -278,55 +278,102 @@ Log::info('Stripe account activated', [
## 📱 FLOW TAP TO PAY (Application Flutter)
### 🎯 Architecture technique
Le flow Tap to Pay repose sur trois composants principaux :
1. **DeviceInfoService** - Vérification de compatibilité hardware (Android SDK ≥ 28, NFC disponible)
2. **StripeTapToPayService** - Gestion du SDK Stripe Terminal et des paiements
3. **Backend API** - Endpoints PHP pour les tokens de connexion et PaymentIntents
### 🔄 Diagramme de séquence complet
```
┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────┐
│ App Flutter │ │ API PHP │ │ Stripe │ │ Carte
└──────┬──────┘ └──────┬──────┘ └────┬─────┘ └────┬────┘
│ │ │ │
[1] │ Validation form │ │
│ + montant CB
│ │
[2] │ POST/PUT passage │ │
│──────────────────>│ │ │
│ │
[3] │<──────────────────│ │ │
Passage ID: 456
[4] │ POST create-intent│ │
──────────────────>│ (avec passage_id: 456)
│ │
[5] │ │ Create PaymentIntent
│─────────────────>│
│ │
[6] │ │<─────────────────│
│ pi_xxx + secret │ │
[7] │<──────────────────│ │
PaymentIntent ID
│ │
[8] │ SDK Terminal Init │ │
"Approchez carte"
│ │ │
[9] │<──────────────────────────────────────────────────────│
NFC : Lecture carte sans contact
[10] │ Process Payment │ │ │
───────────────────────────────────>│
│ │
[11] │<───────────────────────────────────│
Payment Success
│ │
[12] │ POST confirm
──────────────────>│ │
│ │
[13] │ PUT passage/456
──────────────────>│ (ajout stripe_payment_id)
│ │
[14] │<──────────────────│ │
Passage updated │ │ │
│ │
┌─────────────┐ ┌─────────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐
│ App Flutter │ │ DeviceInfo │ │ API │ │ Stripe │ │ SDK Terminal
│ │ Service │ │ PHP │ │ │ │ │
└──────┬──────┘ └────────┬────────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘
│ │ │ │
[1] │ Login utilisateur │
────────────────────>│
│ │ │ │
[2] │ │ checkStripeCertification()
│ • Android SDK ≥ 28
│ │ • NFC disponible
│ │
[3] │<────────────────────│
│ ✅ Compatible │ │ │
│ │ │
[4] │ Validation form
│ + montant CB │ │ │
│ │ │
[5] │ POST/PUT passage
│────────────────────────────────────────>│ │
│ │
[6] │<────────────────────────────────────────
│ Passage ID: 456 │ │ │
│ │
[7] │ initialize()
│────────────────────────────────────────────────────────────────────────────>
│ │
[8] │ │ │ Terminal.initTerminal()
│ │ │ │ │ (fetchToken callback)
│ │ │
[9] │ │ POST /terminal/connection-token
│────────────────────────────────────────>│
{amicale_id, stripe_account, location_id} │
│ │
[10] │ │ │ CreateConnectionToken
│───────────────>│
│ │
[11] │ │<───────────────│
│ │ {secret: "..."}│
│ │
[12] │<────────────────────────────────────────
Connection Token │ │ │
│ │
[13] │────────────────────────────────────────────────────────────────────────────>
Token delivered to SDK │ │ ✅ SDK Ready │
│ │
[14] │ createPaymentIntent() │ │ │
│────────────────────────────────────────>│ │ │
│ {amount, passage_id, amicale_id} │ │ │
│ │ │ │ │
[15] │ │ │ Create PaymentIntent │
│ │ │───────────────>│ │
│ │ │ │ │
[16] │ │ │<───────────────│ │
│ │ │ pi_xxx + secret│ │
│ │ │ │ │
[17] │<────────────────────────────────────────│ │ │
│ PaymentIntent ID │ │ │ │
│ │ │ │ │
[18] │ collectPayment() │ │ │ │
│────────────────────────────────────────────────────────────────────────────>│
│ │ │ │ │
[19] │ │ │ │ discoverReaders()
│ │ │ │ + connectReader()
│ │ │ │ │
[20] │ │ │ │ collectPaymentMethod()
│ 💳 "Présentez la carte" │ │ ⏳ Attente NFC │
│ │ │ │ │
[21] │<──────────────────────────────────────────────────────────── 📱 TAP CARTE │
│ │ │ │ │
[22] │ confirmPayment() │ │ │ │
│────────────────────────────────────────────────────────────────────────────>│
│ │ │ │ │
[23] │ │ │ │ confirmPaymentIntent()
│ │ │ │ │
[24] │ │ │ │ ✅ Succeeded │
│<────────────────────────────────────────────────────────────────────────────│
│ Payment Success │ │ │ │
│ │ │ │ │
[25] │ PUT passage/456 │ │ │ │
│────────────────────────────────────────>│ │ │
│ {stripe_payment_id: "pi_xxx"} │ │ │
│ │ │ │ │
[26] │<────────────────────────────────────────│ │ │
│ ✅ Passage updated │ │ │ │
```
### 🎮 Gestion du Terminal de Paiement
@@ -378,6 +425,59 @@ Le terminal de paiement reste affiché jusqu'à la réponse définitive de Strip
- **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe
- **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte
### 🔑 Connection Token - Flow détaillé
Le Connection Token est crucial pour le SDK Stripe Terminal. Il est récupéré via un callback au moment de l'initialisation.
**Code côté App (stripe_tap_to_pay_service.dart:87-89) :**
```dart
await Terminal.initTerminal(
fetchToken: _fetchConnectionToken, // Callback appelé automatiquement
);
```
**Callback de récupération (lignes 137-161) :**
```dart
Future<String> _fetchConnectionToken() async {
debugPrint('🔑 Récupération du token de connexion Stripe...');
final response = await ApiService.instance.post(
'/stripe/terminal/connection-token',
data: {
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
}
);
final token = response.data['secret'];
if (token == null || token.isEmpty) {
throw Exception('Token de connexion invalide');
}
debugPrint('✅ Token de connexion récupéré');
return token;
}
```
**Backend PHP :**
```php
// POST /stripe/terminal/connection-token
$token = \Stripe\Terminal\ConnectionToken::create([], [
'stripe_account' => $amicale->stripe_id,
]);
return response()->json([
'secret' => $token->secret,
]);
```
**Points importants :**
- ✅ Le token est **temporaire** (valide quelques minutes)
- ✅ Un nouveau token est créé à **chaque initialisation** du SDK
- ✅ Le token est spécifique au **compte Stripe Connect** de l'amicale
- ✅ Utilisé pour **authentifier** le Terminal SDK auprès de Stripe
### 📋 Détail des étapes
#### Étape 1 : VALIDATION DU FORMULAIRE
@@ -1085,28 +1185,168 @@ POST/PUT /api/passages
## 🔄 GESTION DES ERREURS
### 📱 Erreurs Tap to Pay
### 📱 Erreurs Tap to Pay - Messages utilisateur clairs
| Code erreur | Description | Action utilisateur |
|-------------|-------------|-------------------|
| `device_not_compatible` | iPhone non compatible | Afficher message explicatif |
| `nfc_disabled` | NFC désactivé | Demander activation dans réglages |
| `card_declined` | Carte refusée | Essayer autre carte |
| `insufficient_funds` | Solde insuffisant | Essayer autre carte |
| `network_error` | Erreur réseau | Réessayer ou mode offline |
| `timeout` | Timeout lecture carte | Rapprocher carte et réessayer |
L'application détecte automatiquement le type d'erreur et affiche un message adapté :
### 🔄 Flow de retry
#### Gestion intelligente des erreurs (passage_form_dialog.dart)
```dart
catch (e) {
final errorMsg = e.toString().toLowerCase();
String userMessage;
bool shouldCancelPayment = true;
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
// Annulation volontaire
userMessage = 'Paiement annulé';
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
// Timeout NFC avec conseils
userMessage = 'Lecture de la carte impossible.\n\n'
'Conseils :\n'
'• Maintenez la carte contre le dos du téléphone\n'
'• Ne bougez pas jusqu\'à confirmation\n'
'• Retirez la coque si nécessaire\n'
'• Essayez différentes positions sur le téléphone';
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
// PaymentIntent existe déjà
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
'Veuillez réessayer dans quelques instants.';
shouldCancelPayment = false;
}
// Annulation automatique du PaymentIntent pour permettre nouvelle tentative
if (shouldCancelPayment && _paymentIntentId != null) {
await StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
}
}
```
#### Table des erreurs et actions
| Type erreur | Message utilisateur | Action automatique |
|-------------|--------------------|--------------------|
| `canceled` / `cancelled` | "Paiement annulé" | Annulation PaymentIntent ✅ |
| `cardReadTimedOut` | Message avec 4 conseils NFC | Annulation PaymentIntent ✅ |
| `already payment` | "Paiement déjà en cours" | Pas d'annulation ⏳ |
| `device_not_compatible` | "Appareil non compatible" | Annulation PaymentIntent ✅ |
| `nfc_disabled` | "NFC désactivé" | Annulation PaymentIntent ✅ |
| Autre erreur | Message technique complet | Annulation PaymentIntent ✅ |
### ⚠️ Contraintes NFC - Tap to Pay vs Google Pay
**Différence fondamentale :**
- **Google Pay (émission)** : Le téléphone *émet* un signal NFC puissant → fonctionne avec coque
- **Tap to Pay (réception)** : Le téléphone *lit* le signal de la carte → très sensible aux interférences
#### Coques problématiques
-**Kevlar / Carbone** : Fibres conductrices perturbent la réception NFC
-**Métal** : Bloque complètement les ondes NFC
-**Coque épaisse** : Réduit la portée effective
-**TPU / Silicone** : Compatible
#### Bonnes pratiques pour réussite NFC
**Position optimale :**
```
┌─────────────────┐
│ 📱 Téléphone │
│ │
│ [Capteur NFC]│ ← Généralement vers le haut du dos
│ │
│ │
│ 💳 Carte ici │ ← Maintenir immobile 3-5 secondes
└─────────────────┘
```
**Checklist utilisateur :**
1. ✅ Retirer la coque si échec
2. ✅ Carte à plat contre le dos du téléphone
3. ✅ Ne pas bouger pendant toute la lecture
4. ✅ Essayer différentes positions (haut/milieu du téléphone)
5. ✅ Carte sans contact activée (logo sans contact visible)
### 🔄 Flow de retry automatique
```
1. Erreur détectée
2. Message utilisateur explicite
3. Option "Réessayer" proposée
4. Conservation du montant et contexte
5. Nouveau PaymentIntent si nécessaire
6. Maximum 3 tentatives
1. Erreur détectée → Analyse du type
2. Annulation automatique PaymentIntent (si applicable)
3. Message clair avec conseils contextuels
4. Bouton "Réessayer" disponible
5. Nouveau PaymentIntent créé automatiquement
6. Conservation du contexte (montant, passage)
```
**Avantages :**
- ✅ Pas de blocage "PaymentIntent déjà existant"
- ✅ Nombre illimité de tentatives
- ✅ Contexte préservé (pas besoin de tout ressaisir)
- ✅ Messages orientés solution plutôt qu'erreur technique
### 🏗️ Environnement et Build Release
#### Détection automatique de l'environnement
L'application détecte l'environnement via l'URL de l'API (plus fiable que `kDebugMode`) :
```dart
// stripe_tap_to_pay_service.dart (lignes 236-252)
Future<bool> _ensureReaderConnected() async {
// Détection via URL API
final apiUrl = ApiService.instance.baseUrl;
final isProduction = apiUrl.contains('app3.geosector.fr');
final isSimulated = !isProduction;
final config = TapToPayDiscoveryConfiguration(
isSimulated: isSimulated,
);
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
debugPrint('🔧 isSimulated: $isSimulated');
}
```
**Mapping environnement :**
| URL API | Environnement | Reader Stripe | Cartes acceptées |
|---------|---------------|---------------|------------------|
| `dapp.geosector.fr` | DEV | Simulé | Cartes test uniquement |
| `rapp.geosector.fr` | REC | Simulé | Cartes test uniquement |
| `app3.geosector.fr` | PROD | Réel | Cartes réelles uniquement |
#### ⚠️ Restriction Stripe - Build Release obligatoire en PROD
**Erreur si app debuggable en PROD :**
```
Debuggable applications are not supported when using the production
version of the Tap to Pay reader. Please use a simulated version of
the reader by setting TapToPayDiscoveryConfiguration.isSimulated to true.
```
**Solution - Build release :**
```bash
# Build APK optimisé pour production
flutter build apk --release
# Installation sur device
adb install build/app/outputs/flutter-apk/app-release.apk
```
**Différences debug vs release :**
| Aspect | Debug (`flutter run`) | Release (`flutter build`) |
|--------|-----------------------|--------------------|
| **Optimisation** | ❌ Code non optimisé | ✅ R8/ProGuard activé |
| **Taille APK** | ~200 MB | ~30-50 MB |
| **Performance** | Lente (dev mode) | Rapide (optimisée) |
| **Tap to Pay PROD** | ❌ Refusé par Stripe | ✅ Accepté |
| **Débogage** | ✅ Hot reload, logs | ❌ Pas de hot reload |
**Pourquoi Stripe refuse les apps debug :**
- **Sécurité renforcée** : Les apps debuggables peuvent être inspectées
- **Conformité PCI-DSS** : Exigences de sécurité pour paiements réels
- **Protection production** : Éviter utilisation accidentelle de readers réels en dev
---
## 📊 MONITORING ET LOGS

216
app/docs/connexions-api.md Normal file
View File

@@ -0,0 +1,216 @@
API Event Stats - Guide Flutter
Authentification
Toutes les routes nécessitent un Bearer Token (session) et un rôle Admin (2) ou Super-admin (1).
headers: {
'Authorization': 'Bearer $sessionToken',
'Content-Type': 'application/json',
}
---
1. Résumé du jour
GET /api/events/stats/summary?date=2025-12-22
| Param | Type | Requis | Description |
| ----- | ------ | ------ | ------------------------------------- |
| date | string | Non | Date YYYY-MM-DD (défaut: aujourd'hui) |
Réponse (~1 KB) :
{
"status": "success",
"data": {
"date": "2025-12-22",
"stats": {
"auth": { "success": 45, "failed": 3, "logout": 12 },
"passages": { "created": 128, "updated": 5, "deleted": 2, "amount": 2450.00 },
"users": { "created": 2, "updated": 5, "deleted": 0 },
"sectors": { "created": 1, "updated": 3, "deleted": 0 },
"stripe": { "created": 15, "success": 12, "failed": 1, "cancelled": 2, "amount": 890.00 }
},
"totals": { "events": 245, "unique_users": 18 }
}
}
---
2. Stats journalières (graphiques)
GET /api/events/stats/daily?from=2025-12-01&to=2025-12-22&events=passage_created,login_success
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------- |
| from | string | Oui | Date début YYYY-MM-DD |
| to | string | Oui | Date fin YYYY-MM-DD |
| events | string | Non | Types filtrés (comma-separated) |
Limite : 90 jours max
Réponse (~5 KB) :
{
"status": "success",
"data": {
"from": "2025-12-01",
"to": "2025-12-22",
"days": [
{
"date": "2025-12-01",
"events": {
"passage_created": { "count": 45, "sum_amount": 850.00, "unique_users": 8 },
"login_success": { "count": 12, "sum_amount": 0, "unique_users": 12 }
},
"totals": { "count": 57, "sum_amount": 850.00 }
},
...
]
}
}
---
3. Stats hebdomadaires
GET /api/events/stats/weekly?from=2025-10-01&to=2025-12-22
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------- |
| from | string | Oui | Date début |
| to | string | Oui | Date fin |
| events | string | Non | Types filtrés |
Limite : 365 jours max
Réponse (~2 KB) :
{
"status": "success",
"data": {
"from": "2025-10-01",
"to": "2025-12-22",
"weeks": [
{
"week_start": "2025-12-16",
"week_number": 51,
"year": 2025,
"events": {
"passage_created": { "count": 320, "sum_amount": 5200.00, "unique_users": 15 }
},
"totals": { "count": 450, "sum_amount": 5200.00 }
},
...
]
}
}
---
4. Stats mensuelles
GET /api/events/stats/monthly?year=2025&events=passage_created,stripe_payment_success
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------ |
| year | int | Non | Année (défaut: année courante) |
| events | string | Non | Types filtrés |
Réponse (~1 KB) :
{
"status": "success",
"data": {
"year": 2025,
"months": [
{
"month": "2025-01",
"year": 2025,
"month_number": 1,
"events": {
"passage_created": { "count": 1250, "sum_amount": 18500.00, "unique_users": 25 }
},
"totals": { "count": 1800, "sum_amount": 18500.00 }
},
...
]
}
}
---
5. Détail des événements (drill-down)
GET /api/events/stats/details?date=2025-12-22&event=login_failed&limit=50&offset=0
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------------ |
| date | string | Oui | Date YYYY-MM-DD |
| event | string | Non | Filtrer par type |
| limit | int | Non | Max résultats (défaut: 50, max: 100) |
| offset | int | Non | Pagination (défaut: 0) |
Réponse (~10 KB) :
{
"status": "success",
"data": {
"date": "2025-12-22",
"events": [
{
"timestamp": "2025-12-22T08:15:32Z",
"event": "login_failed",
"username": "jean.dupont",
"reason": "invalid_password",
"attempt": 2,
"ip": "192.168.x.x",
"platform": "ios",
"app_version": "3.5.2"
},
...
],
"pagination": {
"total": 3,
"limit": 50,
"offset": 0,
"has_more": false
}
}
}
---
6. Types d'événements disponibles
GET /api/events/stats/types
Réponse :
{
"status": "success",
"data": {
"auth": ["login_success", "login_failed", "logout"],
"passages": ["passage_created", "passage_updated", "passage_deleted"],
"sectors": ["sector_created", "sector_updated", "sector_deleted"],
"users": ["user_created", "user_updated", "user_deleted"],
"entities": ["entity_created", "entity_updated", "entity_deleted"],
"operations": ["operation_created", "operation_updated", "operation_deleted"],
"stripe": ["stripe_payment_created", "stripe_payment_success", "stripe_payment_failed", "stripe_payment_cancelled", "stripe_terminal_error"]
}
}
---
Codes d'erreur
| Code | Signification |
| ---- | -------------------------------------------- |
| 200 | Succès |
| 400 | Paramètre invalide (date, plage trop grande) |
| 403 | Accès refusé (rôle insuffisant) |
| 500 | Erreur serveur |
---
Super-admin uniquement
Le super-admin peut ajouter ?entity_id=X pour filtrer par entité :
GET /api/events/stats/summary?date=2025-12-22&entity_id=5
Sans ce paramètre, il voit les stats globales de toutes les entités.