feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API

- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

@@ -137,111 +137,143 @@ class StripeController extends Controller {
}
}
/**
* POST /api/stripe/locations
* Créer une Location pour Terminal/Tap to Pay
*/
public function createLocation(): void {
try {
$this->requireAuth();
// Vérifier le rôle de l'utilisateur
$userId = Session::getUserId();
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($userRole < 2) {
$this->sendError('Droits insuffisants', 403);
return;
}
$data = $this->getJsonInput();
$entiteId = $data['fk_entite'] ?? Session::getEntityId();
$result = $this->stripeService->createLocation($entiteId);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/terminal/connection-token
* Créer un token de connexion pour Terminal/Tap to Pay
*/
public function createConnectionToken(): void {
try {
$this->requireAuth();
$entiteId = Session::getEntityId();
if (!$entiteId) {
$this->sendError('Entité non définie', 400);
return;
}
$result = $this->stripeService->createConnectionToken($entiteId);
if ($result['success']) {
$this->sendSuccess(['secret' => $result['secret']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payments/create-intent
* Créer une intention de paiement
* Créer une intention de paiement pour Tap to Pay ou paiement Web
*
* Payload Tap to Pay:
* {
* "amount": 2500,
* "currency": "eur",
* "description": "Calendrier pompiers - Passage #789",
* "payment_method_types": ["card_present"],
* "capture_method": "automatic",
* "passage_id": 789,
* "amicale_id": 42,
* "member_id": 156,
* "stripe_account": "acct_1O3ABC456DEF789",
* "location_id": "tml_FGH123456789",
* "metadata": {...}
* }
*/
public function createPaymentIntent(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
$amount = $data['amount'] ?? 0;
// Validation des champs requis
if (!isset($data['amount']) || !isset($data['passage_id'])) {
$this->sendError('Montant et passage_id requis', 400);
return;
}
$amount = (int)$data['amount'];
$passageId = (int)$data['passage_id'];
// Validation du passage_id (doit être > 0 car le passage est créé avant)
if ($passageId <= 0) {
$this->sendError('passage_id invalide. Le passage doit être créé avant le paiement', 400);
return;
}
// Validation du montant
if ($amount < 100) {
$this->sendError('Le montant minimum est de 1€ (100 centimes)', 400);
return;
}
if ($amount > 50000) {
$this->sendError('Le montant maximum est de 500€', 400);
if ($amount > 99900) { // 999€ max selon la doc
$this->sendError('Le montant maximum est de 999€', 400);
return;
}
// Vérifier que le passage existe et appartient à l'utilisateur
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND p.fk_user = ?
');
$stmt->execute([$passageId, Session::getUserId()]);
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Passage non trouvé ou non autorisé', 404);
return;
}
// Vérifier qu'il n'y a pas déjà un paiement Stripe pour ce passage
if (!empty($passage['stripe_payment_id'])) {
$this->sendError('Un paiement Stripe existe déjà pour ce passage', 400);
return;
}
// Vérifier que le montant correspond (passage.montant est en euros, amount en centimes)
$expectedAmount = (int)($passage['montant'] * 100);
if ($amount !== $expectedAmount) {
$this->sendError("Le montant ne correspond pas au passage (attendu: {$expectedAmount} centimes, reçu: {$amount} centimes)", 400);
return;
}
$entiteId = $passage['fk_entite'];
// Déterminer le type de paiement (Tap to Pay ou Web)
$paymentMethodTypes = $data['payment_method_types'] ?? ['card_present'];
$isTapToPay = in_array('card_present', $paymentMethodTypes);
// Préparer les paramètres pour StripeService
$params = [
'amount' => $amount,
'fk_entite' => $data['fk_entite'] ?? Session::getEntityId(),
'fk_user' => Session::getUserId(),
'metadata' => $data['metadata'] ?? []
'currency' => $data['currency'] ?? 'eur',
'description' => $data['description'] ?? "Calendrier pompiers - Passage #$passageId",
'payment_method_types' => $paymentMethodTypes,
'capture_method' => $data['capture_method'] ?? 'automatic',
'passage_id' => $passageId,
'amicale_id' => $data['amicale_id'] ?? $entiteId,
'member_id' => $data['member_id'] ?? Session::getUserId(),
'stripe_account' => $data['stripe_account'] ?? null,
'metadata' => array_merge(
[
'passage_id' => (string)$passageId,
'amicale_id' => (string)($data['amicale_id'] ?? $entiteId),
'member_id' => (string)($data['member_id'] ?? Session::getUserId()),
'type' => $isTapToPay ? 'tap_to_pay' : 'web'
],
$data['metadata'] ?? []
)
];
// Ajouter location_id si fourni (pour Tap to Pay)
if (isset($data['location_id'])) {
$params['location_id'] = $data['location_id'];
}
// Créer le PaymentIntent via StripeService
$result = $this->stripeService->createPaymentIntent($params);
if ($result['success']) {
// Mettre à jour le passage avec le stripe_payment_id
$stmt = $this->db->prepare('
UPDATE ope_pass
SET stripe_payment_id = ?, updated_at = NOW()
WHERE id = ?
');
$stmt->execute([$result['payment_intent_id'], $passageId]);
// Retourner la réponse
$this->sendSuccess([
'client_secret' => $result['client_secret'],
'payment_intent_id' => $result['payment_intent_id'],
'amount' => $result['amount'],
'application_fee' => $result['application_fee']
'currency' => $params['currency'],
'passage_id' => $passageId,
'type' => $isTapToPay ? 'tap_to_pay' : 'web'
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
@@ -249,60 +281,78 @@ class StripeController extends Controller {
/**
* GET /api/stripe/payments/{paymentIntentId}
* Récupérer le statut d'un paiement
* Récupérer le statut d'un paiement depuis ope_pass et Stripe
*/
public function getPaymentStatus(string $paymentIntentId): void {
try {
$this->requireAuth();
$stmt = $this->db->prepare(
"SELECT spi.*, e.nom as entite_nom, u.nom as user_nom, u.prenom as user_prenom
FROM stripe_payment_intents spi
LEFT JOIN entites e ON spi.fk_entite = e.id
LEFT JOIN users u ON spi.fk_user = u.id
WHERE spi.stripe_payment_intent_id = :pi_id"
);
// Récupérer les informations depuis ope_pass
$stmt = $this->db->prepare("
SELECT p.*, o.fk_entite,
e.encrypted_name as entite_nom,
u.first_name as user_prenom, u.sect_name as user_nom
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
LEFT JOIN entites e ON o.fk_entite = e.id
LEFT JOIN users u ON p.fk_user = u.id
WHERE p.stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntentId]);
$payment = $stmt->fetch();
if (!$payment) {
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Paiement non trouvé', 404);
return;
}
// Vérifier les droits
$userEntityId = Session::getEntityId();
$userId = Session::getUserId();
// Récupérer le rôle depuis la base de données
$stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch();
$userRole = $result ? (int)$result['fk_role'] : 0;
if ($payment['fk_entite'] != $userEntityId &&
$payment['fk_user'] != $userId &&
if ($passage['fk_entite'] != $userEntityId &&
$passage['fk_user'] != $userId &&
$userRole < 3) {
$this->sendError('Non autorisé', 403);
return;
}
// Récupérer le statut en temps réel depuis Stripe
$stripeStatus = $this->stripeService->getPaymentIntentStatus($paymentIntentId);
// Déchiffrer le nom de l'entité si nécessaire
$entiteNom = '';
if (!empty($passage['entite_nom'])) {
try {
$entiteNom = \ApiService::decryptData($passage['entite_nom']);
} catch (Exception $e) {
$entiteNom = 'Entité inconnue';
}
}
$this->sendSuccess([
'payment_intent_id' => $payment['stripe_payment_intent_id'],
'status' => $payment['status'],
'amount' => $payment['amount'],
'currency' => $payment['currency'],
'application_fee' => $payment['application_fee'],
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passage['id'],
'status' => $stripeStatus['status'] ?? 'unknown',
'amount' => (int)($passage['montant'] * 100), // montant en BDD est en euros, on convertit en centimes
'currency' => 'eur',
'entite' => [
'id' => $payment['fk_entite'],
'nom' => $payment['entite_nom']
'id' => $passage['fk_entite'],
'nom' => $entiteNom
],
'user' => [
'id' => $payment['fk_user'],
'nom' => $payment['user_nom'],
'prenom' => $payment['user_prenom']
'id' => $passage['fk_user'],
'nom' => $passage['user_nom'],
'prenom' => $passage['user_prenom']
],
'created_at' => $payment['created_at']
'created_at' => $passage['date_creat'],
'stripe_details' => $stripeStatus
]);
} catch (Exception $e) {
@@ -419,10 +469,11 @@ class StripeController extends Controller {
$platform = $data['platform'] ?? '';
if ($platform === 'ios') {
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 15.4+)
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
$this->sendSuccess([
'message' => 'Vérification iOS à faire côté client',
'requirements' => 'iPhone XS ou plus récent avec iOS 15.4+'
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
]);
return;
}