# PLANNING STRIPE - DÉVELOPPEUR BACKEND PHP ## API PHP 8.3 - Intégration Stripe Tap to Pay (Mobile uniquement) ### Période : 25/08/2024 - 05/09/2024 ### Mise à jour : Janvier 2025 - Simplification architecture --- ## 📅 LUNDI 25/08 - Setup et architecture (8h) ### 🌅 Matin (4h) ```bash # Installation Stripe PHP SDK cd api composer require stripe/stripe-php ``` #### ✅ Configuration environnement - [x] Créer configuration Stripe dans `AppConfig.php` avec clés TEST - [x] Ajouter variables de configuration : ```php 'stripe' => [ 'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXm...', 'secret_key_test' => 'sk_test_51QwoVN00pblGEgsXnvqi8qf...', 'webhook_secret_test' => 'whsec_test_...', 'api_version' => '2024-06-20', 'application_fee_percent' => 0, // DECISION: 0% commission 'mode' => 'test' ] ``` - [x] Créer service `StripeService.php` singleton - [x] Configurer authentification Session-based API #### ✅ Base de données ```sql -- Modification de la table ope_pass existante (JANVIER 2025) ALTER TABLE `ope_pass` DROP COLUMN IF EXISTS `is_striped`, ADD COLUMN `stripe_payment_id` VARCHAR(50) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe (pi_xxx)', ADD INDEX `idx_stripe_payment` (`stripe_payment_id`); -- Tables à créer (simplifiées) CREATE TABLE stripe_accounts ( id INT PRIMARY KEY AUTO_INCREMENT, amicale_id INT NOT NULL, stripe_account_id VARCHAR(255) UNIQUE, charges_enabled BOOLEAN DEFAULT FALSE, payouts_enabled BOOLEAN DEFAULT FALSE, onboarding_completed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (amicale_id) REFERENCES amicales(id) ); -- NOTE: Table payment_intents SUPPRIMÉE - on utilise directement stripe_payment_id dans ope_pass -- NOTE: Table terminal_readers SUPPRIMÉE - Tap to Pay uniquement, pas de terminaux externes CREATE TABLE android_certified_devices ( id INT PRIMARY KEY AUTO_INCREMENT, manufacturer VARCHAR(100), model VARCHAR(200), model_identifier VARCHAR(200), tap_to_pay_certified BOOLEAN DEFAULT FALSE, certification_date DATE, min_android_version INT, country VARCHAR(2) DEFAULT 'FR', last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_manufacturer_model (manufacturer, model) ); ``` ### 🌆 Après-midi (4h) #### ✅ Endpoints Connect - Onboarding (RÉALISÉS) ```php // POST /api/stripe/accounts - IMPLEMENTED public function createAccount() { $amicale = Amicale::find($amicaleId); $account = \Stripe\Account::create([ 'type' => 'express', 'country' => 'FR', 'email' => $amicale->email, 'capabilities' => [ 'card_payments' => ['requested' => true], 'transfers' => ['requested' => true], ], 'business_type' => 'non_profit', 'business_profile' => [ 'name' => $amicale->name, 'product_description' => 'Vente de calendriers des pompiers', ], ]); // Sauvegarder stripe_account_id return $account; } // GET /api/amicales/{id}/onboarding-link public function getOnboardingLink($amicaleId) { $accountLink = \Stripe\AccountLink::create([ 'account' => $amicale->stripe_account_id, 'refresh_url' => config('app.url') . '/stripe/refresh', 'return_url' => config('app.url') . '/stripe/success', 'type' => 'account_onboarding', ]); return ['url' => $accountLink->url]; } ``` --- ## 📅 MARDI 26/08 - Webhooks et Terminal (8h) ### 🌅 Matin (4h) #### ✅ Webhooks handler ```php // POST /api/webhooks/stripe public function handleWebhook(Request $request) { $payload = $request->getContent(); $sig_header = $request->header('Stripe-Signature'); try { $event = \Stripe\Webhook::constructEvent( $payload, $sig_header, config('stripe.webhook_secret') ); } catch(\Exception $e) { return response('Invalid signature', 400); } switch ($event->type) { case 'account.updated': $this->handleAccountUpdated($event->data->object); break; case 'account.application.authorized': $this->handleAccountAuthorized($event->data->object); break; case 'payment_intent.succeeded': $this->handlePaymentSucceeded($event->data->object); break; } return response('Webhook handled', 200); } ``` #### ✅ Configuration Tap to Pay ```php // POST /api/stripe/tap-to-pay/init public function initTapToPay(Request $request) { $userId = Session::getUserId(); $entityId = Session::getEntityId(); // Vérifier que l'entité a un compte Stripe $account = $this->getStripeAccount($entityId); return [ 'stripe_account_id' => $account->stripe_account_id, 'tap_to_pay_enabled' => true ]; } ``` ### 🌆 Après-midi (4h) #### ✅ Vérification compatibilité Device ```php // POST /api/stripe/devices/check-tap-to-pay public function checkTapToPayCapability(Request $request) { $platform = $request->input('platform'); $model = $request->input('device_model'); $osVersion = $request->input('os_version'); if ($platform === 'iOS') { // iPhone XS et ultérieurs avec iOS 16.4+ $supported = $this->checkiOSCompatibility($model, $osVersion); } else { // Android certifié pour la France $supported = $this->checkAndroidCertification($model); } return [ 'tap_to_pay_supported' => $supported, 'message' => $supported ? 'Tap to Pay disponible' : 'Appareil non compatible' ]; } ``` --- ## 📅 MERCREDI 27/08 - Paiements et fees (8h) ### 🌅 Matin (4h) #### ✅ Création PaymentIntent avec association au passage ```php // POST /api/payments/create-intent public function createPaymentIntent(Request $request) { $validated = $request->validate([ 'amount' => 'required|integer|min:100', // en centimes 'passage_id' => 'required|integer', // ID du passage ope_pass 'entity_id' => 'required|integer', ]); $userId = Session::getUserId(); $entity = $this->getEntity($validated['entity_id']); // Commission à 0% (décision client) $applicationFee = 0; $paymentIntent = \Stripe\PaymentIntent::create([ 'amount' => $validated['amount'], 'currency' => 'eur', 'payment_method_types' => ['card_present'], 'capture_method' => 'automatic', 'application_fee_amount' => $applicationFee, 'transfer_data' => [ 'destination' => $entity->stripe_account_id, ], 'metadata' => [ 'passage_id' => $validated['passage_id'], 'user_id' => $userId, 'entity_id' => $entity->id, 'year' => date('Y'), ], ]); // Mise à jour directe dans ope_pass $this->db->prepare(" UPDATE ope_pass SET stripe_payment_id = :stripe_id, date_modified = NOW() WHERE id = :passage_id ")->execute([ ':stripe_id' => $paymentIntent->id, ':passage_id' => $validated['passage_id'] ]); return [ 'client_secret' => $paymentIntent->client_secret, 'payment_intent_id' => $paymentIntent->id, ]; } ``` ### 🌆 Après-midi (4h) #### ✅ Capture et confirmation ```php // POST /api/payments/{id}/capture public function capturePayment($paymentIntentId) { // Récupérer le passage depuis ope_pass $stmt = $this->db->prepare(" SELECT id, stripe_payment_id, montant FROM ope_pass WHERE stripe_payment_id = :stripe_id "); $stmt->execute([':stripe_id' => $paymentIntentId]); $passage = $stmt->fetch(); $paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId); if ($paymentIntent->status === 'requires_capture') { $paymentIntent->capture(); } // Mettre à jour le statut dans ope_pass si nécessaire if ($paymentIntent->status === 'succeeded' && $passage) { $this->db->prepare(" UPDATE ope_pass SET date_stripe_validated = NOW() WHERE id = :passage_id ")->execute([':passage_id' => $passage['id']]); // Envoyer email reçu si configuré $this->sendReceipt($passage['id']); } return $paymentIntent; } // GET /api/passages/{id}/stripe-status public function getPassageStripeStatus($passageId) { $stmt = $this->db->prepare(" SELECT stripe_payment_id, montant, date_creat FROM ope_pass WHERE id = :id "); $stmt->execute([':id' => $passageId]); $passage = $stmt->fetch(); if (!$passage['stripe_payment_id']) { return ['status' => 'no_stripe_payment']; } // Récupérer le statut depuis Stripe $paymentIntent = \Stripe\PaymentIntent::retrieve($passage['stripe_payment_id']); return [ 'stripe_payment_id' => $passage['stripe_payment_id'], 'status' => $paymentIntent->status, 'amount' => $paymentIntent->amount, 'currency' => $paymentIntent->currency, 'created_at' => $passage['date_creat'] ]; } ``` --- ## 📅 JEUDI 28/08 - Reporting et Android compatibility (8h) ### 🌅 Matin (4h) #### ✅ Gestion appareils Android certifiés ```php // POST /api/devices/check-tap-to-pay public function checkTapToPayCapability(Request $request) { $validated = $request->validate([ 'platform' => 'required|in:ios,android', 'manufacturer' => 'required_if:platform,android', 'model' => 'required_if:platform,android', 'os_version' => 'required', ]); if ($validated['platform'] === 'ios') { // iPhone XS et ultérieurs avec iOS 15.4+ $supportedModels = ['iPhone11,', 'iPhone12,', 'iPhone13,', 'iPhone14,', 'iPhone15,', 'iPhone16,']; $modelSupported = false; foreach ($supportedModels as $prefix) { if (str_starts_with($validated['model'], $prefix)) { $modelSupported = true; break; } } $osVersion = explode('.', $validated['os_version']); $osSupported = $osVersion[0] > 15 || ($osVersion[0] == 15 && isset($osVersion[1]) && $osVersion[1] >= 4); return [ 'tap_to_pay_supported' => $modelSupported && $osSupported, 'message' => $modelSupported && $osSupported ? 'Tap to Pay disponible' : 'iPhone XS ou ultérieur avec iOS 15.4+ requis' ]; } // Android - vérifier dans la base de données $device = DB::table('android_certified_devices') ->where('manufacturer', $validated['manufacturer']) ->where('model', $validated['model']) ->where('tap_to_pay_certified', true) ->first(); return [ 'tap_to_pay_supported' => $device !== null, 'message' => $device ? 'Tap to Pay disponible sur cet appareil' : 'Appareil non certifié pour Tap to Pay en France', 'alternative' => !$device ? 'Utilisez un iPhone compatible' : null ]; } // GET /api/devices/certified-android public function getCertifiedAndroidDevices() { return DB::table('android_certified_devices') ->where('tap_to_pay_certified', true) ->where('country', 'FR') ->orderBy('manufacturer') ->orderBy('model') ->get(); } ``` #### ✅ Seeder pour appareils certifiés ```php // database/seeders/AndroidCertifiedDevicesSeeder.php public function run() { $devices = [ // Samsung ['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11], ['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11], ['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11], ['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12], ['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13], ['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14], // Google Pixel ['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12], ['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12], ['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13], ['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14], ]; foreach ($devices as $device) { DB::table('android_certified_devices')->insert([ 'manufacturer' => $device['manufacturer'], 'model' => $device['model'], 'model_identifier' => $device['model_identifier'], 'tap_to_pay_certified' => true, 'certification_date' => now(), 'min_android_version' => $device['min_android_version'], 'country' => 'FR', ]); } } ``` #### ✅ Endpoints statistiques ```php // GET /api/amicales/{id}/stats public function getAmicaleStats($amicaleId) { $stats = DB::table('payment_intents') ->where('amicale_id', $amicaleId) ->where('status', 'succeeded') ->selectRaw(' COUNT(*) as total_ventes, SUM(amount) as total_montant, SUM(application_fee) as total_commissions, DATE(created_at) as date ') ->groupBy('date') ->get(); return $stats; } // GET /api/pompiers/{id}/ventes public function getPompierVentes($pompierId) { return PaymentIntent::where('pompier_id', $pompierId) ->where('status', 'succeeded') ->orderBy('created_at', 'desc') ->paginate(20); } ``` ### 🌆 Après-midi (4h) #### ✅ Gestion des remboursements ```php // POST /api/payments/{id}/refund public function refundPayment($paymentIntentId, Request $request) { $validated = $request->validate([ 'amount' => 'integer|min:100', // optionnel, remboursement partiel 'reason' => 'string|in:duplicate,fraudulent,requested_by_customer', ]); $payment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first(); $refund = \Stripe\Refund::create([ 'payment_intent' => $paymentIntentId, 'amount' => $validated['amount'] ?? null, // null = remboursement total 'reason' => $validated['reason'] ?? 'requested_by_customer', 'reverse_transfer' => true, // Important pour Connect 'refund_application_fee' => true, // Rembourser aussi la commission ]); $payment->update(['status' => 'refunded']); return $refund; } ``` --- ## 📅 VENDREDI 29/08 - Mode offline et sync (8h) ### 🌅 Matin (4h) #### ✅ Queue de synchronisation ```php // POST /api/payments/batch-sync public function batchSync(Request $request) { $validated = $request->validate([ 'transactions' => 'required|array', 'transactions.*.local_id' => 'required|string', 'transactions.*.amount' => 'required|integer', 'transactions.*.created_at' => 'required|date', 'transactions.*.payment_method' => 'required|in:card,cash', ]); $results = []; foreach ($validated['transactions'] as $transaction) { if ($transaction['payment_method'] === 'cash') { // Enregistrer paiement cash uniquement en DB $results[] = $this->recordCashPayment($transaction); } else { // Créer PaymentIntent a posteriori (si possible) $results[] = $this->createOfflinePayment($transaction); } } return ['synced' => $results]; } ``` ### 🌆 Après-midi (4h) #### ✅ Tests unitaires critiques ```php class StripePaymentTest extends TestCase { public function test_create_payment_intent_with_fees() { // Test création PaymentIntent avec commission } public function test_webhook_signature_validation() { // Test sécurité webhook } public function test_refund_reverses_transfer() { // Test remboursement avec annulation virement } } ``` --- ## 📅 LUNDI 01/09 - Sécurité et optimisations (8h) ### 🌅 Matin (4h) #### ✅ Rate limiting et sécurité ```php // Middleware RateLimiter pour endpoints sensibles Route::middleware(['throttle:10,1'])->group(function () { Route::post('/payments/create-intent', 'PaymentController@createIntent'); }); // Validation des montants public function validateAmount($amount) { if ($amount < 100 || $amount > 50000) { // 1€ - 500€ throw new ValidationException('Montant invalide'); } } ``` ### 🌆 Après-midi (4h) #### ✅ Logs et monitoring ```php // Logger tous les événements Stripe Log::channel('stripe')->info('Payment created', [ 'payment_intent_id' => $paymentIntent->id, 'amount' => $paymentIntent->amount, 'pompier_id' => $pompier->id, ]); ``` --- ## 📅 MARDI 02/09 - Documentation API (4h) #### ✅ Documentation OpenAPI/Swagger ```yaml /api/payments/create-intent: post: summary: Créer une intention de paiement parameters: - name: amount type: integer required: true description: Montant en centimes responses: 200: description: PaymentIntent créé ``` --- ## 📅 MERCREDI 03/09 - Tests d'intégration (8h) #### ✅ Tests end-to-end - [ ] Parcours complet onboarding amicale - [ ] Création paiement → capture → confirmation - [ ] Test remboursement complet et partiel - [ ] Test webhooks avec ngrok --- ## 📅 JEUDI 04/09 - Mise en production (8h) --- ## 📅 VENDREDI 05/09 - Support et livraison finale (8h) ### 🌅 Matin (4h) #### ✅ Déploiement final - [ ] Migration DB production - [ ] Variables environnement LIVE - [ ] Smoke tests production - [ ] Vérification des webhooks en production ### 🌆 Après-midi (4h) #### ✅ Support et monitoring - [ ] Monitoring des premiers paiements réels - [ ] Support hotline pour équipes terrain - [ ] Documentation de passation - [ ] Réunion de clôture et retour d'expérience --- ## 📊 RÉCAPITULATIF - **Total heures** : 72h sur 10 jours - **Endpoints créés** : 15 - **Tables DB** : 3 - **Tests** : 20+ ## 🔧 DÉPENDANCES ```json { "require": { "php": "^8.3", "stripe/stripe-php": "^13.0", "laravel/framework": "^10.0" } } ``` ## ⚠️ CHECKLIST SÉCURITÉ - [ ] ❌ JAMAIS logger les clés secrètes - [ ] ✅ TOUJOURS valider signature webhooks - [ ] ✅ TOUJOURS utiliser HTTPS - [ ] ✅ Rate limiting sur endpoints paiement - [ ] ✅ Logs détaillés pour audit --- ## 🎯 BILAN DÉVELOPPEMENT API (01/09/2024) ### ✅ ENDPOINTS IMPLÉMENTÉS (TAP TO PAY UNIQUEMENT) #### **Stripe Connect - Comptes** - **POST /api/stripe/accounts** ✅ - Création compte Stripe Express pour amicales - Gestion déchiffrement données (encrypted_email, encrypted_name) - Support des comptes existants - **GET /api/stripe/accounts/:entityId/status** ✅ - Récupération statut complet du compte - Vérification charges_enabled et payouts_enabled - Retour JSON avec informations détaillées - **POST /api/stripe/accounts/:accountId/onboarding-link** ✅ - Génération liens d'onboarding Stripe - URLs de retour configurées - Gestion des erreurs et timeouts #### **Configuration et Utilitaires** - **GET /api/stripe/config** ✅ - Configuration publique Stripe - Clés publiques et paramètres client - Adaptation par environnement - **POST /api/stripe/webhook** ✅ - Réception événements Stripe - Vérification signatures webhook - Traitement des événements Connect ### 🔧 CORRECTIONS TECHNIQUES RÉALISÉES #### **StripeController.php** - Fixed `Database::getInstance()` → `$this->db` - Fixed `$db->prepare()` → `$this->db->prepare()` - Removed `details_submitted` column from SQL UPDATE - Added proper exit statements after JSON responses - Commented out Logger class calls (class not found) #### **StripeService.php** - Added proper Stripe SDK imports (`use Stripe\Account`) - Fixed `Account::retrieve()` → `$this->stripe->accounts->retrieve()` - **CRUCIAL**: Added data decryption support: ```php $nom = !empty($entite['encrypted_name']) ? \ApiService::decryptData($entite['encrypted_name']) : ''; $email = !empty($entite['encrypted_email']) ? \ApiService::decryptSearchableData($entite['encrypted_email']) : null; ``` - Fixed address mapping (adresse1, adresse2 vs adresse) - **REMOVED commission calculation - set to 0%** #### **Router.php** - Commented out excessive debug logging causing nginx 502 errors: ```php // error_log("Recherche de route pour: méthode=$method, uri=$uri"); // error_log("Test pattern: $pattern contre uri: $uri"); ``` #### **AppConfig.php** - Set `application_fee_percent` to 0 (was 2.5) - Set `application_fee_minimum` to 0 (was 50) - **Policy**: 100% of payments go to amicales ### 📊 TESTS ET VALIDATION #### **Tests Réussis** 1. **POST /api/stripe/accounts** → 200 OK (Compte créé: acct_1S2YfNP63A07c33Y) 2. **GET /api/stripe/accounts/5/status** → 200 OK (charges_enabled: true) 3. **POST /api/stripe/locations** → 200 OK (Location: tml_GLJ21w7KCYX4Wj) 4. **POST /api/stripe/accounts/.../onboarding-link** → 200 OK (Link generated) 5. **Onboarding Stripe** → Completed successfully by user #### **Erreurs Résolues** - ❌ 500 "Class App\Controllers\Database not found" → ✅ Fixed - ❌ 400 "Invalid email address: " → ✅ Fixed (decryption added) - ❌ 502 "upstream sent too big header" → ✅ Fixed (logs removed) - ❌ SQL "Column not found: details_submitted" → ✅ Fixed ### 🚀 ARCHITECTURE TECHNIQUE #### **Services Implémentés** - **StripeService**: Singleton pour interactions Stripe API - **StripeController**: Endpoints REST avec gestion sessions - **StripeWebhookController**: Handler événements webhook - **ApiService**: Déchiffrement données encrypted fields #### **Sécurité** - Validation signatures webhook Stripe - Authentification session-based pour APIs privées - Public endpoints: webhook uniquement - Pas de stockage clés secrètes en base #### **Base de données (MISE À JOUR JANVIER 2025)** - **Modification table `ope_pass`** : `stripe_payment_id` VARCHAR(50) remplace `is_striped` - **Table `payment_intents` supprimée** : Intégration directe dans `ope_pass` - Utilisation tables existantes (entites) - Champs encrypted_email et encrypted_name supportés - Déchiffrement automatique avant envoi Stripe ### 🎯 PROCHAINES ÉTAPES API 1. **Tests paiements réels** avec PaymentIntents 2. **Endpoints statistiques** pour dashboard amicales 3. **Webhooks production** avec clés live 4. **Monitoring et logs** des transactions 5. **Rate limiting** sur endpoints sensibles --- ## 📱 FLOW TAP TO PAY SIMPLIFIÉ (Janvier 2025) ### Architecture ``` Flutter App (Tap to Pay) ↔ API PHP ↔ Stripe API ``` ### Étape 1: Création PaymentIntent **Flutter → API** ```json POST /api/stripe/payments/create-intent { "amount": 1500, "passage_id": 123, "entity_id": 5 } ``` **API → Stripe → Base de données** ```php // 1. Créer le PaymentIntent $paymentIntent = Stripe\PaymentIntent::create([...]); // 2. Sauvegarder dans ope_pass UPDATE ope_pass SET stripe_payment_id = 'pi_xxx' WHERE id = 123; ``` **API → Flutter** ```json { "client_secret": "pi_xxx_secret_yyy", "payment_intent_id": "pi_xxx" } ``` ### Étape 2: Collecte du paiement (Flutter) - L'app Flutter utilise le SDK Stripe Terminal - Le téléphone devient le terminal de paiement (Tap to Pay) - Utilise le client_secret pour collecter le paiement ### Étape 3: Confirmation (Webhook) **Stripe → API** - Event: `payment_intent.succeeded` - Met à jour le statut dans la base de données ### Tables nécessaires - ✅ `ope_pass.stripe_payment_id` - Association passage/paiement - ✅ `stripe_accounts` - Comptes Connect des amicales - ✅ `android_certified_devices` - Vérification compatibilité - ❌ ~~`stripe_payment_intents`~~ - Supprimée - ❌ ~~`terminal_readers`~~ - Pas de terminaux externes ### Endpoints essentiels 1. `POST /api/stripe/payments/create-intent` - Créer PaymentIntent 2. `POST /api/stripe/devices/check-tap-to-pay` - Vérifier compatibilité 3. `POST /api/stripe/webhook` - Recevoir confirmations 4. `GET /api/passages/{id}/stripe-status` - Vérifier statut --- ## 📝 CHANGELOG ### Janvier 2025 - Refactoring base de données - **Suppression** de la table `payment_intents` (non nécessaire) - **Migration** : `is_striped` → `stripe_payment_id` VARCHAR(50) dans `ope_pass` - **Simplification** : Association directe PaymentIntent ↔ Passage - **Avantage** : Traçabilité directe sans table intermédiaire --- *Document créé le 24/08/2024 - Dernière mise à jour : 09/01/2025*