Files
geo/api/docs/PLANNING-STRIPE-API.md

18 KiB

PLANNING STRIPE - DÉVELOPPEUR BACKEND PHP

API PHP 8.3 - Intégration Stripe Connect + Terminal

Période : 25/08/2024 - 05/09/2024


📅 LUNDI 25/08 - Setup et architecture (8h)

🌅 Matin (4h)

# Installation Stripe PHP SDK
cd api
composer require stripe/stripe-php

Configuration environnement

  • Créer config/stripe.php avec clés TEST
  • Ajouter variables .env :
    STRIPE_PUBLIC_KEY=pk_test_...
    STRIPE_SECRET_KEY=sk_test_...
    STRIPE_WEBHOOK_SECRET=whsec_...
    STRIPE_API_VERSION=2024-06-20
    
  • Créer service StripeService.php singleton
  • Configurer middleware authentification API

Base de données

-- Tables à créer
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)
);

CREATE TABLE payment_intents (
    id INT PRIMARY KEY AUTO_INCREMENT,
    stripe_payment_intent_id VARCHAR(255) UNIQUE,
    amicale_id INT NOT NULL,
    pompier_id INT NOT NULL,
    amount INT NOT NULL, -- en centimes
    currency VARCHAR(3) DEFAULT 'eur',
    status VARCHAR(50),
    application_fee INT,
    metadata JSON,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (amicale_id) REFERENCES amicales(id),
    FOREIGN KEY (pompier_id) REFERENCES users(id)
);

CREATE TABLE terminal_readers (
    id INT PRIMARY KEY AUTO_INCREMENT,
    stripe_reader_id VARCHAR(255) UNIQUE,
    amicale_id INT NOT NULL,
    label VARCHAR(255),
    location VARCHAR(255),
    status VARCHAR(50),
    device_type VARCHAR(50),
    last_seen_at TIMESTAMP,
    FOREIGN KEY (amicale_id) REFERENCES amicales(id)
);

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

// POST /api/amicales/{id}/stripe-account
public function createStripeAccount($amicaleId) {
    $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

// 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);
}

Terminal Connection Token

// POST /api/terminal/connection-token
public function createConnectionToken(Request $request) {
    $pompier = Auth::user();
    $amicale = $pompier->amicale;
    
    $connectionToken = \Stripe\Terminal\ConnectionToken::create([
        'location' => $amicale->stripe_location_id,
    ], [
        'stripe_account' => $amicale->stripe_account_id
    ]);
    
    return ['secret' => $connectionToken->secret];
}

🌆 Après-midi (4h)

Gestion des Locations

// POST /api/amicales/{id}/create-location
public function createLocation($amicaleId) {
    $amicale = Amicale::find($amicaleId);
    
    $location = \Stripe\Terminal\Location::create([
        'display_name' => $amicale->name,
        'address' => [
            'line1' => $amicale->address,
            'city' => $amicale->city,
            'postal_code' => $amicale->postal_code,
            'country' => 'FR',
        ],
    ], [
        'stripe_account' => $amicale->stripe_account_id
    ]);
    
    $amicale->update(['stripe_location_id' => $location->id]);
    return $location;
}

📅 MERCREDI 27/08 - Paiements et fees (8h)

🌅 Matin (4h)

Création PaymentIntent avec commission

// POST /api/payments/create-intent
public function createPaymentIntent(Request $request) {
    $validated = $request->validate([
        'amount' => 'required|integer|min:100', // en centimes
        'amicale_id' => 'required|exists:amicales,id',
    ]);
    
    $pompier = Auth::user();
    $amicale = Amicale::find($validated['amicale_id']);
    
    // Calculer la commission (2.5% ou 50 centimes minimum)
    $applicationFee = max(
        50, // 0.50€ minimum
        round($validated['amount'] * 0.025) // 2.5%
    );
    
    $paymentIntent = \Stripe\PaymentIntent::create([
        'amount' => $validated['amount'],
        'currency' => 'eur',
        'payment_method_types' => ['card_present'],
        'capture_method' => 'automatic',
        'application_fee_amount' => $applicationFee,
        'transfer_data' => [
            'destination' => $amicale->stripe_account_id,
        ],
        'metadata' => [
            'pompier_id' => $pompier->id,
            'pompier_name' => $pompier->name,
            'amicale_id' => $amicale->id,
            'calendrier_annee' => date('Y'),
        ],
    ]);
    
    // Sauvegarder en DB
    PaymentIntent::create([
        'stripe_payment_intent_id' => $paymentIntent->id,
        'amicale_id' => $amicale->id,
        'pompier_id' => $pompier->id,
        'amount' => $validated['amount'],
        'application_fee' => $applicationFee,
        'status' => $paymentIntent->status,
    ]);
    
    return [
        'client_secret' => $paymentIntent->client_secret,
        'payment_intent_id' => $paymentIntent->id,
    ];
}

🌆 Après-midi (4h)

Capture et confirmation

// POST /api/payments/{id}/capture
public function capturePayment($paymentIntentId) {
    $localPayment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
    
    $paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId);
    
    if ($paymentIntent->status === 'requires_capture') {
        $paymentIntent->capture();
    }
    
    $localPayment->update(['status' => $paymentIntent->status]);
    
    // Si succès, envoyer email reçu
    if ($paymentIntent->status === 'succeeded') {
        $this->sendReceipt($localPayment);
    }
    
    return $paymentIntent;
}

// GET /api/payments/{id}/status
public function getPaymentStatus($paymentIntentId) {
    $payment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
    return [
        'status' => $payment->status,
        'amount' => $payment->amount,
        'created_at' => $payment->created_at,
    ];
}

📅 JEUDI 28/08 - Reporting et Android compatibility (8h)

🌅 Matin (4h)

Gestion appareils Android certifiés

// 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

// 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

// 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

// 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

// 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

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é

// 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

// 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

/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

{
    "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

Document créé le 24/08/2024 - À mettre à jour quotidiennement