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

@@ -14,10 +14,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Build Commands
- Install dependencies: `composer install` - install PHP dependencies
- Update dependencies: `composer update` - update PHP dependencies to latest versions
- Deploy to REC: `./livre-api.sh rec` - deploy from DVA to RECETTE environment
- Deploy to PROD: `./livre-api.sh prod` - deploy from RECETTE to PRODUCTION environment
- Deploy to DEV: `./deploy-api.sh` - deploy local code to dva-geo on IN3 (195.154.80.116)
- Deploy to REC: `./deploy-api.sh rca` - deploy from dva-geo to rca-geo on IN3
- Deploy to PROD: `./deploy-api.sh pra` - deploy from rca-geo (IN3) to pra-geo (IN4)
- Export operations: `php export_operation.php` - export operations data
## Development Environment
- **DEV Container**: dva-geo on IN3 server (195.154.80.116)
- **DEV API URL Public**: https://dapp.geosector.fr/api/
- **DEV API URL Internal**: http://13.23.33.43/api/
- **Access**: Via Incus container on IN3 server
## Code Architecture
This is a PHP 8.3 API without framework, using a custom MVC-like architecture:

View File

@@ -0,0 +1 @@
{"ip":"169.155.255.55","timestamp":1758618220,"retrieved_at":"2025-09-23 09:03:41"}

30
api/data/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Répertoire data
Ce répertoire contient les données de référence pour l'API.
## Fichiers
- `stripe_certified_devices.json` (optionnel) : Liste personnalisée des appareils certifiés Stripe Tap to Pay
## Format stripe_certified_devices.json
Si vous souhaitez ajouter des appareils supplémentaires à la liste intégrée, créez un fichier `stripe_certified_devices.json` avec le format suivant :
```json
[
{
"manufacturer": "Samsung",
"model": "Galaxy A55",
"model_identifier": "SM-A556B",
"min_android_version": 14
},
{
"manufacturer": "Fairphone",
"model": "Fairphone 5",
"model_identifier": "FP5",
"min_android_version": 13
}
]
```
Les appareils dans ce fichier seront ajoutés à la liste intégrée dans le script CRON.

View File

@@ -24,8 +24,8 @@ HOST_PORT="22"
HOST_USER="root"
# Configuration des serveurs
RCA_HOST="195.154.80.116" # Serveur de recette
PRA_HOST="51.159.7.190" # Serveur de production
RCA_HOST="195.154.80.116" # IN3 - Serveur de recette
PRA_HOST="51.159.7.190" # IN4 - Serveur de production
# Configuration Incus
INCUS_PROJECT="default"
@@ -33,9 +33,10 @@ API_PATH="/var/www/geosector/api"
FINAL_OWNER="nginx"
FINAL_GROUP="nginx"
FINAL_OWNER_LOGS="nobody"
FINAL_GROUP_LOGS="nginx"
# Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector"
BACKUP_DIR="/data/backup/geosector/api"
# Couleurs pour les messages
GREEN='\033[0;32m'
@@ -65,31 +66,20 @@ echo_error() {
exit 1
}
# Fonction pour créer une sauvegarde locale
create_local_backup() {
local archive_file=$1
local backup_type=$2
echo_info "Creating backup in ${BACKUP_DIR}..."
if [ ! -d "${BACKUP_DIR}" ]; then
mkdir -p "${BACKUP_DIR}" || echo_warning "Could not create backup directory ${BACKUP_DIR}"
fi
if [ -d "${BACKUP_DIR}" ]; then
BACKUP_FILE="${BACKUP_DIR}/api-${backup_type}-$(date +%Y%m%d-%H%M%S).tar.gz"
cp "${archive_file}" "${BACKUP_FILE}" && {
echo_info "Backup saved to: ${BACKUP_FILE}"
echo_info "Backup size: $(du -h "${BACKUP_FILE}" | cut -f1)"
# Nettoyer les anciens backups (garder les 10 derniers)
echo_info "Cleaning old backups (keeping last 10)..."
ls -t "${BACKUP_DIR}"/api-${backup_type}-*.tar.gz 2>/dev/null | tail -n +11 | xargs -r rm -f && {
REMAINING_BACKUPS=$(ls "${BACKUP_DIR}"/api-${backup_type}-*.tar.gz 2>/dev/null | wc -l)
echo_info "Kept ${REMAINING_BACKUPS} backup(s)"
}
} || echo_warning "Failed to create backup in ${BACKUP_DIR}"
fi
# Fonction pour nettoyer les anciens backups
cleanup_old_backups() {
local prefix=""
case $TARGET_ENV in
"dev") prefix="api-dev-" ;;
"rca") prefix="api-rca-" ;;
"pra") prefix="api-pra-" ;;
esac
echo_info "Cleaning old backups (keeping last 10)..."
ls -t "${BACKUP_DIR}"/${prefix}*.tar.gz 2>/dev/null | tail -n +11 | xargs -r rm -f && {
REMAINING_BACKUPS=$(ls "${BACKUP_DIR}"/${prefix}*.tar.gz 2>/dev/null | wc -l)
echo_info "Kept ${REMAINING_BACKUPS} backup(s) for ${TARGET_ENV}"
}
}
# =====================================
@@ -98,16 +88,17 @@ create_local_backup() {
case $TARGET_ENV in
"dev")
echo_step "Configuring for LOCAL DEV deployment"
echo_step "Configuring for DEV deployment on IN3"
SOURCE_TYPE="local_code"
DEST_CONTAINER="geo"
DEST_HOST="local"
DEST_CONTAINER="dva-geo"
DEST_HOST="${RCA_HOST}" # IN3 pour le DEV aussi
ENV_NAME="DEVELOPMENT"
;;
"rca")
echo_step "Configuring for RECETTE delivery"
SOURCE_TYPE="local_container"
SOURCE_CONTAINER="geo"
SOURCE_TYPE="remote_container"
SOURCE_CONTAINER="dva-geo"
SOURCE_HOST="${RCA_HOST}"
DEST_CONTAINER="rca-geo"
DEST_HOST="${RCA_HOST}"
ENV_NAME="RECETTE"
@@ -132,9 +123,29 @@ echo_info "Deployment flow: ${ENV_NAME}"
# Création de l'archive selon la source
# =====================================
TIMESTAMP=$(date +%s)
ARCHIVE_NAME="api-deploy-${TIMESTAMP}.tar.gz"
TEMP_ARCHIVE="/tmp/${ARCHIVE_NAME}"
# Créer le dossier de backup s'il n'existe pas
if [ ! -d "${BACKUP_DIR}" ]; then
echo_info "Creating backup directory ${BACKUP_DIR}..."
mkdir -p "${BACKUP_DIR}" || echo_error "Failed to create backup directory"
fi
# Horodatage format YYYYMMDDHH
TIMESTAMP=$(date +%Y%m%d%H)
# Nom de l'archive selon l'environnement
case $TARGET_ENV in
"dev")
ARCHIVE_NAME="api-dev-${TIMESTAMP}.tar.gz"
;;
"rca")
ARCHIVE_NAME="api-rca-${TIMESTAMP}.tar.gz"
;;
"pra")
ARCHIVE_NAME="api-pra-${TIMESTAMP}.tar.gz"
;;
esac
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
if [ "$SOURCE_TYPE" = "local_code" ]; then
# DEV: Créer une archive depuis le code local
@@ -165,34 +176,15 @@ if [ "$SOURCE_TYPE" = "local_code" ]; then
--exclude='*.swp' \
--exclude='*.swo' \
--exclude='*~' \
--warning=no-file-changed \
--no-xattrs \
-czf "${TEMP_ARCHIVE}" . || echo_error "Failed to create archive"
-czf "${ARCHIVE_PATH}" . 2>/dev/null || echo_error "Failed to create archive"
echo_info "Archive created: ${ARCHIVE_PATH}"
echo_info "Archive size: $(du -h "${ARCHIVE_PATH}" | cut -f1)"
create_local_backup "${TEMP_ARCHIVE}" "dev"
elif [ "$SOURCE_TYPE" = "local_container" ]; then
# RCA: Créer une archive depuis le container local
echo_step "Creating archive from local container ${SOURCE_CONTAINER}..."
echo_info "Switching to Incus project ${INCUS_PROJECT}..."
incus project switch ${INCUS_PROJECT} || echo_error "Failed to switch project"
# Créer l'archive directement depuis le container local
incus exec ${SOURCE_CONTAINER} -- tar \
--exclude='logs' \
--exclude='uploads' \
--warning=no-file-changed \
-czf /tmp/${ARCHIVE_NAME} -C ${API_PATH} . || echo_error "Failed to create archive from container"
# Récupérer l'archive depuis le container
incus file pull ${SOURCE_CONTAINER}/tmp/${ARCHIVE_NAME} ${TEMP_ARCHIVE} || echo_error "Failed to pull archive from container"
incus exec ${SOURCE_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
create_local_backup "${TEMP_ARCHIVE}" "to-rca"
# Cette section n'est plus utilisée car RCA utilise maintenant remote_container
elif [ "$SOURCE_TYPE" = "remote_container" ]; then
# PRA: Créer une archive depuis un container distant
# RCA et PRA: Créer une archive depuis un container distant
echo_step "Creating archive from remote container ${SOURCE_CONTAINER} on ${SOURCE_HOST}..."
# Créer l'archive sur le serveur source
@@ -201,7 +193,6 @@ elif [ "$SOURCE_TYPE" = "remote_container" ]; then
incus exec ${SOURCE_CONTAINER} -- tar \
--exclude='logs' \
--exclude='uploads' \
--warning=no-file-changed \
-czf /tmp/${ARCHIVE_NAME} -C ${API_PATH} .
" || echo_error "Failed to create archive on remote"
@@ -211,54 +202,23 @@ elif [ "$SOURCE_TYPE" = "remote_container" ]; then
incus exec ${SOURCE_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
" || echo_error "Failed to extract archive from remote container"
# Copier l'archive vers la machine locale pour backup
scp -i ${HOST_KEY} -P ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST}:/tmp/${ARCHIVE_NAME} ${TEMP_ARCHIVE} || echo_error "Failed to copy archive locally"
create_local_backup "${TEMP_ARCHIVE}" "to-pra"
# Copier l'archive vers la machine locale
scp -i ${HOST_KEY} -P ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST}:/tmp/${ARCHIVE_NAME} ${ARCHIVE_PATH} || echo_error "Failed to copy archive locally"
echo_info "Archive saved: ${ARCHIVE_PATH}"
echo_info "Archive size: $(du -h "${ARCHIVE_PATH}" | cut -f1)"
fi
ARCHIVE_SIZE=$(du -h "${TEMP_ARCHIVE}" | cut -f1)
echo_info "Archive size: ${ARCHIVE_SIZE}"
# Nettoyer les anciens backups
cleanup_old_backups
# =====================================
# Déploiement selon la destination
# =====================================
if [ "$DEST_HOST" = "local" ]; then
# Déploiement sur container local (DEV)
echo_step "Deploying to local container ${DEST_CONTAINER}..."
echo_info "Switching to Incus project ${INCUS_PROJECT}..."
incus project switch ${INCUS_PROJECT} || echo_error "Failed to switch to project ${INCUS_PROJECT}"
echo_info "Pushing archive to container..."
incus file push "${TEMP_ARCHIVE}" ${DEST_CONTAINER}/tmp/${ARCHIVE_NAME} || echo_error "Failed to push archive to container"
echo_info "Preparing deployment directory..."
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH} || echo_error "Failed to create deployment directory"
incus exec ${DEST_CONTAINER} -- rm -rf ${API_PATH}/* || echo_warning "Could not clean deployment directory"
echo_info "Extracting archive..."
incus exec ${DEST_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${API_PATH}/ || echo_error "Failed to extract archive"
echo_info "Setting permissions..."
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs ${API_PATH}/uploads
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${API_PATH}
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \;
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \;
# Permissions spéciales pour logs et uploads
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${API_PATH}/logs ${API_PATH}/uploads
incus exec ${DEST_CONTAINER} -- chmod -R 775 ${API_PATH}/logs ${API_PATH}/uploads
echo_info "Updating Composer dependencies..."
incus exec ${DEST_CONTAINER} -- bash -c "cd ${API_PATH} && composer update --no-dev --optimize-autoloader" || echo_warning "Composer not available or failed"
echo_info "Cleaning up..."
incus exec ${DEST_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME}
else
# Déploiement sur container distant (RCA ou PRA)
# Tous les déploiements se font maintenant sur des containers distants
if [ "$DEST_HOST" != "local" ]; then
# Déploiement sur container distant (DEV, RCA ou PRA)
echo_step "Deploying to remote container ${DEST_CONTAINER} on ${DEST_HOST}..."
# Créer une sauvegarde sur le serveur de destination
@@ -276,17 +236,20 @@ else
# Transférer l'archive vers le serveur de destination
echo_info "Transferring archive to ${DEST_HOST}..."
if [ "$SOURCE_TYPE" = "local_container" ]; then
# Pour RCA: copier depuis local vers distant
scp -i ${HOST_KEY} -P ${HOST_PORT} ${TEMP_ARCHIVE} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME} || echo_error "Failed to copy archive to destination"
if [ "$SOURCE_TYPE" = "local_code" ]; then
# Pour DEV: copier depuis local vers IN3
scp -i ${HOST_KEY} -P ${HOST_PORT} ${ARCHIVE_PATH} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME} || echo_error "Failed to copy archive to destination"
elif [ "$SOURCE_TYPE" = "remote_container" ] && [ "$SOURCE_HOST" = "$DEST_HOST" ]; then
# Pour RCA: même serveur (IN3), pas de transfert nécessaire, l'archive est déjà là
echo_info "Archive already on destination server (same host)"
else
# Pour PRA: copier de serveur à serveur
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "
scp -i ${HOST_KEY} -P ${HOST_PORT} /tmp/${ARCHIVE_NAME} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME}
" || echo_error "Failed to transfer archive between servers"
# Nettoyer sur le serveur source
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "rm -f /tmp/${ARCHIVE_NAME}"
# Pour PRA: l'archive est déjà sur la machine locale (copiée depuis IN3)
# On la transfère maintenant vers IN4
echo_info "Transferring archive from local to IN4..."
scp -i ${HOST_KEY} -P ${HOST_PORT} ${ARCHIVE_PATH} ${HOST_USER}@${DEST_HOST}:/tmp/${ARCHIVE_NAME} || echo_error "Failed to copy archive to IN4"
# Nettoyer sur le serveur source IN3
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "rm -f /tmp/${ARCHIVE_NAME}" || echo_warning "Could not clean source server"
fi
# Déployer sur le container de destination
@@ -311,16 +274,16 @@ else
# Permissions spéciales pour logs
incus exec ${DEST_CONTAINER} -- test -d ${API_PATH}/logs &&
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${API_PATH}/logs &&
incus exec ${DEST_CONTAINER} -- chmod -R 775 ${API_PATH}/logs || true &&
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP_LOGS} ${API_PATH}/logs &&
incus exec ${DEST_CONTAINER} -- chmod -R 755 ${API_PATH}/logs || true &&
# Permissions spéciales pour uploads
incus exec ${DEST_CONTAINER} -- test -d ${API_PATH}/uploads &&
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${API_PATH}/uploads &&
incus exec ${DEST_CONTAINER} -- chmod -R 775 ${API_PATH}/uploads || true &&
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP_LOGS} ${API_PATH}/uploads &&
incus exec ${DEST_CONTAINER} -- chmod -R 755 ${API_PATH}/uploads || true &&
# Composer
incus exec ${DEST_CONTAINER} -- bash -c 'cd ${API_PATH} && composer update --no-dev --optimize-autoloader' || echo 'Composer update skipped' &&
incus exec ${DEST_CONTAINER} -- bash -c 'cd ${API_PATH} && composer install --no-dev --optimize-autoloader' || echo 'Composer install skipped' &&
# Nettoyage
incus exec ${DEST_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} &&
@@ -330,8 +293,8 @@ else
echo_info "Remote backup saved: ${REMOTE_BACKUP_DIR} on ${DEST_CONTAINER}"
fi
# Nettoyage local
rm -f "${TEMP_ARCHIVE}"
# L'archive reste dans le dossier de backup, pas de nettoyage nécessaire
echo_info "Archive preserved in backup directory: ${ARCHIVE_PATH}"
# =====================================
# Résumé final
@@ -341,9 +304,9 @@ echo_step "Deployment completed successfully!"
echo_info "Environment: ${ENV_NAME}"
if [ "$TARGET_ENV" = "dev" ]; then
echo_info "Deployed from local code to container ${DEST_CONTAINER}"
echo_info "Deployed from local code to container ${DEST_CONTAINER} on IN3 (${DEST_HOST})"
elif [ "$TARGET_ENV" = "rca" ]; then
echo_info "Delivered from ${SOURCE_CONTAINER} (local) to ${DEST_CONTAINER} on ${DEST_HOST}"
echo_info "Delivered from ${SOURCE_CONTAINER} to ${DEST_CONTAINER} on ${DEST_HOST}"
elif [ "$TARGET_ENV" = "pra" ]; then
echo_info "Delivered from ${SOURCE_CONTAINER} on ${SOURCE_HOST} to ${DEST_CONTAINER} on ${DEST_HOST}"
fi
@@ -351,4 +314,4 @@ fi
echo_info "Deployment completed at: $(date)"
# Journaliser le déploiement
echo "$(date '+%Y-%m-%d %H:%M:%S') - API deployed to ${ENV_NAME} (${DEST_CONTAINER})" >> ~/.geo_deploy_history
echo "$(date '+%Y-%m-%d %H:%M:%S') - API deployed to ${ENV_NAME} (${DEST_CONTAINER}) - Archive: ${ARCHIVE_NAME}" >> ~/.geo_deploy_history

View File

@@ -1,6 +1,7 @@
# PLANNING STRIPE - DÉVELOPPEUR BACKEND PHP
## API PHP 8.3 - Intégration Stripe Connect + Terminal
## 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
---
@@ -31,7 +32,13 @@ composer require stripe/stripe-php
#### ✅ Base de données
```sql
-- Tables à créer
-- 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,
@@ -44,32 +51,8 @@ CREATE TABLE stripe_accounts (
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)
);
-- 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,
@@ -162,45 +145,47 @@ public function handleWebhook(Request $request) {
}
```
#### ✅ Terminal Connection Token
#### ✅ Configuration Tap to Pay
```php
// 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];
// 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)
#### ✅ Gestion des Locations
#### ✅ Vérification compatibilité Device
```php
// 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;
// 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'
];
}
```
@@ -210,24 +195,22 @@ public function createLocation($amicaleId) {
### 🌅 Matin (4h)
#### ✅ Création PaymentIntent avec commission
#### ✅ 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
'amicale_id' => 'required|exists:amicales,id',
'passage_id' => 'required|integer', // ID du passage ope_pass
'entity_id' => 'required|integer',
]);
$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%
);
$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',
@@ -235,26 +218,27 @@ public function createPaymentIntent(Request $request) {
'capture_method' => 'automatic',
'application_fee_amount' => $applicationFee,
'transfer_data' => [
'destination' => $amicale->stripe_account_id,
'destination' => $entity->stripe_account_id,
],
'metadata' => [
'pompier_id' => $pompier->id,
'pompier_name' => $pompier->name,
'amicale_id' => $amicale->id,
'calendrier_annee' => date('Y'),
'passage_id' => $validated['passage_id'],
'user_id' => $userId,
'entity_id' => $entity->id,
'year' => 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,
// 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,
@@ -268,31 +252,59 @@ public function createPaymentIntent(Request $request) {
```php
// POST /api/payments/{id}/capture
public function capturePayment($paymentIntentId) {
$localPayment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
// 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();
}
$localPayment->update(['status' => $paymentIntent->status]);
// Si succès, envoyer email reçu
if ($paymentIntent->status === 'succeeded') {
$this->sendReceipt($localPayment);
// 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/payments/{id}/status
public function getPaymentStatus($paymentIntentId) {
$payment = PaymentIntent::where('stripe_payment_intent_id', $paymentIntentId)->first();
// 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 [
'status' => $payment->status,
'amount' => $payment->amount,
'created_at' => $payment->created_at,
'stripe_payment_id' => $passage['stripe_payment_id'],
'status' => $paymentIntent->status,
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'created_at' => $passage['date_creat']
];
}
```
@@ -625,14 +637,14 @@ Log::channel('stripe')->info('Payment created', [
## 🎯 BILAN DÉVELOPPEMENT API (01/09/2024)
### ✅ ENDPOINTS IMPLÉMENTÉS ET TESTÉS
### ✅ 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
@@ -643,17 +655,6 @@ Log::channel('stripe')->info('Payment created', [
- URLs de retour configurées
- Gestion des erreurs et timeouts
#### **Terminal et Locations**
- **POST /api/stripe/locations** ✅
- Création de locations Terminal
- Association avec compte Stripe de l'amicale
- ID location retourné : tml_GLJ21w7KCYX4Wj
- **POST /api/stripe/terminal/connection-token** ✅
- Génération tokens de connexion Terminal
- Authentification par session
- Support multi-amicales
#### **Configuration et Utilitaires**
- **GET /api/stripe/config** ✅
- Configuration publique Stripe
@@ -728,9 +729,10 @@ Log::channel('stripe')->info('Payment created', [
- Public endpoints: webhook uniquement
- Pas de stockage clés secrètes en base
#### **Base de données**
#### **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)
- Pas de nouvelles tables créées (pas nécessaire pour V1)
- Champs encrypted_email et encrypted_name supportés
- Déchiffrement automatique avant envoi Stripe
@@ -743,4 +745,74 @@ Log::channel('stripe')->info('Payment created', [
---
*Document créé le 24/08/2024 - Dernière mise à jour : 01/09/2024*
## 📱 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*

View File

@@ -0,0 +1,343 @@
# Flow de paiement Stripe Tap to Pay
## Vue d'ensemble
Ce document décrit le flow complet pour les paiements Stripe Tap to Pay dans l'application GeoSector, depuis la création du compte Stripe Connect jusqu'au paiement final.
---
## 🏢 PRÉALABLE : Création d'un compte Amicale Stripe Connect
Avant de pouvoir utiliser les paiements Stripe, chaque amicale doit créer son compte Stripe Connect.
### 📋 Flow de création du compte
#### 1. Initiation depuis l'application web admin
**Endpoint :** `POST /api/stripe/accounts/create`
**Requête :**
```json
{
"amicale_id": 45,
"type": "express", // Type de compte Stripe Connect
"country": "FR",
"email": "contact@amicale-pompiers-paris.fr",
"business_profile": {
"name": "Amicale des Pompiers de Paris",
"product_description": "Vente de calendriers des pompiers",
"mcc": "8398", // Code activité : organisations civiques
"url": "https://www.amicale-pompiers-paris.fr"
}
}
```
#### 2. Création du compte Stripe
**Actions API :**
1. Appel Stripe API pour créer un compte Express
2. Génération d'un lien d'onboarding personnalisé
3. Sauvegarde en base de données
**Réponse :**
```json
{
"success": true,
"stripe_account_id": "acct_1O3ABC456DEF789",
"onboarding_url": "https://connect.stripe.com/express/oauth/authorize?...",
"status": "pending"
}
```
#### 3. Processus d'onboarding Stripe
**Actions utilisateur (dirigeant amicale) :**
1. Clic sur le lien d'onboarding
2. Connexion/création compte Stripe
3. Saisie des informations légales :
- **Entité** : Association loi 1901
- **SIRET** de l'amicale
- **RIB** pour les virements
- **Pièce d'identité** du représentant légal
4. Validation des conditions d'utilisation
#### 4. Vérification et activation
**Webhook Stripe → API :**
```json
POST /api/stripe/webhooks
{
"type": "account.updated",
"data": {
"object": {
"id": "acct_1O3ABC456DEF789",
"charges_enabled": true,
"payouts_enabled": true,
"details_submitted": true
}
}
}
```
**Actions API :**
1. Mise à jour du statut en base
2. Notification email à l'amicale
3. Activation des fonctionnalités de paiement
#### 5. Structure en base de données
**Table `stripe_accounts` :**
```sql
CREATE TABLE `stripe_accounts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_entite` int(10) unsigned NOT NULL,
`stripe_account_id` varchar(50) NOT NULL,
`account_type` enum('express','standard','custom') DEFAULT 'express',
`charges_enabled` tinyint(1) DEFAULT 0,
`payouts_enabled` tinyint(1) DEFAULT 0,
`details_submitted` tinyint(1) DEFAULT 0,
`country` varchar(2) DEFAULT 'FR',
`default_currency` varchar(3) DEFAULT 'eur',
`business_name` varchar(255) DEFAULT NULL,
`support_email` varchar(255) DEFAULT NULL,
`onboarding_completed_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `stripe_account_id` (`stripe_account_id`),
KEY `fk_entite` (`fk_entite`),
CONSTRAINT `stripe_accounts_ibfk_1` FOREIGN KEY (`fk_entite`) REFERENCES `entites` (`id`)
);
```
### 🔐 Sécurité et validation
#### Prérequis pour créer un compte :
- ✅ Utilisateur administrateur de l'amicale
- ✅ Amicale active avec statut validé
- ✅ Email de contact vérifié
- ✅ Informations légales complètes (SIRET, adresse)
#### Validation avant paiements :
-`charges_enabled = 1` (peut recevoir des paiements)
-`payouts_enabled = 1` (peut recevoir des virements)
-`details_submitted = 1` (onboarding terminé)
### 📊 États du compte Stripe
| État | Description | Actions possibles |
|------|-------------|-------------------|
| `pending` | Compte créé, onboarding en cours | Compléter l'onboarding |
| `restricted` | Informations manquantes | Fournir documents manquants |
| `restricted_soon` | Vérification en cours | Attendre validation Stripe |
| `active` | Compte opérationnel | Recevoir des paiements ✅ |
| `rejected` | Compte refusé par Stripe | Contacter support |
### 🚨 Gestion des erreurs
#### Erreurs courantes lors de la création :
- **400** : Données manquantes ou invalides
- **409** : Compte Stripe déjà existant pour cette amicale
- **403** : Utilisateur non autorisé
#### Erreurs durant l'onboarding :
- Documents manquants ou invalides
- Informations bancaires incorrectes
- Activité non autorisée par Stripe
### 📞 Support et résolution
#### Pour les amicales :
1. **Email support** : support@geosector.fr
2. **Documentation** : Guides d'onboarding disponibles
3. **Assistance téléphonique** : Disponible aux heures ouvrables
#### Pour les développeurs :
1. **Stripe Dashboard** : Accès aux comptes et statuts
2. **Logs API** : Traçabilité complète des opérations
3. **Webhook monitoring** : Suivi des événements Stripe
---
## 🚨 IMPORTANT : Nouveau Flow (v2)
**Le passage est TOUJOURS créé/modifié EN PREMIER** pour obtenir un ID réel, PUIS le PaymentIntent est créé avec cet ID.
## Flow détaillé
### 1. Sauvegarde du passage EN PREMIER
L'application crée ou modifie d'abord le passage pour obtenir un ID réel :
```
POST /api/passages/create // Nouveau passage
PUT /api/passages/456 // Mise à jour passage existant
```
**Réponse avec l'ID réel :**
```json
{
"status": "success",
"passage_id": 456 // ID RÉEL du passage créé/modifié
}
```
### 2. Création du PaymentIntent AVEC l'ID réel
Ensuite seulement, création du PaymentIntent avec le `passage_id` réel :
```
POST /api/stripe/payments/create-intent
```
```json
{
"amount": 2500, // En centimes (25€)
"passage_id": 456, // ID RÉEL du passage (JAMAIS 0)
"payment_method_types": ["card_present"], // Tap to Pay
"location_id": "tml_xxx", // Terminal reader location
"amicale_id": 45,
"member_id": 67,
"stripe_account": "acct_xxx"
}
```
#### Réponse
```json
{
"status": "success",
"data": {
"client_secret": "pi_3QaXYZ_secret_xyz",
"payment_intent_id": "pi_3QaXYZ123ABC456",
"amount": 2500,
"currency": "eur",
"passage_id": 789, // 0 pour nouveau passage
"type": "tap_to_pay"
}
}
```
### 2. Traitement du paiement côté client
L'application utilise le SDK Stripe pour traiter le paiement via NFC :
```dart
// Flutter - Utilisation du client_secret
final paymentResult = await stripe.collectPaymentMethod(
clientSecret: response['client_secret'],
// ... configuration Tap to Pay
);
```
### 3. Traitement du paiement Tap to Pay
L'application utilise le SDK Stripe Terminal avec le `client_secret` pour collecter le paiement via NFC.
### 4. Mise à jour du passage avec stripe_payment_id
Après succès du paiement, l'app met à jour le passage avec le `stripe_payment_id` :
```json
PUT /api/passages/456
{
"stripe_payment_id": "pi_3QaXYZ123ABC456", // ← LIEN AVEC STRIPE
// ... autres champs si nécessaire
}
```
## Points clés du nouveau flow
### ✅ Avantages
1. **Passage toujours existant** : Le passage existe toujours avec un ID réel avant le paiement
2. **Traçabilité garantie** : Le `passage_id` dans Stripe est toujours valide
3. **Gestion d'erreur robuste** : Si le paiement échoue, le passage existe déjà
4. **Cohérence des données** : Pas de passage "orphelin" ou de paiement sans passage
### ❌ Ce qui n'est plus supporté
1. **passage_id=0** : Plus jamais utilisé dans `/create-intent`
2. **operation_id** : Plus nécessaire car le passage existe déjà
3. **Création conditionnelle** : Le passage est toujours créé avant
## Schéma de séquence (Nouveau Flow v2)
```
┌─────────┐ ┌─────────┐ ┌────────┐ ┌────────────┐
│ App │ │ API │ │ Stripe │ │ ope_pass │
└────┬────┘ └────┬────┘ └────┬───┘ └─────┬──────┘
│ │ │ │
│ 1. CREATE/UPDATE passage │ │
├──────────────>│ │ │
│ ├────────────────┼───────────────>│
│ │ │ INSERT/UPDATE │
│ │ │ │
│ 2. passage_id: 456 (réel) │ │
│<──────────────│ │ │
│ │ │ │
│ 3. create-intent (id=456) │ │
├──────────────>│ │ │
│ │ │ │
│ │ 4. Create PI │ │
│ ├───────────────>│ │
│ │ │ │
│ │ 5. PI created │ │
│ │<───────────────│ │
│ │ │ │
│ │ 6. UPDATE │ │
│ ├────────────────┼───────────────>│
│ │ stripe_payment_id = pi_xxx │
│ │ │ │
│ 7. client_secret + pi_id │ │
│<──────────────│ │ │
│ │ │ │
│ 8. Tap to Pay │ │ │
├───────────────┼───────────────>│ │
│ avec SDK │ │ │
│ │ │ │
│ 9. Payment OK │ │ │
│<──────────────┼────────────────│ │
│ │ │ │
│ 10. UPDATE passage (optionnel) │ │
├──────────────>│ │ │
│ ├────────────────┼───────────────>│
│ │ Confirmer stripe_payment_id │
│ │ │ │
│ 11. Success │ │ │
│<──────────────│ │ │
│ │ │ │
```
## Points importants (Nouveau Flow v2)
1. **Passage créé en premier** : Le passage est TOUJOURS créé/modifié AVANT le PaymentIntent
2. **ID réel obligatoire** : Le `passage_id` ne peut jamais être 0 dans `/create-intent`
3. **Lien Stripe automatique** : Le `stripe_payment_id` est ajouté automatiquement lors de la création du PaymentIntent
4. **Idempotence** : Un passage ne peut avoir qu'un seul `stripe_payment_id`
5. **Validation stricte** : Vérification du montant, propriété et existence du passage
## Erreurs possibles
- **400** :
- `passage_id` manquant ou ≤ 0
- Montant invalide (< 1 ou > 999€)
- Passage déjà payé par Stripe
- Montant ne correspond pas au passage
- **401** : Non authentifié
- **403** : Passage non autorisé (pas le bon utilisateur)
- **404** : Passage non trouvé
## Migration base de données
La colonne `stripe_payment_id VARCHAR(50)` a été ajoutée via :
```sql
ALTER TABLE `ope_pass` ADD COLUMN `stripe_payment_id` VARCHAR(50) DEFAULT NULL;
ALTER TABLE `ope_pass` ADD INDEX `idx_stripe_payment` (`stripe_payment_id`);
```
## Environnements
- **DEV** : dva-geo sur IN3 - Base mise à jour ✅
- **REC** : rca-geo sur IN3 - Base mise à jour ✅
- **PROD** : pra-geo sur IN4 - À mettre à jour

View File

@@ -0,0 +1,197 @@
# Stripe Tap to Pay - Requirements officiels
> Document basé sur la documentation officielle Stripe - Dernière vérification : 29 septembre 2025
## 📱 iOS - Tap to Pay sur iPhone
### Configuration minimum requise
| Composant | Requirement | Notes |
|-----------|------------|--------|
| **Appareil** | iPhone XS ou plus récent | iPhone XS, XR, 11, 12, 13, 14, 15, 16 |
| **iOS** | iOS 16.4 ou plus récent | Pour support PIN complet |
| **SDK** | Terminal iOS SDK 2.23.0+ | Version 3.6.0+ pour Interac (Canada) |
| **Entitlement** | Apple Tap to Pay | À demander sur Apple Developer |
### Fonctionnalités par version iOS
- **iOS 16.0-16.3** : Tap to Pay basique (sans PIN)
- **iOS 16.4+** : Support PIN complet pour toutes les cartes
- **Versions beta** : NON SUPPORTÉES
### Méthodes de paiement supportées
- ✅ Cartes sans contact : Visa, Mastercard, American Express
- ✅ Wallets NFC : Apple Pay, Google Pay, Samsung Pay
- ✅ Discover (USA uniquement)
- ✅ Interac (Canada uniquement, SDK 3.6.0+)
- ✅ eftpos (Australie uniquement)
### Limitations importantes
- ❌ iPad non supporté (pas de NFC)
- ❌ Puerto Rico non disponible
- ❌ Versions iOS beta non supportées
## 🤖 Android - Tap to Pay
### Configuration minimum requise
| Composant | Requirement | Notes |
|-----------|------------|--------|
| **Android** | Android 11 ou plus récent | API level 30+ |
| **NFC** | Capteur NFC fonctionnel | Obligatoire |
| **Processeur** | ARM | x86 non supporté |
| **Sécurité** | Appareil non rooté | Bootloader verrouillé |
| **Services** | Google Mobile Services | GMS obligatoire |
| **Keystore** | Hardware keystore intégré | Pour sécurité |
| **OS** | OS constructeur non modifié | Pas de ROM custom |
### Appareils certifiés en France (liste non exhaustive)
#### Samsung
- Galaxy S21, S21+, S21 Ultra, S21 FE (Android 11+)
- Galaxy S22, S22+, S22 Ultra (Android 12+)
- Galaxy S23, S23+, S23 Ultra, S23 FE (Android 13+)
- Galaxy S24, S24+, S24 Ultra (Android 14+)
- Galaxy Z Fold 3, 4, 5, 6
- Galaxy Z Flip 3, 4, 5, 6
- Galaxy Note 20, Note 20 Ultra
- Galaxy A54, A73 (haut de gamme)
#### Google Pixel
- Pixel 6, 6 Pro, 6a (Android 12+)
- Pixel 7, 7 Pro, 7a (Android 13+)
- Pixel 8, 8 Pro, 8a (Android 14+)
- Pixel 9, 9 Pro, 9 Pro XL (Android 14+)
- Pixel Fold (Android 13+)
- Pixel Tablet (Android 13+)
#### OnePlus
- OnePlus 9, 9 Pro (Android 11+)
- OnePlus 10 Pro, 10T (Android 12+)
- OnePlus 11, 11R (Android 13+)
- OnePlus 12, 12R (Android 14+)
- OnePlus Open (Android 13+)
#### Xiaomi
- Mi 11, 11 Ultra (Android 11+)
- Xiaomi 12, 12 Pro, 12T Pro (Android 12+)
- Xiaomi 13, 13 Pro, 13T Pro (Android 13+)
- Xiaomi 14, 14 Pro, 14 Ultra (Android 14+)
#### Autres marques
- OPPO Find X3/X5/X6 Pro, Find N2/N3
- Realme GT 2 Pro, GT 3, GT 5 Pro
- Honor Magic5/6 Pro, 90
- ASUS Zenfone 9/10, ROG Phone 7
- Nothing Phone (1), (2), (2a)
## 🌍 Disponibilité par pays
### Europe
- ✅ France : Disponible
- ✅ Royaume-Uni : Disponible
- ✅ Allemagne : Disponible
- ✅ Pays-Bas : Disponible
- ✅ Irlande : Disponible
- ✅ Italie : Disponible (récent)
- ✅ Espagne : Disponible (récent)
### Amérique
- ✅ États-Unis : Disponible (+ Discover)
- ✅ Canada : Disponible (+ Interac)
- ❌ Puerto Rico : Non disponible
- ❌ Mexique : Non disponible
### Asie-Pacifique
- ✅ Australie : Disponible (+ eftpos)
- ✅ Nouvelle-Zélande : Disponible
- ✅ Singapour : Disponible
- ✅ Japon : Disponible (récent)
## 🔧 Intégration technique
### SDK Requirements
```javascript
// iOS
pod 'StripeTerminal', '~> 2.23.0' // Minimum pour Tap to Pay
pod 'StripeTerminal', '~> 3.6.0' // Pour support Interac
// Android
implementation 'com.stripe:stripeterminal-taptopay:3.7.1'
implementation 'com.stripe:stripeterminal-core:3.7.1'
// React Native
"@stripe/stripe-terminal-react-native": "^0.0.1-beta.17"
// Flutter
stripe_terminal: ^3.2.0
```
### Capacités requises
#### iOS Info.plist
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Bluetooth nécessaire pour Tap to Pay</string>
<key>NFCReaderUsageDescription</key>
<string>NFC nécessaire pour lire les cartes</string>
<key>com.apple.developer.proximity-reader</key>
<true/>
```
#### Android Manifest
```xml
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />
```
## 📊 Limites techniques
| Limite | Valeur | Notes |
|--------|--------|-------|
| **Montant min** | 1€ / $1 | Selon devise |
| **Montant max** | Variable par pays | France : 50€ sans PIN, illimité avec PIN |
| **Timeout transaction** | 60 secondes | Après présentation carte |
| **Distance NFC** | 4cm max | Distance optimale |
| **Tentatives PIN** | 3 max | Puis carte bloquée |
## 🔐 Sécurité
### Certifications
- PCI-DSS Level 1
- EMV Contactless Level 1
- Apple ProximityReader Framework
- Google SafetyNet Attestation
### Données sensibles
- Les données de carte ne transitent JAMAIS par l'appareil
- Tokenisation end-to-end par Stripe
- Pas de stockage local des données carte
- PIN chiffré directement vers Stripe
## 📚 Ressources officielles
- [Documentation Stripe Terminal](https://docs.stripe.com/terminal)
- [Tap to Pay sur iPhone - Apple Developer](https://developer.apple.com/tap-to-pay/)
- [Guide d'intégration iOS](https://docs.stripe.com/terminal/payments/setup-reader/tap-to-pay?platform=ios)
- [Guide d'intégration Android](https://docs.stripe.com/terminal/payments/setup-reader/tap-to-pay?platform=android)
- [SDK Terminal iOS](https://github.com/stripe/stripe-terminal-ios)
- [SDK Terminal Android](https://github.com/stripe/stripe-terminal-android)
## 🔄 Historique des versions
| Date | Version iOS | Changement |
|------|-------------|------------|
| Sept 2022 | iOS 16.0 | Lancement initial Tap to Pay |
| Mars 2023 | iOS 16.4 | Ajout support PIN |
| Sept 2023 | iOS 17.0 | Améliorations performances |
| Sept 2024 | iOS 18.0 | Support étendu international |
---
*Document maintenu par l'équipe GeoSector - Dernière mise à jour : 29/09/2025*

View File

@@ -10,7 +10,8 @@
6. [Sécurité](#sécurité)
7. [Gestion des mots de passe (NIST SP 800-63B)](#gestion-des-mots-de-passe-nist-sp-800-63b)
8. [Endpoints API](#endpoints-api)
9. [Changements récents](#changements-récents)
9. [Paiements Stripe Connect](#paiements-stripe-connect)
10. [Changements récents](#changements-récents)
## Structure du projet
@@ -130,6 +131,27 @@ Exemple détaillé du parcours d'une requête POST /api/users :
## Base de données
### Architecture des containers MariaDB
Depuis janvier 2025, les bases de données sont hébergées dans des containers MariaDB dédiés :
| Environnement | Container API | Container DB | Serveur | IP DB | Nom BDD | Utilisateur | Source des données |
|---------------|--------------|--------------|---------|-------|---------|-------------|-------------------|
| **DEV** | dva-geo | maria3 | IN3 | 13.23.33.4 | dva_geo | dva_geo_user | Migré depuis dva-geo/geo_app |
| **RECETTE** | rca-geo | maria3 | IN3 | 13.23.33.4 | rca_geo | rca_geo_user | Migré depuis rca-geo/geo_app |
| **PRODUCTION** | pra-geo | maria4 | IN4 | 13.23.33.4 | pra_geo | pra_geo_user | **Dupliqué depuis maria3/rca_geo** |
**Note importante :** La base de production `pra_geo` est créée en dupliquant `rca_geo` depuis IN3/maria3 vers IN4/maria4.
**Avantages de cette architecture :**
- Isolation des données par environnement
- Performances optimisées (containers dédiés)
- Sauvegardes indépendantes
- Maintenance simplifiée
- Séparation physique Production/Recette (serveurs différents)
**Migration :** Utiliser le script `scripts/migrate_to_maria_containers.sh` pour migrer les données.
### Structure des tables principales
#### Table `users`
@@ -735,6 +757,300 @@ Lors du login, les paramètres de l'entité sont retournés dans le groupe `amic
Ces paramètres permettent à l'application Flutter d'adapter dynamiquement le formulaire de création de membre.
## Paiements Stripe Connect
### Vue d'ensemble
L'API intègre un système complet de paiements via Stripe Connect, permettant aux amicales de recevoir des paiements pour leurs calendriers via deux méthodes :
- **Paiements Web** : Interface de paiement dans un navigateur
- **Tap to Pay** : Paiements NFC via l'application mobile Flutter
### Architecture Stripe Connect
#### Tables de base de données
**Table `stripe_accounts` :**
```sql
CREATE TABLE `stripe_accounts` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_entite` int(10) unsigned NOT NULL,
`stripe_account_id` varchar(50) NOT NULL,
`account_type` enum('express','standard','custom') DEFAULT 'express',
`charges_enabled` tinyint(1) DEFAULT 0,
`payouts_enabled` tinyint(1) DEFAULT 0,
`details_submitted` tinyint(1) DEFAULT 0,
`country` varchar(2) DEFAULT 'FR',
`default_currency` varchar(3) DEFAULT 'eur',
`business_name` varchar(255) DEFAULT NULL,
`support_email` varchar(255) DEFAULT NULL,
`onboarding_completed_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `stripe_account_id` (`stripe_account_id`),
KEY `fk_entite` (`fk_entite`),
CONSTRAINT `stripe_accounts_ibfk_1` FOREIGN KEY (`fk_entite`) REFERENCES `entites` (`id`)
);
```
**Ajout du champ `stripe_payment_id` dans `ope_pass` :**
```sql
ALTER TABLE `ope_pass` ADD COLUMN `stripe_payment_id` VARCHAR(50) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe (pi_xxx)';
ALTER TABLE `ope_pass` ADD INDEX `idx_stripe_payment` (`stripe_payment_id`);
```
#### Services principaux
**StripeService** (`src/Services/StripeService.php`) :
- Gestion des PaymentIntents
- Communication avec l'API Stripe
- Gestion des comptes Stripe Connect
**StripeController** (`src/Controllers/StripeController.php`) :
- Endpoints pour la création de PaymentIntents
- Gestion des webhooks Stripe
- API pour les comptes Connect
### Flow de paiement
#### 1. Création du compte Stripe Connect (Onboarding)
```http
POST /api/stripe/accounts/create
Authorization: Bearer {session_id}
Content-Type: application/json
{
"amicale_id": 45,
"type": "express",
"country": "FR",
"email": "contact@amicale-pompiers.fr",
"business_profile": {
"name": "Amicale des Pompiers",
"product_description": "Vente de calendriers des pompiers",
"mcc": "8398"
}
}
```
**Réponse :**
```json
{
"success": true,
"stripe_account_id": "acct_1O3ABC456DEF789",
"onboarding_url": "https://connect.stripe.com/express/oauth/authorize?...",
"status": "pending"
}
```
#### 2. Création d'un PaymentIntent (Tap to Pay)
**Flow actuel (v2) :**
1. L'application crée/modifie d'abord le passage pour obtenir un ID réel
2. Puis crée le PaymentIntent avec cet ID
```http
POST /api/stripe/payments/create-intent
Authorization: Bearer {session_id}
Content-Type: application/json
{
"amount": 2500, // 25€ en centimes
"passage_id": 456, // ID RÉEL du passage (jamais 0)
"payment_method_types": ["card_present"], // Tap to Pay
"location_id": "tml_xxx",
"amicale_id": 45,
"member_id": 67,
"stripe_account": "acct_1234"
}
```
**Réponse :**
```json
{
"success": true,
"client_secret": "pi_3QaXYZ_secret_xyz",
"payment_intent_id": "pi_3QaXYZ123ABC456",
"amount": 2500,
"currency": "eur",
"passage_id": 456,
"type": "tap_to_pay"
}
```
#### 3. Traitement du paiement
**Côté application Flutter :**
- Utilisation du SDK Stripe Terminal
- Collecte NFC avec le `client_secret`
- Traitement automatique du paiement
**Mise à jour automatique :**
- Le `stripe_payment_id` est automatiquement ajouté au passage lors de la création du PaymentIntent
- Lien bidirectionnel entre le passage et le paiement Stripe
### Endpoints Stripe
#### Gestion des comptes
- `POST /api/stripe/accounts/create` : Création d'un compte Connect
- `GET /api/stripe/accounts/{id}` : Statut d'un compte
- `PUT /api/stripe/accounts/{id}` : Mise à jour d'un compte
#### Gestion des paiements
- `POST /api/stripe/payments/create-intent` : Création d'un PaymentIntent
- `GET /api/stripe/payments/{id}` : Statut d'un paiement
- `POST /api/stripe/payments/confirm` : Confirmation d'un paiement
#### Gestion des devices Tap to Pay
- `GET /api/stripe/devices/certified-android` : Liste des appareils Android certifiés
- `POST /api/stripe/devices/check-tap-to-pay` : Vérification de compatibilité d'un appareil
- `GET /api/stripe/config` : Configuration publique Stripe
- `GET /api/stripe/stats` : Statistiques de paiement
#### Webhooks
- `POST /api/stripe/webhooks` : Réception des événements Stripe
- `account.updated` : Mise à jour du statut d'un compte
- `payment_intent.succeeded` : Confirmation d'un paiement réussi
- `payment_intent.payment_failed` : Échec d'un paiement
### Sécurité et validation
#### Prérequis pour les paiements :
- ✅ Compte Stripe Connect activé (`charges_enabled = 1`)
- ✅ Virements activés (`payouts_enabled = 1`)
- ✅ Onboarding terminé (`details_submitted = 1`)
- ✅ Passage existant avec montant correspondant
- ✅ Utilisateur authentifié et autorisé
#### Validation des montants :
- Minimum : 1€ (100 centimes)
- Maximum : 999€ (99 900 centimes)
- Vérification de correspondance avec le passage
#### Sécurité des transactions :
- Headers CORS configurés
- Validation côté serveur obligatoire
- Logs de toutes les transactions
- Gestion des erreurs robuste
### États et statuts
#### États des comptes Stripe :
- `pending` : Onboarding en cours
- `restricted` : Informations manquantes
- `active` : Opérationnel pour les paiements
- `rejected` : Refusé par Stripe
#### États des paiements :
- `requires_payment_method` : En attente de paiement
- `processing` : Traitement en cours
- `succeeded` : Paiement réussi
- `canceled` : Paiement annulé
- `requires_action` : Action utilisateur requise
### Intégration avec l'application
#### Flutter (Tap to Pay) :
- SDK Stripe Terminal pour iOS/Android
- Interface NFC native
- Gestion des états du terminal
- Validation en temps réel
#### Web (Paiements navigateur) :
- Stripe.js pour l'interface
- Formulaire de carte sécurisé
- Confirmation 3D Secure automatique
### Monitoring et logs
#### Logs importants :
- Création/mise à jour des comptes Connect
- Succès/échecs des paiements
- Erreurs webhook Stripe
- Tentatives de paiement frauduleuses
#### Métriques de suivi :
- Taux de succès des paiements par amicale
- Montants moyens des transactions
- Temps de traitement des paiements
- Erreurs par type d'appareil
### Configuration environnement
#### Variables Stripe par environnement :
| Environnement | Clés | Webhooks |
|---------------|------|----------|
| **DEV** | Test keys (pk_test_, sk_test_) | URL dev webhook |
| **RECETTE** | Test keys (pk_test_, sk_test_) | URL recette webhook |
| **PRODUCTION** | Live keys (pk_live_, sk_live_) | URL prod webhook |
#### Comptes Connect :
- Type : Express (simplifié pour les associations)
- Pays : France (FR)
- Devise : Euro (EUR)
- Frais : Standard Stripe Connect
### Gestion des appareils certifiés Tap to Pay
#### Table `stripe_android_certified_devices`
Stocke la liste des appareils Android certifiés pour Tap to Pay en France :
- **95+ appareils** pré-chargés lors de l'installation
- **Mise à jour automatique** hebdomadaire via CRON
- **Vérification de compatibilité** via endpoints dédiés
#### Endpoint de vérification de compatibilité
```http
POST /api/stripe/devices/check-tap-to-pay
Content-Type: application/json
{
"platform": "ios" | "android",
"manufacturer": "Samsung", // Requis pour Android
"model": "SM-S921B" // Requis pour Android
}
```
**Réponse Android compatible :**
```json
{
"status": "success",
"tap_to_pay_supported": true,
"message": "Tap to Pay disponible sur cet appareil",
"min_android_version": 14
}
```
**Réponse iOS :**
```json
{
"status": "success",
"message": "Vérification iOS à faire côté client",
"requirements": "iPhone XS ou plus récent avec iOS 16.4+",
"details": "iOS 16.4 minimum requis pour le support PIN complet"
}
```
#### Requirements Tap to Pay
| Plateforme | Appareil minimum | OS minimum | Notes |
|------------|------------------|------------|-------|
| **iOS** | iPhone XS (2018+) | iOS 16.4+ | Support PIN complet |
| **Android** | Variable | Android 11+ | NFC obligatoire, non rooté |
### Documentation technique complète
Pour le flow détaillé complet, voir :
- **`docs/STRIPE-TAP-TO-PAY-FLOW.md`** : Documentation technique complète du flow de paiement
- **`docs/PLANNING-STRIPE-API.md`** : Planification et architecture Stripe
- **`docs/STRIPE-TAP-TO-PAY-REQUIREMENTS.md`** : Requirements officiels et liste complète des devices certifiés
## Intégration Frontend
### Configuration des Requêtes
@@ -754,6 +1070,71 @@ fetch('/api/endpoint', {
- Pas besoin de stocker ou gérer des tokens manuellement
- Redirection vers /login si session expirée (401)
## Système de tâches CRON
### Vue d'ensemble
L'API utilise des scripts CRON pour automatiser les tâches de maintenance et de traitement. Les scripts sont situés dans `/scripts/cron/` et s'exécutent dans les containers Incus Alpine.
### Tâches CRON configurées
| Script | Fréquence | Fonction | Container |
|--------|-----------|----------|-----------|
| `process_email_queue.php` | */5 * * * * | Traite la queue d'emails (reçus, notifications) | DVA, RCA |
| `cleanup_security_data.php` | 0 2 * * * | Nettoie les données de sécurité obsolètes | DVA, RCA |
| `update_stripe_devices.php` | 0 3 * * 0 | Met à jour la liste des devices certifiés Tap to Pay | DVA, RCA |
### Configuration des CRONs
Sur les containers Alpine (dva-geo, rca-geo, pra-geo) :
```bash
# Vérifier les crons actifs
crontab -l
# Éditer les crons
crontab -e
# Format des lignes cron
*/5 * * * * /usr/bin/php /var/www/geosector/api/scripts/cron/process_email_queue.php >> /var/www/geosector/api/logs/email_queue.log 2>&1
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
```
### Script `process_email_queue.php`
- **Fonction** : Envoie les emails en attente dans la table `email_queue`
- **Batch** : 50 emails maximum par exécution
- **Lock file** : `/tmp/process_email_queue.lock` (évite l'exécution simultanée)
- **Gestion d'erreur** : 3 tentatives max par email
### Script `cleanup_security_data.php`
- **Fonction** : Purge les données de sécurité selon la politique de rétention
- **Rétention** :
- Métriques de performance : 30 jours
- Tentatives de login échouées : 7 jours
- Alertes résolues : 90 jours
- IPs expirées : Déblocage immédiat
### Script `update_stripe_devices.php`
- **Fonction** : Maintient à jour la liste des appareils certifiés Tap to Pay
- **Source** : Liste de 95+ devices intégrée + fichier JSON optionnel
- **Actions** :
- Ajoute les nouveaux appareils certifiés
- Met à jour les versions Android minimales
- Désactive les appareils obsolètes
- Envoie une notification email si changements importants
- **Personnalisation** : Possibilité d'ajouter des devices via `/data/stripe_certified_devices.json`
### Monitoring des CRONs
Les logs sont stockés dans `/var/www/geosector/api/logs/` :
- `email_queue.log` : Logs du traitement des emails
- `cleanup_security.log` : Logs du nettoyage sécurité
- `stripe_devices.log` : Logs de mise à jour des devices
## Maintenance et Déploiement
### Logs
@@ -764,11 +1145,36 @@ fetch('/api/endpoint', {
### Déploiement
1. Pull du repository
2. Vérification des permissions
3. Configuration de l'environnement
4. Tests des endpoints
5. Redémarrage des services
Le script `deploy-api.sh` gère le déploiement sur les 3 environnements :
```bash
# Déploiement DEV : code local → container dva-geo sur IN3
./deploy-api.sh
# Déploiement RECETTE : container dva-geo → container rca-geo sur IN3
./deploy-api.sh rca
# Déploiement PRODUCTION : container rca-geo (IN3) → container pra-geo (IN4)
./deploy-api.sh pra
```
Flux de déploiement :
1. **DEV** : Archive du code local, déploiement sur container `dva-geo` sur IN3 (195.154.80.116)
- URL publique : https://dapp.geosector.fr/api/
- IP interne : http://13.23.33.43/api/
2. **RECETTE** : Archive depuis container `dva-geo`, déploiement sur `rca-geo` sur IN3
- URL publique : https://rapp.geosector.fr/api/
3. **PRODUCTION** : Archive depuis `rca-geo` (IN3), déploiement sur `pra-geo` (51.159.7.190)
- URL publique : https://app.geosector.fr/api/
Caractéristiques :
- Sauvegarde automatique avec rotation (garde les 10 dernières)
- Préservation des dossiers `logs/` et `uploads/`
- Gestion des permissions :
- Code API : `nginx:nginx` (755/644)
- Logs et uploads : `nobody:nginx` (755/644)
- Installation des dépendances Composer (pas de mise à jour)
- Journalisation dans `~/.geo_deploy_history`
### Surveillance
@@ -779,6 +1185,89 @@ fetch('/api/endpoint', {
## Changements récents
### Version 3.2.5 (29 Septembre 2025)
#### 1. Système de gestion automatique des devices Tap to Pay
**Nouveaux endpoints ajoutés :**
- `GET /api/stripe/devices/certified-android` : Récupération de la liste complète des appareils certifiés
- `POST /api/stripe/devices/check-tap-to-pay` : Vérification de compatibilité d'un appareil spécifique
- Endpoints publics (pas d'authentification requise) pour vérification côté app
**Script CRON de mise à jour automatique :**
- **Script** : `/scripts/cron/update_stripe_devices.php`
- **Fréquence** : Hebdomadaire (dimanche 3h)
- **Fonction** : Maintient à jour la liste de 95+ appareils Android certifiés
- **Base de données** : Table `stripe_android_certified_devices` avec 77 appareils actifs
**Corrections des requirements iOS :**
- Mise à jour : iOS 16.4+ minimum (au lieu de 15.4/16.0)
- Raison : Support PIN complet obligatoire pour les paiements > 50€
**Documentation ajoutée :**
- `docs/STRIPE-TAP-TO-PAY-REQUIREMENTS.md` : Requirements officiels complets
- Liste exhaustive des appareils certifiés par fabricant
- Configuration SDK pour toutes les plateformes
#### 2. Configuration des tâches CRON sur les containers
**Environnements configurés :**
- **DVA-GEO (DEV)** : 3 CRONs actifs
- **RCA-GEO (RECETTE)** : 3 CRONs actifs (ajoutés le 29/09)
- **PRA-GEO (PROD)** : À configurer
**Tâches automatisées :**
1. Queue d'emails : Toutes les 5 minutes
2. Nettoyage sécurité : Quotidien à 2h
3. Mise à jour devices Stripe : Hebdomadaire dimanche 3h
### Version 3.2.4 (Septembre 2025)
#### 1. Implémentation complète de Stripe Connect V1
**Paiements Stripe intégrés pour les amicales :**
- **Stripe Connect Express** : Onboarding simplifié pour les associations
- **Tap to Pay** : Paiements NFC via l'application mobile Flutter
- **Paiements Web** : Interface de paiement navigateur avec Stripe.js
- **Webhooks** : Gestion automatique des événements Stripe
**Nouvelles tables de base de données :**
- `stripe_accounts` : Gestion des comptes Connect par amicale
- `stripe_payment_history` : Historique des transactions Stripe
- `stripe_refunds` : Gestion des remboursements
- Ajout de `stripe_payment_id` dans `ope_pass` pour liaison bidirectionnelle
**Nouveaux services :**
- **StripeService** : Communication avec l'API Stripe, gestion des PaymentIntents
- **StripeController** : Endpoints API pour création de comptes, paiements et webhooks
**Flow de paiement optimisé (v2) :**
1. Passage créé/modifié EN PREMIER pour obtenir un ID réel
2. Création PaymentIntent avec `passage_id` réel (jamais 0)
3. Traitement Tap to Pay via SDK Stripe Terminal
4. Mise à jour automatique du passage avec `stripe_payment_id`
**Endpoints ajoutés :**
- `POST /api/stripe/accounts/create` : Création compte Connect
- `POST /api/stripe/payments/create-intent` : Création PaymentIntent
- `GET /api/stripe/payments/{id}` : Statut d'un paiement
- `POST /api/stripe/webhooks` : Réception événements Stripe
**Sécurité et validation :**
- Validation stricte des montants (1€ à 999€)
- Vérification correspondance passage/montant
- Gestion des permissions par amicale
- Logs complets des transactions
**Configuration multi-environnements :**
- DEV/RECETTE : Clés de test Stripe
- PRODUCTION : Clés live avec webhooks sécurisés
- Migration base de données via `migrate_stripe_payment_id.sql`
**Documentation technique :**
- `docs/STRIPE-TAP-TO-PAY-FLOW.md` : Flow complet de paiement
- `docs/PLANNING-STRIPE-API.md` : Architecture et planification
### Version 3.0.7 (Août 2025)
#### 1. Implémentation complète de la norme NIST SP 800-63B pour les mots de passe

View File

@@ -0,0 +1,53 @@
-- Table pour stocker les informations des devices des utilisateurs
CREATE TABLE IF NOT EXISTS `user_devices` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`fk_user` int(10) unsigned NOT NULL COMMENT 'Référence vers la table users',
-- Informations générales du device
`platform` varchar(20) NOT NULL COMMENT 'Plateforme: iOS, Android, etc.',
`device_model` varchar(100) DEFAULT NULL COMMENT 'Modèle du device (ex: iPhone13,2)',
`device_name` varchar(255) DEFAULT NULL COMMENT 'Nom personnalisé du device',
`device_manufacturer` varchar(100) DEFAULT NULL COMMENT 'Fabricant (Apple, Samsung, etc.)',
`device_identifier` varchar(100) DEFAULT NULL COMMENT 'Identifiant unique du device',
-- Informations réseau (IPv4 uniquement)
`device_ip_local` varchar(15) DEFAULT NULL COMMENT 'Adresse IP locale IPv4',
`device_ip_public` varchar(15) DEFAULT NULL COMMENT 'Adresse IP publique IPv4',
`device_wifi_name` varchar(255) DEFAULT NULL COMMENT 'Nom du réseau WiFi (SSID)',
`device_wifi_bssid` varchar(17) DEFAULT NULL COMMENT 'BSSID du point d\'accès (format
XX:XX:XX:XX:XX:XX)',
-- Capacités et version OS
`ios_version` varchar(20) DEFAULT NULL COMMENT 'Version iOS/Android OS',
`device_nfc_capable` tinyint(1) DEFAULT NULL COMMENT 'Support NFC (1=oui, 0=non)',
`device_supports_tap_to_pay` tinyint(1) DEFAULT NULL COMMENT 'Support Tap to Pay (1=oui, 0=non)',
-- État batterie
`battery_level` tinyint(3) unsigned DEFAULT NULL COMMENT 'Niveau batterie en pourcentage (0-100)',
`battery_charging` tinyint(1) DEFAULT NULL COMMENT 'En charge (1=oui, 0=non)',
`battery_state` varchar(20) DEFAULT NULL COMMENT 'État batterie (charging, discharging, full)',
-- Versions application
`app_version` varchar(20) DEFAULT NULL COMMENT 'Version de l\'application (ex: 3.2.8)',
`app_build` varchar(20) DEFAULT NULL COMMENT 'Numéro de build (ex: 328)',
-- Timestamps
`last_device_info_check` timestamp NULL DEFAULT NULL COMMENT 'Dernier check des infos device côté
app',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Date de création de
l\'enregistrement',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT
'Date de dernière modification',
PRIMARY KEY (`id`),
KEY `idx_fk_user` (`fk_user`) COMMENT 'Index pour recherche par utilisateur',
KEY `idx_updated_at` (`updated_at`) COMMENT 'Index pour tri par date de mise à jour',
KEY `idx_last_check` (`last_device_info_check`) COMMENT 'Index pour recherche par dernière
vérification',
UNIQUE KEY `unique_user_device` (`fk_user`, `device_identifier`) COMMENT 'Un seul enregistrement
par device/user',
CONSTRAINT `fk_user_devices_user` FOREIGN KEY (`fk_user`)
REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Informations des devices
utilisateurs';

View File

@@ -0,0 +1,166 @@
1. Route /session/refresh/all
Méthode : POSTAuthentification : Requise (via session_id dans headers ou cookies)
Headers requis :
Authorization: Bearer {session_id}
// ou
Cookie: session_id={session_id}
Réponse attendue :
{
"status": "success",
"message": "Session refreshed",
"user": {
// Mêmes données que le login
"id": 123,
"email": "user@example.com",
"name": "John Doe",
"fk_role": 2,
"fk_entite": 1,
// ...
},
"amicale": {
// Données de l'amicale
"id": 1,
"name": "Amicale Pompiers",
// ...
},
"operations": [...],
"sectors": [...],
"passages": [...],
"membres": [...],
"session_id": "current_session_id",
"session_expiry": "2024-01-20T10:00:00Z"
}
Code PHP suggéré :
// routes/session.php
Route::post('/session/refresh/all', function(Request $request) {
$user = Auth::user();
if (!$user) {
return response()->json(['status' => 'error', 'message' => 'Not authenticated'], 401);
}
// Retourner les mêmes données qu'un login normal
return response()->json([
'status' => 'success',
'user' => $user->toArray(),
'amicale' => $user->amicale,
'operations' => Operation::where('fk_entite', $user->fk_entite)->get(),
'sectors' => Sector::where('fk_entite', $user->fk_entite)->get(),
'passages' => Passage::where('fk_entite', $user->fk_entite)->get(),
'membres' => Membre::where('fk_entite', $user->fk_entite)->get(),
'session_id' => session()->getId(),
'session_expiry' => now()->addDays(7)->toIso8601String()
]);
});
2. Route /session/refresh/partial
Méthode : POSTAuthentification : Requise
Body requis :
{
"last_sync": "2024-01-19T10:00:00Z"
}
Réponse attendue :
{
"status": "success",
"message": "Partial refresh completed",
"sectors": [
// Uniquement les secteurs modifiés après last_sync
{
"id": 45,
"name": "Secteur A",
"updated_at": "2024-01-19T15:00:00Z",
// ...
}
],
"passages": [
// Uniquement les passages modifiés après last_sync
{
"id": 789,
"fk_sector": 45,
"updated_at": "2024-01-19T14:30:00Z",
// ...
}
],
"operations": [...], // Si modifiées
"membres": [...] // Si modifiés
}
Code PHP suggéré :
// routes/session.php
Route::post('/session/refresh/partial', function(Request $request) {
$user = Auth::user();
if (!$user) {
return response()->json(['status' => 'error', 'message' => 'Not authenticated'], 401);
}
$lastSync = Carbon::parse($request->input('last_sync'));
// Récupérer uniquement les données modifiées après last_sync
$response = [
'status' => 'success',
'message' => 'Partial refresh completed'
];
// Secteurs modifiés
$sectors = Sector::where('fk_entite', $user->fk_entite)
->where('updated_at', '>', $lastSync)
->get();
if ($sectors->count() > 0) {
$response['sectors'] = $sectors;
}
// Passages modifiés
$passages = Passage::where('fk_entite', $user->fk_entite)
->where('updated_at', '>', $lastSync)
->get();
if ($passages->count() > 0) {
$response['passages'] = $passages;
}
// Opérations modifiées
$operations = Operation::where('fk_entite', $user->fk_entite)
->where('updated_at', '>', $lastSync)
->get();
if ($operations->count() > 0) {
$response['operations'] = $operations;
}
// Membres modifiés
$membres = Membre::where('fk_entite', $user->fk_entite)
->where('updated_at', '>', $lastSync)
->get();
if ($membres->count() > 0) {
$response['membres'] = $membres;
}
return response()->json($response);
});
Points importants pour l'API :
1. Vérification de session : Les deux routes doivent vérifier que le session_id est valide et non expiré
2. Timestamps : Assurez-vous que toutes vos tables ont des colonnes updated_at qui sont mises à jour automatiquement
3. Gestion des suppressions : Pour le refresh partiel, vous pourriez ajouter un champ pour les éléments supprimés :
{
"deleted": {
"sectors": [12, 34], // IDs des secteurs supprimés
"passages": [567, 890]
}
}
4. Optimisation : Pour éviter de surcharger, limitez le refresh partiel aux dernières 24-48h maximum
5. Gestion d'erreurs :
{
"status": "error",
"message": "Session expired",
"code": "SESSION_EXPIRED"
}
L'app Flutter s'attend à ces formats de réponse et utilisera automatiquement le refresh partiel si la dernière sync
date de moins de 24h, sinon elle fera un refresh complet.

View File

@@ -1,167 +0,0 @@
#!/bin/bash
# Vérification des arguments
if [ $# -ne 1 ]; then
echo "Usage: $0 <environment>"
echo " rec : Livrer de DVA (dva-geo) vers RECETTE (rca-geo)"
echo " prod : Livrer de RECETTE (rca-geo) vers PRODUCTION (pra-geo)"
echo ""
echo "Examples:"
echo " $0 rec # DVA → RECETTE"
echo " $0 prod # RECETTE → PRODUCTION"
exit 1
fi
HOST_IP="195.154.80.116"
HOST_USER=root
HOST_KEY=/home/pierre/.ssh/id_rsa_mbpi
HOST_PORT=22
# Mapping des environnements
ENVIRONMENT=$1
case $ENVIRONMENT in
"rca")
SOURCE_CONTAINER="dva-geo"
DEST_CONTAINER="rca-geo"
ENV_NAME="RECETTE"
;;
"pra")
SOURCE_CONTAINER="rca-geo"
DEST_CONTAINER="pra-geo"
ENV_NAME="PRODUCTION"
;;
*)
echo "❌ Environnement '$ENVIRONMENT' non reconnu"
echo "Utilisez 'rec' pour RECETTE ou 'prod' pour PRODUCTION"
exit 1
;;
esac
API_PATH="/var/www/geosector/api"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_DIR="${API_PATH}_backup_${TIMESTAMP}"
PROJECT="default"
echo "🔄 Livraison vers $ENV_NAME : $SOURCE_CONTAINER$DEST_CONTAINER (projet: $PROJECT)"
# Vérifier si les containers existent
echo "🔍 Vérification des containers..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus info $SOURCE_CONTAINER --project $PROJECT" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "❌ Erreur: Le container source $SOURCE_CONTAINER n'existe pas ou n'est pas accessible dans le projet $PROJECT"
exit 1
fi
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus info $DEST_CONTAINER --project $PROJECT" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "❌ Erreur: Le container destination $DEST_CONTAINER n'existe pas ou n'est pas accessible dans le projet $PROJECT"
exit 1
fi
# Créer une sauvegarde du dossier de destination avant de le remplacer
echo "📦 Création d'une sauvegarde sur $DEST_CONTAINER..."
# Vérifier si le dossier API existe
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $API_PATH"
if [ $? -eq 0 ]; then
# Le dossier existe, créer une sauvegarde
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- cp -r $API_PATH $BACKUP_DIR"
echo "✅ Sauvegarde créée dans $BACKUP_DIR"
else
echo "⚠️ Le dossier API n'existe pas sur la destination"
fi
# Copier le dossier API entre les containers
echo "📋 Copie des fichiers en cours..."
# Nettoyage sélectif : supprimer seulement le code, pas les données (logs et uploads)
echo "🧹 Nettoyage sélectif (préservation de logs et uploads)..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH -mindepth 1 -maxdepth 1 ! -name 'uploads' ! -name 'logs' -exec rm -rf {} \;"
# Copier directement du container source vers le container destination (en excluant logs et uploads)
echo "📤 Transfert du code (hors logs et uploads)..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $SOURCE_CONTAINER --project $PROJECT -- tar -cf - -C $API_PATH --exclude='uploads' --exclude='logs' . | incus exec $DEST_CONTAINER --project $PROJECT -- tar -xf - -C $API_PATH"
if [ $? -ne 0 ]; then
echo "❌ Erreur lors du transfert entre containers"
echo "⚠️ Tentative de restauration de la sauvegarde..."
# Vérifier si la sauvegarde existe
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $BACKUP_DIR"
if [ $? -eq 0 ]; then
# La sauvegarde existe, la restaurer
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- rm -rf $API_PATH"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- cp -r $BACKUP_DIR $API_PATH"
echo "✅ Restauration réussie"
else
echo "❌ Échec de la restauration"
fi
exit 1
fi
echo "✅ Code transféré avec succès (logs et uploads préservés)"
# Changer le propriétaire et les permissions des fichiers
echo "👤 Application des droits et permissions pour tous les fichiers..."
# Définir le propriétaire pour tous les fichiers
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chown -R nginx:nginx $API_PATH"
# Appliquer les permissions de base pour les dossiers (755)
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH -type d -exec chmod 755 {} \;"
# Appliquer les permissions pour les fichiers (644)
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH -type f -exec chmod 644 {} \;"
# Appliquer des permissions spécifiques pour le dossier logs (pour permettre à PHP-FPM de l'utilisateur nobody d'y écrire)
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $API_PATH/logs"
if [ $? -eq 0 ]; then
# Changer le groupe du dossier logs à nobody (utilisateur PHP-FPM)
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chown -R nginx:nobody $API_PATH/logs"
# Appliquer les permissions 775 pour le dossier
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chmod -R 775 $API_PATH/logs"
# Appliquer les permissions 664 pour les fichiers
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH/logs -type f -exec chmod 664 {} \;"
echo "✅ Droits spécifiques appliqués au dossier logs (nginx:nobody avec permissions 775/664)"
else
echo "⚠️ Le dossier logs n'existe pas"
fi
# Vérifier et corriger les permissions du dossier uploads s'il existe
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $API_PATH/uploads"
if [ $? -eq 0 ]; then
# S'assurer que uploads a les bonnes permissions
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chown -R nginx:nobody $API_PATH/uploads"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chmod -R 775 $API_PATH/uploads"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH/uploads -type f -exec chmod 664 {} \;"
echo "✅ Droits vérifiés pour le dossier uploads (nginx:nginx avec permissions 775)"
else
# Créer le dossier uploads s'il n'existe pas
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- mkdir -p $API_PATH/uploads"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chown -R nginx:nobody $API_PATH/uploads"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- chmod -R 775 $API_PATH/uploads"
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- find $API_PATH/uploads -type f -exec chmod 664 {} \;"
echo "✅ Dossier uploads créé avec les bonnes permissions (nginx:nginx avec permissions 775/664)"
fi
echo "✅ Propriétaire et permissions appliqués avec succès"
# Mise à jour des dépendances Composer
echo "📦 Mise à jour des dépendances Composer sur $DEST_CONTAINER..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- bash -c 'cd $API_PATH && composer update --no-dev --optimize-autoloader'" > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✅ Dépendances Composer mises à jour avec succès"
else
echo "⚠️ Composer non disponible ou échec, poursuite sans mise à jour des dépendances"
fi
# Vérifier la copie
echo "✅ Vérification de la copie..."
ssh -i $HOST_KEY -p $HOST_PORT $HOST_USER@$HOST_IP "incus exec $DEST_CONTAINER --project $PROJECT -- test -d $API_PATH"
if [ $? -eq 0 ]; then
echo "✅ Copie réussie"
else
echo "❌ Erreur: Le dossier API n'a pas été copié correctement"
fi
echo "✅ Livraison vers $ENV_NAME terminée avec succès!"
echo "📤 Source: $SOURCE_CONTAINER → Destination: $DEST_CONTAINER"
echo "📁 Sauvegarde créée: $BACKUP_DIR sur $DEST_CONTAINER"
echo "🔒 Données préservées: logs/ et uploads/ intouchés"
echo "👤 Permissions: nginx:nginx (755/644) + logs (nginx:nobody 775/664)"

View File

@@ -0,0 +1,442 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour mettre à jour la liste des appareils certifiés Stripe Tap to Pay
*
* Ce script récupère et met à jour la liste des appareils Android certifiés
* pour Tap to Pay en France dans la table stripe_android_certified_devices
*
* À exécuter hebdomadairement via crontab :
* Exemple: 0 3 * * 0 /usr/bin/php /path/to/api/scripts/cron/update_stripe_devices.php
*/
declare(strict_types=1);
// Configuration
define('LOCK_FILE', '/tmp/update_stripe_devices.lock');
define('DEVICES_JSON_URL', 'https://raw.githubusercontent.com/stripe/stripe-terminal-android/master/tap-to-pay/certified-devices.json');
define('DEVICES_LOCAL_FILE', __DIR__ . '/../../data/stripe_certified_devices.json');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
if (time() - $lockTime > 3600) { // Lock de plus d'1 heure = processus bloqué
unlink(LOCK_FILE);
} else {
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
}
}
// Créer le fichier de lock
file_put_contents(LOCK_FILE, getmypid());
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
register_shutdown_function(function() {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$hostname = gethostname();
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app.geosector.fr';
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['REQUEST_URI'] = '/cron/update_stripe_devices';
$_SERVER['REQUEST_METHOD'] = 'CLI';
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Charger l'environnement
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
try {
echo "[" . date('Y-m-d H:i:s') . "] Début de la mise à jour des devices Stripe certifiés\n";
// Initialiser la configuration et la base de données
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
Database::init($dbConfig);
$db = Database::getInstance();
// Logger le début
LogService::log("Début de la mise à jour des devices Stripe certifiés", [
'source' => 'cron',
'script' => 'update_stripe_devices.php'
]);
// Étape 1: Récupérer la liste des devices
$devicesData = fetchCertifiedDevices();
if (empty($devicesData)) {
echo "[" . date('Y-m-d H:i:s') . "] Aucune donnée de devices récupérée\n";
LogService::log("Aucune donnée de devices récupérée", ['level' => 'warning']);
exit(1);
}
// Étape 2: Traiter et mettre à jour la base de données
$stats = updateDatabase($db, $devicesData);
// Étape 3: Logger les résultats
$message = sprintf(
"Mise à jour terminée : %d ajoutés, %d modifiés, %d désactivés, %d inchangés",
$stats['added'],
$stats['updated'],
$stats['disabled'],
$stats['unchanged']
);
echo "[" . date('Y-m-d H:i:s') . "] $message\n";
LogService::log($message, [
'source' => 'cron',
'stats' => $stats
]);
// Étape 4: Envoyer une notification si changements significatifs
if ($stats['added'] > 0 || $stats['disabled'] > 0) {
sendNotification($stats);
}
echo "[" . date('Y-m-d H:i:s') . "] Mise à jour terminée avec succès\n";
} catch (Exception $e) {
$errorMsg = "Erreur lors de la mise à jour des devices: " . $e->getMessage();
echo "[" . date('Y-m-d H:i:s') . "] $errorMsg\n";
LogService::log($errorMsg, [
'level' => 'error',
'trace' => $e->getTraceAsString()
]);
exit(1);
}
/**
* Récupère la liste des devices certifiés
* Essaie d'abord depuis une URL externe, puis depuis un fichier local en fallback
*/
function fetchCertifiedDevices(): array {
// Liste maintenue manuellement des devices certifiés en France
// Source: Documentation Stripe Terminal et tests confirmés
$frenchCertifiedDevices = [
// Samsung Galaxy S Series
['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 S21 FE', 'model_identifier' => 'SM-G990B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22+', 'model_identifier' => 'SM-S906B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22 Ultra', 'model_identifier' => 'SM-S908B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23+', 'model_identifier' => 'SM-S916B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 Ultra', 'model_identifier' => 'SM-S918B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 FE', 'model_identifier' => 'SM-S711B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24+', 'model_identifier' => 'SM-S926B', 'min_android_version' => 14],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24 Ultra', 'model_identifier' => 'SM-S928B', 'min_android_version' => 14],
// Samsung Galaxy Note
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20', 'model_identifier' => 'SM-N980F', 'min_android_version' => 10],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20 Ultra', 'model_identifier' => 'SM-N986B', 'min_android_version' => 10],
// Samsung Galaxy Z Fold
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold3', 'model_identifier' => 'SM-F926B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold4', 'model_identifier' => 'SM-F936B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold5', 'model_identifier' => 'SM-F946B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold6', 'model_identifier' => 'SM-F956B', 'min_android_version' => 14],
// Samsung Galaxy Z Flip
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip3', 'model_identifier' => 'SM-F711B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip4', 'model_identifier' => 'SM-F721B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip5', 'model_identifier' => 'SM-F731B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip6', 'model_identifier' => 'SM-F741B', 'min_android_version' => 14],
// Samsung Galaxy A Series (haut de gamme)
['manufacturer' => 'Samsung', 'model' => 'Galaxy A54', 'model_identifier' => 'SM-A546B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy A73', 'model_identifier' => 'SM-A736B', 'min_android_version' => 12],
// 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 6a', 'model_identifier' => 'bluejay', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 7 Pro', 'model_identifier' => 'cheetah', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 7a', 'model_identifier' => 'lynx', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 8 Pro', 'model_identifier' => 'husky', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 8a', 'model_identifier' => 'akita', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9', 'model_identifier' => 'tokay', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro', 'model_identifier' => 'caiman', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro XL', 'model_identifier' => 'komodo', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel Fold', 'model_identifier' => 'felix', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel Tablet', 'model_identifier' => 'tangorpro', 'min_android_version' => 13],
// OnePlus
['manufacturer' => 'OnePlus', 'model' => '9', 'model_identifier' => 'LE2113', 'min_android_version' => 11],
['manufacturer' => 'OnePlus', 'model' => '9 Pro', 'model_identifier' => 'LE2123', 'min_android_version' => 11],
['manufacturer' => 'OnePlus', 'model' => '10 Pro', 'model_identifier' => 'NE2213', 'min_android_version' => 12],
['manufacturer' => 'OnePlus', 'model' => '10T', 'model_identifier' => 'CPH2413', 'min_android_version' => 12],
['manufacturer' => 'OnePlus', 'model' => '11', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
['manufacturer' => 'OnePlus', 'model' => '11R', 'model_identifier' => 'CPH2487', 'min_android_version' => 13],
['manufacturer' => 'OnePlus', 'model' => '12', 'model_identifier' => 'CPH2581', 'min_android_version' => 14],
['manufacturer' => 'OnePlus', 'model' => '12R', 'model_identifier' => 'CPH2585', 'min_android_version' => 14],
['manufacturer' => 'OnePlus', 'model' => 'Open', 'model_identifier' => 'CPH2551', 'min_android_version' => 13],
// Xiaomi
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11', 'model_identifier' => 'M2011K2G', 'min_android_version' => 11],
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11 Ultra', 'model_identifier' => 'M2102K1G', 'min_android_version' => 11],
['manufacturer' => 'Xiaomi', 'model' => '12', 'model_identifier' => '2201123G', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '12 Pro', 'model_identifier' => '2201122G', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '12T Pro', 'model_identifier' => '2207122MC', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '13', 'model_identifier' => '2211133G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '13 Pro', 'model_identifier' => '2210132G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '13T Pro', 'model_identifier' => '23078PND5G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '14', 'model_identifier' => '23127PN0CG', 'min_android_version' => 14],
['manufacturer' => 'Xiaomi', 'model' => '14 Pro', 'model_identifier' => '23116PN5BG', 'min_android_version' => 14],
['manufacturer' => 'Xiaomi', 'model' => '14 Ultra', 'model_identifier' => '24030PN60G', 'min_android_version' => 14],
// OPPO
['manufacturer' => 'OPPO', 'model' => 'Find X3 Pro', 'model_identifier' => 'CPH2173', 'min_android_version' => 11],
['manufacturer' => 'OPPO', 'model' => 'Find X5 Pro', 'model_identifier' => 'CPH2305', 'min_android_version' => 12],
['manufacturer' => 'OPPO', 'model' => 'Find X6 Pro', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
['manufacturer' => 'OPPO', 'model' => 'Find N2', 'model_identifier' => 'CPH2399', 'min_android_version' => 13],
['manufacturer' => 'OPPO', 'model' => 'Find N3', 'model_identifier' => 'CPH2499', 'min_android_version' => 13],
// Realme
['manufacturer' => 'Realme', 'model' => 'GT 2 Pro', 'model_identifier' => 'RMX3301', 'min_android_version' => 12],
['manufacturer' => 'Realme', 'model' => 'GT 3', 'model_identifier' => 'RMX3709', 'min_android_version' => 13],
['manufacturer' => 'Realme', 'model' => 'GT 5 Pro', 'model_identifier' => 'RMX3888', 'min_android_version' => 14],
// Honor
['manufacturer' => 'Honor', 'model' => 'Magic5 Pro', 'model_identifier' => 'PGT-N19', 'min_android_version' => 13],
['manufacturer' => 'Honor', 'model' => 'Magic6 Pro', 'model_identifier' => 'BVL-N49', 'min_android_version' => 14],
['manufacturer' => 'Honor', 'model' => '90', 'model_identifier' => 'REA-NX9', 'min_android_version' => 13],
// ASUS
['manufacturer' => 'ASUS', 'model' => 'Zenfone 9', 'model_identifier' => 'AI2202', 'min_android_version' => 12],
['manufacturer' => 'ASUS', 'model' => 'Zenfone 10', 'model_identifier' => 'AI2302', 'min_android_version' => 13],
['manufacturer' => 'ASUS', 'model' => 'ROG Phone 7', 'model_identifier' => 'AI2205', 'min_android_version' => 13],
// Nothing
['manufacturer' => 'Nothing', 'model' => 'Phone (1)', 'model_identifier' => 'A063', 'min_android_version' => 12],
['manufacturer' => 'Nothing', 'model' => 'Phone (2)', 'model_identifier' => 'A065', 'min_android_version' => 13],
['manufacturer' => 'Nothing', 'model' => 'Phone (2a)', 'model_identifier' => 'A142', 'min_android_version' => 14],
];
// Essayer de charger depuis un fichier JSON local si présent
if (file_exists(DEVICES_LOCAL_FILE)) {
$localData = json_decode(file_get_contents(DEVICES_LOCAL_FILE), true);
if (!empty($localData)) {
echo "[" . date('Y-m-d H:i:s') . "] Données chargées depuis le fichier local\n";
return array_merge($frenchCertifiedDevices, $localData);
}
}
echo "[" . date('Y-m-d H:i:s') . "] Utilisation de la liste intégrée des devices certifiés\n";
return $frenchCertifiedDevices;
}
/**
* Met à jour la base de données avec les nouvelles données
*/
function updateDatabase($db, array $devices): array {
$stats = [
'added' => 0,
'updated' => 0,
'disabled' => 0,
'unchanged' => 0,
'total' => 0
];
// Récupérer tous les devices existants
$stmt = $db->prepare("SELECT * FROM stripe_android_certified_devices WHERE country = 'FR'");
$stmt->execute();
$existingDevices = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$key = $row['manufacturer'] . '|' . $row['model'] . '|' . $row['model_identifier'];
$existingDevices[$key] = $row;
}
// Marquer tous les devices pour tracking
$processedKeys = [];
// Traiter chaque device de la nouvelle liste
foreach ($devices as $device) {
$key = $device['manufacturer'] . '|' . $device['model'] . '|' . $device['model_identifier'];
$processedKeys[$key] = true;
if (isset($existingDevices[$key])) {
// Le device existe, vérifier s'il faut le mettre à jour
$existing = $existingDevices[$key];
// Vérifier si des champs ont changé
$needsUpdate = false;
if ($existing['min_android_version'] != $device['min_android_version']) {
$needsUpdate = true;
}
if ($existing['tap_to_pay_certified'] != 1) {
$needsUpdate = true;
}
if ($needsUpdate) {
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET min_android_version = :min_version,
tap_to_pay_certified = 1,
last_verified = NOW(),
updated_at = NOW()
WHERE manufacturer = :manufacturer
AND model = :model
AND model_identifier = :model_identifier
AND country = 'FR'
");
$stmt->execute([
'min_version' => $device['min_android_version'],
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier']
]);
$stats['updated']++;
LogService::log("Device mis à jour", [
'device' => $device['manufacturer'] . ' ' . $device['model']
]);
} else {
// Juste mettre à jour last_verified
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET last_verified = NOW()
WHERE manufacturer = :manufacturer
AND model = :model
AND model_identifier = :model_identifier
AND country = 'FR'
");
$stmt->execute([
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier']
]);
$stats['unchanged']++;
}
} else {
// Nouveau device, l'ajouter
$stmt = $db->prepare("
INSERT INTO stripe_android_certified_devices
(manufacturer, model, model_identifier, tap_to_pay_certified,
certification_date, min_android_version, country, notes, last_verified)
VALUES
(:manufacturer, :model, :model_identifier, 1,
NOW(), :min_version, 'FR', 'Ajouté automatiquement via CRON', NOW())
");
$stmt->execute([
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier'],
'min_version' => $device['min_android_version']
]);
$stats['added']++;
LogService::log("Nouveau device ajouté", [
'device' => $device['manufacturer'] . ' ' . $device['model']
]);
}
}
// Désactiver les devices qui ne sont plus dans la liste
foreach ($existingDevices as $key => $existing) {
if (!isset($processedKeys[$key]) && $existing['tap_to_pay_certified'] == 1) {
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET tap_to_pay_certified = 0,
notes = CONCAT(IFNULL(notes, ''), ' | Désactivé le ', NOW(), ' (non présent dans la mise à jour)'),
updated_at = NOW()
WHERE id = :id
");
$stmt->execute(['id' => $existing['id']]);
$stats['disabled']++;
LogService::log("Device désactivé", [
'device' => $existing['manufacturer'] . ' ' . $existing['model'],
'reason' => 'Non présent dans la liste mise à jour'
]);
}
}
$stats['total'] = count($devices);
return $stats;
}
/**
* Envoie une notification email aux administrateurs si changements importants
*/
function sendNotification(array $stats): void {
try {
// Récupérer la configuration
$appConfig = AppConfig::getInstance();
$emailConfig = $appConfig->getEmailConfig();
if (empty($emailConfig['admin_email'])) {
return; // Pas d'email admin configuré
}
$db = Database::getInstance();
// Préparer le contenu de l'email
$subject = "Mise à jour des devices Stripe Tap to Pay";
$body = "Bonjour,\n\n";
$body .= "La mise à jour automatique de la liste des appareils certifiés Stripe Tap to Pay a été effectuée.\n\n";
$body .= "Résumé des changements :\n";
$body .= "- Nouveaux appareils ajoutés : " . $stats['added'] . "\n";
$body .= "- Appareils mis à jour : " . $stats['updated'] . "\n";
$body .= "- Appareils désactivés : " . $stats['disabled'] . "\n";
$body .= "- Appareils inchangés : " . $stats['unchanged'] . "\n";
$body .= "- Total d'appareils traités : " . $stats['total'] . "\n\n";
if ($stats['added'] > 0) {
$body .= "Les nouveaux appareils ont été automatiquement ajoutés à la base de données.\n";
}
if ($stats['disabled'] > 0) {
$body .= "Certains appareils ont été désactivés car ils ne sont plus certifiés.\n";
}
$body .= "\nConsultez les logs pour plus de détails.\n";
$body .= "\nCordialement,\nLe système GeoSector";
// Insérer dans la queue d'emails
$stmt = $db->prepare("
INSERT INTO email_queue
(to_email, subject, body, status, created_at, attempts)
VALUES
(:to_email, :subject, :body, 'pending', NOW(), 0)
");
$stmt->execute([
'to_email' => $emailConfig['admin_email'],
'subject' => $subject,
'body' => $body
]);
echo "[" . date('Y-m-d H:i:s') . "] Notification ajoutée à la queue d'emails\n";
} catch (Exception $e) {
// Ne pas faire échouer le script si l'email ne peut pas être envoyé
echo "[" . date('Y-m-d H:i:s') . "] Impossible d'envoyer la notification: " . $e->getMessage() . "\n";
}
}

View File

@@ -0,0 +1,248 @@
#!/bin/bash
# Script de migration des bases de données vers les containers MariaDB
# Date: Janvier 2025
# Auteur: Pierre (avec l'aide de Claude)
#
# Ce script migre les bases de données depuis les containers applicatifs
# vers les containers MariaDB dédiés (maria3 sur IN3, maria4 sur IN4)
set -euo pipefail
# Configuration SSH
HOST_KEY="/home/pierre/.ssh/id_rsa_mbpi"
HOST_PORT="22"
HOST_USER="root"
# Serveurs
RCA_HOST="195.154.80.116" # IN3
PRA_HOST="51.159.7.190" # IN4
# Configuration MariaDB
MARIA_ROOT_PASS="MyAlpLocal,90b" # Mot de passe root pour maria3 et maria4
# Couleurs
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo_step() {
echo -e "${GREEN}==>${NC} $1"
}
echo_info() {
echo -e "${BLUE}Info:${NC} $1"
}
echo_warning() {
echo -e "${YELLOW}Warning:${NC} $1"
}
echo_error() {
echo -e "${RED}Error:${NC} $1"
exit 1
}
# Fonction pour créer une base de données et un utilisateur
create_database_and_user() {
local HOST=$1
local CONTAINER=$2
local DB_NAME=$3
local DB_USER=$4
local DB_PASS=$5
local SOURCE_CONTAINER=$6
echo_step "Creating database ${DB_NAME} in ${CONTAINER} on ${HOST}..."
# Commandes SQL pour créer la base et l'utilisateur
SQL_COMMANDS="
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'%' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'%';
FLUSH PRIVILEGES;
"
if [ "$HOST" = "local" ]; then
# Pour local (non utilisé actuellement)
incus exec ${CONTAINER} -- mysql -u root -p${MARIA_ROOT_PASS} -e "${SQL_COMMANDS}"
else
# Pour serveur distant
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${HOST} \
"incus exec ${CONTAINER} -- mysql -u root -p${MARIA_ROOT_PASS} -e \"${SQL_COMMANDS}\""
fi
echo_info "Database ${DB_NAME} and user ${DB_USER} created"
}
# Fonction pour migrer les données
migrate_data() {
local HOST=$1
local SOURCE_CONTAINER=$2
local TARGET_CONTAINER=$3
local SOURCE_DB=$4
local TARGET_DB=$5
local TARGET_USER=$6
local TARGET_PASS=$7
echo_step "Migrating data from ${SOURCE_CONTAINER}/${SOURCE_DB} to ${TARGET_CONTAINER}/${TARGET_DB}..."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="/tmp/${SOURCE_DB}_dump_${TIMESTAMP}.sql"
# Créer le dump depuis le container source
echo_info "Creating database dump..."
# Déterminer si le container source utilise root avec mot de passe
# Les containers d'app (dva-geo, rca-geo, pra-geo) n'ont probablement pas de mot de passe root
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${HOST} \
"incus exec ${SOURCE_CONTAINER} -- mysqldump --single-transaction --routines --triggers ${SOURCE_DB} > ${DUMP_FILE} 2>/dev/null || \
incus exec ${SOURCE_CONTAINER} -- mysqldump -u root -p${MARIA_ROOT_PASS} --single-transaction --routines --triggers ${SOURCE_DB} > ${DUMP_FILE}"
# Importer dans le container cible
echo_info "Importing data into ${TARGET_CONTAINER}..."
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${HOST} \
"cat ${DUMP_FILE} | incus exec ${TARGET_CONTAINER} -- mysql -u ${TARGET_USER} -p${TARGET_PASS} ${TARGET_DB}"
# Nettoyer
ssh -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${HOST} "rm -f ${DUMP_FILE}"
echo_info "Migration completed for ${TARGET_DB}"
}
# Fonction pour migrer les données entre serveurs différents (pour PRODUCTION)
migrate_data_cross_server() {
local SOURCE_HOST=$1
local SOURCE_CONTAINER=$2
local TARGET_HOST=$3
local TARGET_CONTAINER=$4
local SOURCE_DB=$5
local TARGET_DB=$6
local TARGET_USER=$7
local TARGET_PASS=$8
echo_step "Migrating data from ${SOURCE_HOST}/${SOURCE_CONTAINER}/${SOURCE_DB} to ${TARGET_HOST}/${TARGET_CONTAINER}/${TARGET_DB}..."
echo_info "Using WireGuard VPN tunnel (IN3 → IN4)..."
# Option 1: Streaming direct via VPN avec agent forwarding
echo_info "Streaming database directly through VPN tunnel..."
echo_warning "Note: This requires SSH agent forwarding (ssh -A) when connecting to IN3"
# Utiliser -A pour activer l'agent forwarding vers IN3
# Utilise l'alias 'in4' défini dans /root/.ssh/config sur IN3
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "
# Dump depuis maria3 avec mot de passe root et pipe direct vers IN4 via VPN
incus exec ${SOURCE_CONTAINER} -- mysqldump -u root -p${MARIA_ROOT_PASS} --single-transaction --routines --triggers ${SOURCE_DB} | \
ssh in4 'incus exec ${TARGET_CONTAINER} -- mysql -u ${TARGET_USER} -p${TARGET_PASS} ${TARGET_DB}'
"
if [ $? -eq 0 ]; then
echo_info "Direct VPN streaming migration completed successfully!"
else
echo_warning "VPN streaming failed, falling back to file transfer method..."
# Option 2: Fallback avec fichiers temporaires si le streaming échoue
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="/tmp/${SOURCE_DB}_dump_${TIMESTAMP}.sql"
# Créer le dump sur IN3
echo_info "Creating database dump..."
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} \
"incus exec ${SOURCE_CONTAINER} -- mysqldump -u root -p${MARIA_ROOT_PASS} --single-transaction --routines --triggers ${SOURCE_DB} > ${DUMP_FILE}"
# Transférer via VPN depuis IN3 vers IN4 (utilise l'alias 'in4')
echo_info "Transferring dump file through VPN..."
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} \
"scp ${DUMP_FILE} in4:${DUMP_FILE}"
# Importer sur IN4 (utilise l'alias 'in4')
echo_info "Importing data on IN4..."
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} \
"ssh in4 'cat ${DUMP_FILE} | incus exec ${TARGET_CONTAINER} -- mysql -u ${TARGET_USER} -p${TARGET_PASS} ${TARGET_DB}'"
# Nettoyer
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} "rm -f ${DUMP_FILE}"
ssh -A -i ${HOST_KEY} -p ${HOST_PORT} ${HOST_USER}@${SOURCE_HOST} \
"ssh in4 'rm -f ${DUMP_FILE}'"
fi
echo_info "Cross-server migration completed for ${TARGET_DB}"
}
# Menu de sélection
echo_step "Database Migration to MariaDB Containers"
echo ""
echo "Select environment to migrate:"
echo "1) DEV - dva-geo → maria3/dva_geo"
echo "2) RCA - rca-geo → maria3/rca_geo"
echo "3) PROD - rca_geo (IN3/maria3) → maria4/pra_geo (copy from RECETTE)"
echo "4) ALL - Migrate all environments"
echo ""
read -p "Your choice [1-4]: " choice
case $choice in
1)
echo_step "Migrating DEV environment..."
create_database_and_user "${RCA_HOST}" "maria3" "dva_geo" "dva_geo_user" "CBq9tKHj6PGPZuTmAHV7" "dva-geo"
migrate_data "${RCA_HOST}" "dva-geo" "maria3" "geo_app" "dva_geo" "dva_geo_user" "CBq9tKHj6PGPZuTmAHV7"
echo_step "DEV migration completed!"
;;
2)
echo_step "Migrating RECETTE environment..."
create_database_and_user "${RCA_HOST}" "maria3" "rca_geo" "rca_geo_user" "UPf3C0cQ805LypyM71iW" "rca-geo"
migrate_data "${RCA_HOST}" "rca-geo" "maria3" "geo_app" "rca_geo" "rca_geo_user" "UPf3C0cQ805LypyM71iW"
echo_step "RECETTE migration completed!"
;;
3)
echo_step "Migrating PRODUCTION environment (copying from RECETTE)..."
echo_warning "Note: PRODUCTION will be duplicated from rca_geo on IN3/maria3"
# Créer la base et l'utilisateur sur IN4/maria4
create_database_and_user "${PRA_HOST}" "maria4" "pra_geo" "pra_geo_user" "d2jAAGGWi8fxFrWgXjOA" "pra-geo"
# Copier les données depuis rca_geo (IN3/maria3) vers pra_geo (IN4/maria4)
migrate_data_cross_server "${RCA_HOST}" "maria3" "${PRA_HOST}" "maria4" "rca_geo" "pra_geo" "pra_geo_user" "d2jAAGGWi8fxFrWgXjOA"
echo_step "PRODUCTION migration completed (duplicated from RECETTE)!"
;;
4)
echo_step "Migrating ALL environments..."
echo_info "Starting DEV migration..."
create_database_and_user "${RCA_HOST}" "maria3" "dva_geo" "dva_geo_user" "CBq9tKHj6PGPZuTmAHV7" "dva-geo"
migrate_data "${RCA_HOST}" "dva-geo" "maria3" "geo_app" "dva_geo" "dva_geo_user" "CBq9tKHj6PGPZuTmAHV7"
echo_info "Starting RECETTE migration..."
create_database_and_user "${RCA_HOST}" "maria3" "rca_geo" "rca_geo_user" "UPf3C0cQ805LypyM71iW" "rca-geo"
migrate_data "${RCA_HOST}" "rca-geo" "maria3" "geo_app" "rca_geo" "rca_geo_user" "UPf3C0cQ805LypyM71iW"
echo_info "Starting PRODUCTION migration (copying from RECETTE)..."
echo_warning "Note: PRODUCTION will be duplicated from rca_geo on IN3/maria3"
create_database_and_user "${PRA_HOST}" "maria4" "pra_geo" "pra_geo_user" "d2jAAGGWi8fxFrWgXjOA" "pra-geo"
migrate_data_cross_server "${RCA_HOST}" "maria3" "${PRA_HOST}" "maria4" "rca_geo" "pra_geo" "pra_geo_user" "d2jAAGGWi8fxFrWgXjOA"
echo_step "All migrations completed!"
;;
*)
echo_error "Invalid choice"
;;
esac
echo ""
echo_step "Migration Summary:"
echo ""
echo "┌─────────────┬──────────────┬──────────────┬─────────────┬──────────────────────┐"
echo "│ Environment │ Source │ Target │ Database │ User │"
echo "├─────────────┼──────────────┼──────────────┼─────────────┼──────────────────────┤"
echo "│ DEV │ dva-geo │ maria3 (IN3) │ dva_geo │ dva_geo_user │"
echo "│ RECETTE │ rca-geo │ maria3 (IN3) │ rca_geo │ rca_geo_user │"
echo "│ PRODUCTION │ pra-geo │ maria4 (IN4) │ pra_geo │ pra_geo_user │"
echo "└─────────────┴──────────────┴──────────────┴─────────────┴──────────────────────┘"
echo ""
echo_warning "Remember to:"
echo " 1. Test database connectivity from application containers"
echo " 2. Deploy the updated AppConfig.php"
echo " 3. Monitor application logs after migration"
echo " 4. Keep old databases for rollback if needed"

View File

@@ -0,0 +1,94 @@
-- =====================================================
-- Migration Stripe : is_striped → stripe_payment_id
-- Date : Janvier 2025
-- Description : Refactoring pour simplifier la gestion des paiements Stripe
-- =====================================================
-- 1. Modifier la table ope_pass
-- ------------------------------
ALTER TABLE `ope_pass` DROP COLUMN IF EXISTS `chk_striped`;
ALTER TABLE `ope_pass` ADD COLUMN `stripe_payment_id` VARCHAR(50) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe (pi_xxx)';
ALTER TABLE `ope_pass` ADD INDEX `idx_stripe_payment` (`stripe_payment_id`);
-- 2. Modifier stripe_payment_history pour la rendre indépendante
-- ----------------------------------------------------------------
-- Supprimer la clé étrangère vers stripe_payment_intents
ALTER TABLE `stripe_payment_history`
DROP FOREIGN KEY IF EXISTS `stripe_payment_history_ibfk_1`;
-- Modifier la colonne pour stocker directement l'ID Stripe (totalement indépendante)
ALTER TABLE `stripe_payment_history`
DROP INDEX IF EXISTS `idx_fk_payment_intent`,
CHANGE COLUMN `fk_payment_intent` `stripe_payment_intent_id` VARCHAR(255) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe',
ADD INDEX `idx_stripe_payment_intent_id` (`stripe_payment_intent_id`);
-- 3. Modifier stripe_refunds pour la rendre indépendante
-- --------------------------------------------------------
ALTER TABLE `stripe_refunds`
DROP FOREIGN KEY IF EXISTS `stripe_refunds_ibfk_1`;
-- Modifier la colonne pour stocker directement l'ID Stripe (totalement indépendante)
ALTER TABLE `stripe_refunds`
DROP INDEX IF EXISTS `idx_fk_payment_intent`,
CHANGE COLUMN `fk_payment_intent` `stripe_payment_intent_id` VARCHAR(255) NOT NULL COMMENT 'ID du PaymentIntent Stripe',
ADD INDEX `idx_stripe_payment_intent_id` (`stripe_payment_intent_id`);
-- 4. Supprimer la vue qui dépend de stripe_payment_intents
-- ----------------------------------------------------------
DROP VIEW IF EXISTS `v_stripe_payment_stats`;
-- 5. Supprimer la table stripe_payment_intents
-- ---------------------------------------------
DROP TABLE IF EXISTS `stripe_payment_intents`;
-- 6. Créer une nouvelle vue basée sur ope_pass
-- ----------------------------------------------
CREATE OR REPLACE VIEW `v_stripe_payment_stats` AS
SELECT
o.fk_entite,
e.encrypted_name as entite_name,
p.fk_user,
CONCAT(u.first_name, ' ', u.sect_name) as user_name,
COUNT(DISTINCT p.id) as total_ventes,
COUNT(DISTINCT CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.id END) as ventes_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.montant ELSE 0 END) as montant_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NULL THEN p.montant ELSE 0 END) as montant_autres,
DATE(p.created_at) as date_vente
FROM ope_pass p
LEFT 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.fk_type = 2 -- Type vente calendrier
GROUP BY o.fk_entite, p.fk_user, DATE(p.created_at);
-- 7. Vue pour les statistiques par entité uniquement
-- ----------------------------------------------------
CREATE OR REPLACE VIEW `v_stripe_entite_stats` AS
SELECT
e.id as entite_id,
e.encrypted_name as entite_name,
sa.stripe_account_id,
sa.charges_enabled,
sa.payouts_enabled,
COUNT(DISTINCT p.id) as total_passages,
COUNT(DISTINCT CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.id END) as passages_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.montant ELSE 0 END) as revenue_stripe,
SUM(p.montant) as revenue_total
FROM entites e
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
LEFT JOIN operations o ON e.id = o.fk_entite
LEFT JOIN ope_pass p ON o.id = p.fk_operation
GROUP BY e.id, e.encrypted_name, sa.stripe_account_id;
-- 8. Fonction helper pour vérifier si un passage a un paiement Stripe
-- ---------------------------------------------------------------------
-- NOTE: Si vous exécutez en copier/coller, cette fonction est optionnelle
-- Vous pouvez l'ignorer ou l'exécuter séparément avec DELIMITER
-- =====================================================
-- FIN DE LA MIGRATION
-- =====================================================
-- Tables supprimées : stripe_payment_intents
-- Tables modifiées : ope_pass, stripe_payment_history, stripe_refunds
-- Tables conservées : stripe_accounts, stripe_terminal_readers, etc.
-- =====================================================

95
api/scripts/test_whitelist.php Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour la whitelist dynamique
* Usage: php scripts/test_whitelist.php [refresh]
*/
require_once __DIR__ . '/../src/Services/Security/IPBlocker.php';
use App\Services\Security\IPBlocker;
echo "=== Test de la whitelist dynamique depuis IN3 ===\n\n";
// Si l'argument "refresh" est passé, forcer le rafraîchissement
if (isset($argv[1]) && $argv[1] === 'refresh') {
echo "Forçage du rafraîchissement de la whitelist...\n";
$ips = IPBlocker::refreshDynamicWhitelist();
echo "✓ Whitelist rafraîchie\n\n";
}
// Test 1: Récupérer les IPs whitelistées
echo "1. IPs whitelistées statiques:\n";
$staticWhitelist = IPBlocker::WHITELIST;
foreach ($staticWhitelist as $ip) {
echo " - $ip\n";
}
echo "\n2. Récupération de la whitelist dynamique depuis IN3...\n";
try {
// Forcer le rafraîchissement pour le test
$dynamicIps = IPBlocker::refreshDynamicWhitelist();
if (empty($dynamicIps)) {
echo " ⚠ Aucune IP dynamique récupérée\n";
echo " Vérifiez:\n";
echo " - La connexion SSH vers IN3 (195.154.80.116)\n";
echo " - L'existence du fichier /var/bat/IP sur IN3\n";
echo " - Les clés SSH sont configurées pour root@IN3\n";
} else {
echo " ✓ IPs dynamiques récupérées:\n";
foreach ($dynamicIps as $ip) {
echo " - $ip\n";
}
}
} catch (Exception $e) {
echo " ✗ Erreur: " . $e->getMessage() . "\n";
}
// Test 2: Vérifier le fichier de cache
echo "\n3. Vérification du cache local:\n";
$cacheFile = __DIR__ . '/../config/whitelist_ip_cache.txt';
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData) {
echo " ✓ Cache trouvé:\n";
echo " - IP: " . ($cacheData['ip'] ?? 'N/A') . "\n";
echo " - Récupéré le: " . ($cacheData['retrieved_at'] ?? 'N/A') . "\n";
echo " - Timestamp: " . ($cacheData['timestamp'] ?? 'N/A') . "\n";
$age = time() - ($cacheData['timestamp'] ?? 0);
$ageMinutes = round($age / 60);
echo " - Âge du cache: $ageMinutes minutes\n";
if ($age > 3600) {
echo " ⚠ Le cache a plus d'1 heure et sera rafraîchi au prochain appel\n";
}
} else {
echo " ⚠ Cache invalide\n";
}
} else {
echo " - Pas de cache local trouvé\n";
}
// Test 3: Tester quelques IPs
echo "\n4. Test de blocage pour quelques IPs:\n";
$testIps = [
'127.0.0.1' => 'Localhost (whitelist statique)',
'8.8.8.8' => 'Google DNS (non whitelisté)',
];
// Ajouter l'IP dynamique si elle existe
if (!empty($dynamicIps) && isset($dynamicIps[0])) {
$testIps[$dynamicIps[0]] = 'IP depuis IN3 (whitelist dynamique)';
}
foreach ($testIps as $ip => $description) {
$isWhitelisted = IPBlocker::isWhitelisted($ip);
$isBlocked = IPBlocker::isBlocked($ip);
echo " - $ip ($description):\n";
echo " Whitelisté: " . ($isWhitelisted ? '✓ Oui' : '✗ Non') . "\n";
echo " Bloqué: " . ($isBlocked ? '✗ Oui' : '✓ Non') . "\n";
}
echo "\n=== Fin du test ===\n";

View File

@@ -92,13 +92,13 @@ class AppConfig {
$this->config['app.geosector.fr'] = array_merge($baseConfig, [
'env' => 'production',
'database' => [
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_prod',
'password' => 'QO:96-SrHJ6k7-df*?k{4W6m',
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'pra_geo',
'username' => 'pra_geo_user',
'password' => 'd2jAAGGWi8fxFrWgXjOA',
],
'addresses_database' => [
'host' => '13.23.33.26',
'host' => '13.23.33.4', // Container maria4 sur IN4
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeo.User',
@@ -109,13 +109,20 @@ class AppConfig {
$this->config['rapp.geosector.fr'] = array_merge($baseConfig, [
'env' => 'recette',
'database' => [
// Configuration future avec maria3 (à activer après migration)
// 'host' => '13.23.33.4', // Container maria3 sur IN3
// 'name' => 'rca_geo',
// 'username' => 'rca_geo_user',
// 'password' => 'UPf3C0cQ805LypyM71iW',
// Configuration actuelle - base locale dans rca-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_rec',
'password' => 'QO:96df*?k-dS3KiO-{4W6m',
'password' => 'UPf3C0cQ805LypyM71iW', // À ajuster si nécessaire
],
'addresses_database' => [
'host' => '13.23.33.36',
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoRec.User',
@@ -124,16 +131,23 @@ class AppConfig {
]);
// Configuration DÉVELOPPEMENT
$this->config['app.geo.dev'] = array_merge($baseConfig, [
$this->config['dapp.geosector.fr'] = array_merge($baseConfig, [
'env' => 'development',
'database' => [
'host' => '13.23.33.46',
// Configuration future avec maria3 (à activer après migration)
// 'host' => '13.23.33.4', // Container maria3 sur IN3
// 'name' => 'dva_geo',
// 'username' => 'dva_geo_user',
// 'password' => 'CBq9tKHj6PGPZuTmAHV7',
// Configuration actuelle - base locale dans dva-geo
'host' => 'localhost',
'name' => 'geo_app',
'username' => 'geo_app_user_dev',
'password' => '34GOz-X5gJu-oH@Fa3$#Z',
'password' => 'CBq9tKHj6PGPZuTmAHV7', // À ajuster si nécessaire
],
'addresses_database' => [
'host' => '13.23.33.46',
'host' => '13.23.33.4', // Container maria3 sur IN3
'name' => 'adresses',
'username' => 'adr_geo_user',
'password' => 'd66,AdrGeoDev.User',
@@ -148,7 +162,7 @@ class AppConfig {
if (empty($this->currentHost)) {
// Journaliser cette situation anormale
error_log("WARNING: No host detected, falling back to development environment");
$this->currentHost = 'app.geo.dev';
$this->currentHost = 'dapp.geosector.fr';
}
// Si l'hôte n'existe pas dans la configuration, tenter une correction
@@ -166,7 +180,7 @@ class AppConfig {
// Si toujours pas de correspondance, utiliser l'environnement de développement par défaut
if (!isset($this->config[$this->currentHost])) {
error_log("WARNING: Unknown host '{$this->currentHost}', falling back to development environment");
$this->currentHost = 'app.geo.dev';
$this->currentHost = 'dapp.geosector.fr';
}
}
@@ -186,8 +200,8 @@ class AppConfig {
/**
* Retourne l'identifiant de l'application basé sur l'hôte
*
* @return string L'identifiant de l'application (app.geosector.fr, rapp.geosector.fr, app.geo.dev)
*
* @return string L'identifiant de l'application (app.geosector.fr, rapp.geosector.fr, dapp.geosector.fr)
*/
public function getAppIdentifier(): string {
return $this->currentHost;

View File

@@ -593,6 +593,11 @@ class EntiteController {
$updateFields[] = 'chk_user_delete_pass = ?';
$params[] = $data['chk_user_delete_pass'] ? 1 : 0;
}
if (isset($data['chk_lot_actif'])) {
$updateFields[] = 'chk_lot_actif = ?';
$params[] = $data['chk_lot_actif'] ? 1 : 0;
}
}
// Si aucun champ à mettre à jour, retourner une erreur

View File

@@ -50,10 +50,10 @@ class LoginController {
$username = trim($data['username']);
$encryptedUsername = ApiService::encryptSearchableData($username);
// Récupérer le type d'utilisateur
// admin accessible uniquement aux fk_role>1
// user accessible uniquement aux fk_role=1
$roleCondition = ($interface === 'user') ? 'AND fk_role=1' : 'AND fk_role>1';
// Récupérer le type d'utilisateur
// user accessible aux fk_role=1 ET fk_role=2 (membres + admins amicale)
// admin accessible uniquement aux fk_role>1 (admins amicale + super-admins)
$roleCondition = ($interface === 'user') ? 'AND fk_role IN (1, 2)' : 'AND fk_role>1';
// Log pour le debug
LogService::log('Tentative de connexion GeoSector', [
@@ -343,18 +343,26 @@ class LoginController {
// 3. Récupérer les passages selon l'interface et le rôle
if ($interface === 'user' && !empty($sectors)) {
// Interface utilisateur : passages liés aux secteurs de l'utilisateur
// Interface utilisateur : passages de l'utilisateur + passages à finaliser sur ses secteurs
$userId = $user['id'];
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
$passagesStmt = $this->db->prepare(
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_operation = ? AND fk_sector IN ($sectorIdsString) AND chk_active = 1"
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_operation = ?
AND chk_active = 1
AND (
(fk_user = ?) -- TOUS les passages de l'utilisateur
OR
(fk_sector IN ($sectorIdsString) AND fk_type = 2 AND fk_user != ?) -- Passages type 2 des autres sur ses secteurs
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId]);
$passagesStmt->execute([$activeOperationId, $userId, $userId]);
}
} elseif ($interface === 'admin' && $user['fk_role'] == 2) {
// Interface admin avec rôle 2 : tous les passages de l'opération
@@ -888,6 +896,700 @@ class LoginController {
}
}
public function refreshSession(): void {
try {
// 1. Récupérer l'ID utilisateur depuis la session active
$userId = Session::getUserId();
if (!$userId) {
Response::json(['error' => 'Session invalide'], 401);
return;
}
// 2. Récupérer le mode depuis l'URL
$mode = $_GET['mode'] ?? 'user';
// 3. Validation du mode
if (!in_array($mode, ['user', 'admin'])) {
Response::json(['error' => 'Mode invalide. Valeurs acceptées: user, admin'], 400);
return;
}
// Déterminer le roleCondition selon le mode (même logique que login)
$roleCondition = ($mode === 'user') ? 'AND fk_role IN (1, 2)' : 'AND fk_role>1';
// Log pour le debug
LogService::log('Rafraîchissement session GeoSector', [
'level' => 'info',
'userId' => $userId,
'mode' => $mode,
'role_condition' => $roleCondition
]);
// 4. Requête pour récupérer l'utilisateur et son entité (même requête que login)
$stmt = $this->db->prepare(
'SELECT
u.id, u.encrypted_email, u.encrypted_user_name, u.encrypted_name, u.user_pass_hash,
u.first_name, u.fk_role, u.fk_entite, u.fk_titre, u.chk_active, u.sect_name,
u.date_naissance, u.date_embauche, u.encrypted_phone, u.encrypted_mobile,
e.id AS entite_id, e.encrypted_name AS entite_encrypted_name,
e.adresse1, e.code_postal, e.ville, e.gps_lat, e.gps_lng, e.chk_active AS entite_chk_active
FROM users u
LEFT JOIN entites e ON u.fk_entite = e.id
WHERE u.id = ? AND u.chk_active != 0 ' . $roleCondition
);
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
LogService::log('Rafraîchissement session échoué : utilisateur non trouvé ou accès interdit', [
'level' => 'warning',
'userId' => $userId,
'mode' => $mode
]);
Response::json(['error' => 'Utilisateur non trouvé ou accès interdit à cette interface'], 403);
return;
}
// Vérifier si l'utilisateur a une entité et si elle est active
if (!empty($user['fk_entite']) && (!isset($user['entite_chk_active']) || $user['entite_chk_active'] != 1)) {
LogService::log('Rafraîchissement session échoué : entité non active', [
'level' => 'warning',
'userId' => $userId,
'entite_id' => $user['fk_entite']
]);
Response::json([
'status' => 'error',
'message' => 'Votre amicale n\'est pas activée. Veuillez contacter votre administrateur.'
], 403);
return;
}
// Déchiffrement du nom
$decryptedName = ApiService::decryptData($user['encrypted_name']);
$username = ApiService::decryptSearchableData($user['encrypted_user_name']);
// Déchiffrement de l'email si disponible
$email = '';
if (!empty($user['encrypted_email'])) {
$email = ApiService::decryptSearchableData($user['encrypted_email']);
if (empty($email)) {
LogService::log('Déchiffrement email échoué', [
'level' => 'error',
'message' => 'Déchiffrement de l\'email échoué',
'encrypted_email' => $user['encrypted_email'],
'user_id' => $user['id']
]);
Response::json([
'status' => 'error',
'message' => 'Erreur de déchiffrement de l\'email. Exécutez le script de migration pour résoudre ce problème.',
'debug_info' => [
'encrypted_email' => $user['encrypted_email'],
'user_id' => $user['id']
]
], 500);
return;
}
}
// Préparation des données utilisateur pour la réponse
$userData = [
'id' => $user['id'],
'fk_entite' => $user['fk_entite'] ?? null,
'fk_role' => $user['fk_role'] ?? '0',
'fk_titre' => $user['fk_titre'] ?? null,
'first_name' => $user['first_name'] ?? '',
'sect_name' => $user['sect_name'] ?? '',
'date_naissance' => $user['date_naissance'] ?? null,
'date_embauche' => $user['date_embauche'] ?? null,
'username' => $username,
'name' => $decryptedName
];
// Déchiffrement du téléphone
if (!empty($user['encrypted_phone'])) {
$userData['phone'] = ApiService::decryptData($user['encrypted_phone']);
} else {
$userData['phone'] = '';
}
// Déchiffrement du mobile
if (!empty($user['encrypted_mobile'])) {
$userData['mobile'] = ApiService::decryptData($user['encrypted_mobile']);
} else {
$userData['mobile'] = '';
}
$userData['email'] = $email;
// 5. Charger toutes les données selon le mode (MÊME LOGIQUE QUE LOGIN)
$operationsData = [];
$sectorsData = [];
$passagesData = [];
$usersSectorsData = [];
// Récupération des opérations selon les critères
$operationLimit = 0;
$activeOperationOnly = false;
if ($mode === 'user') {
$operationLimit = 1;
$activeOperationOnly = true;
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$operationLimit = 3;
} elseif ($mode === 'admin' && $user['fk_role'] > 2) {
$operationLimit = 10;
} else {
$operationLimit = 0;
}
if ($operationLimit > 0 && !empty($user['fk_entite'])) {
$operationQuery = "SELECT id, fk_entite, libelle, date_deb, date_fin, chk_active
FROM operations
WHERE fk_entite = ?";
if ($activeOperationOnly) {
$operationQuery .= " AND chk_active = 1";
}
$operationQuery .= " ORDER BY id DESC LIMIT " . $operationLimit;
$operationStmt = $this->db->prepare($operationQuery);
$operationStmt->execute([$user['fk_entite']]);
$operations = $operationStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($operations)) {
foreach ($operations as $operation) {
$operationsData[] = [
'id' => $operation['id'],
'fk_entite' => $operation['fk_entite'],
'libelle' => $operation['libelle'],
'date_deb' => $operation['date_deb'],
'date_fin' => $operation['date_fin'],
'chk_active' => $operation['chk_active']
];
}
$activeOperationId = $operations[0]['id'];
// Récupérer les secteurs selon le mode et le rôle
if ($mode === 'user') {
$sectorsStmt = $this->db->prepare(
'SELECT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
JOIN ope_users_sectors us ON s.id = us.fk_sector
WHERE us.fk_operation = ? AND us.fk_user = ? AND us.chk_active = 1 AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId, $user['id']]);
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$sectorsStmt = $this->db->prepare(
'SELECT DISTINCT s.id, s.libelle, s.color, s.sector
FROM ope_sectors s
WHERE s.fk_operation = ? AND s.chk_active = 1'
);
$sectorsStmt->execute([$activeOperationId]);
} else {
$sectors = [];
$sectorsData = [];
}
if (isset($sectorsStmt)) {
$sectors = $sectorsStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$sectors = [];
}
if (!empty($sectors)) {
$sectorsData = $sectors;
// Récupérer les passages selon le mode et le rôle
if ($mode === 'user' && !empty($sectors)) {
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
$passagesStmt = $this->db->prepare(
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_operation = ?
AND chk_active = 1
AND (
(fk_user = ?)
OR
(fk_sector IN ($sectorIdsString) AND fk_type = 2 AND fk_user != ?)
)
ORDER BY passed_at DESC"
);
$passagesStmt->execute([$activeOperationId, $user['id'], $user['id']]);
}
} elseif ($mode === 'admin' && $user['fk_role'] == 2) {
$passagesStmt = $this->db->prepare(
"SELECT id, fk_operation, fk_sector, fk_user, fk_type, fk_adresse, passed_at, numero, rue, rue_bis, ville, residence, fk_habitat, appt, niveau,
gps_lat, gps_lng, nom_recu, encrypted_name, remarque, encrypted_email, encrypted_phone, montant, fk_type_reglement, email_erreur, nb_passages
FROM ope_pass
WHERE fk_operation = ? AND chk_active = 1"
);
$passagesStmt->execute([$activeOperationId]);
} else {
$passages = [];
$passagesData = [];
}
if (isset($passagesStmt)) {
$passages = $passagesStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$passages = [];
}
if (!empty($passages)) {
foreach ($passages as &$passage) {
$passage['name'] = '';
if (!empty($passage['encrypted_name'])) {
$passage['name'] = ApiService::decryptData($passage['encrypted_name']);
}
unset($passage['encrypted_name']);
$passage['email'] = '';
if (!empty($passage['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($passage['encrypted_email']);
if ($decryptedEmail) {
$passage['email'] = $decryptedEmail;
}
}
unset($passage['encrypted_email']);
$passage['phone'] = '';
if (!empty($passage['encrypted_phone'])) {
$passage['phone'] = ApiService::decryptData($passage['encrypted_phone']);
}
unset($passage['encrypted_phone']);
}
$passagesData = $passages;
}
// Récupérer les utilisateurs des secteurs partagés
if (($mode === 'user' || ($mode === 'admin' && $user['fk_role'] == 2)) && !empty($sectors)) {
$sectorIds = array_column($sectors, 'id');
$sectorIdsString = implode(',', $sectorIds);
if (!empty($sectorIdsString)) {
$usersSectorsStmt = $this->db->prepare(
"SELECT DISTINCT u.id, u.first_name, u.encrypted_name, u.sect_name, us.fk_sector
FROM users u
JOIN ope_users_sectors us ON u.id = us.fk_user
WHERE us.fk_sector IN ($sectorIdsString)
AND us.fk_operation = ?
AND us.chk_active = 1
AND u.chk_active = 1
AND u.id != ?"
);
$usersSectorsStmt->execute([$activeOperationId, $user['id']]);
$usersSectors = $usersSectorsStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($usersSectors)) {
foreach ($usersSectors as &$userSector) {
if (!empty($userSector['encrypted_name'])) {
$userSector['name'] = ApiService::decryptData($userSector['encrypted_name']);
unset($userSector['encrypted_name']);
}
}
$usersSectorsData = $usersSectors;
}
}
} else {
$usersSectorsData = [];
}
}
}
}
// Récupérer les membres si nécessaire
$membresData = [];
if ($mode === 'admin' && $user['fk_role'] == 2 && !empty($user['fk_entite'])) {
$membresStmt = $this->db->prepare(
'SELECT id, fk_role, fk_entite, fk_titre, encrypted_name, first_name, sect_name,
encrypted_user_name, encrypted_phone, encrypted_mobile, encrypted_email,
date_naissance, date_embauche, chk_active
FROM users
WHERE fk_entite = ?'
);
$membresStmt->execute([$user['fk_entite']]);
$membres = $membresStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($membres)) {
foreach ($membres as $membre) {
$membreItem = [
'id' => $membre['id'],
'fk_role' => $membre['fk_role'],
'fk_entite' => $membre['fk_entite'],
'fk_titre' => $membre['fk_titre'],
'first_name' => $membre['first_name'] ?? '',
'sect_name' => $membre['sect_name'] ?? '',
'date_naissance' => $membre['date_naissance'] ?? null,
'date_embauche' => $membre['date_embauche'] ?? null,
'chk_active' => $membre['chk_active']
];
if (!empty($membre['encrypted_name'])) {
$membreItem['name'] = ApiService::decryptData($membre['encrypted_name']);
} else {
$membreItem['name'] = '';
}
if (!empty($membre['encrypted_user_name'])) {
$membreItem['username'] = ApiService::decryptSearchableData($membre['encrypted_user_name']);
} else {
$membreItem['username'] = '';
}
if (!empty($membre['encrypted_phone'])) {
$membreItem['phone'] = ApiService::decryptData($membre['encrypted_phone']);
} else {
$membreItem['phone'] = '';
}
if (!empty($membre['encrypted_mobile'])) {
$membreItem['mobile'] = ApiService::decryptData($membre['encrypted_mobile']);
} else {
$membreItem['mobile'] = '';
}
if (!empty($membre['encrypted_email'])) {
$decryptedEmail = ApiService::decryptSearchableData($membre['encrypted_email']);
if ($decryptedEmail) {
$membreItem['email'] = $decryptedEmail;
}
} else {
$membreItem['email'] = '';
}
$membresData[] = $membreItem;
}
}
}
// Récupérer les amicales selon le rôle
$amicalesData = [];
if (!empty($user['fk_entite'])) {
if ($user['fk_role'] <= 2) {
$amicaleStmt = $this->db->prepare(
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id = ? AND e.chk_active = 1'
);
$amicaleStmt->execute([$user['fk_entite']]);
$amicales = $amicaleStmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$amicaleStmt = $this->db->prepare(
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.id != 1 AND e.chk_active = 1'
);
$amicaleStmt->execute();
$amicales = $amicaleStmt->fetchAll(PDO::FETCH_ASSOC);
}
if (!empty($amicales)) {
foreach ($amicales as &$amicale) {
if (!empty($amicale['name'])) {
$amicale['name'] = ApiService::decryptData($amicale['name']);
}
if (!empty($amicale['email'])) {
$decryptedEmail = ApiService::decryptSearchableData($amicale['email']);
if ($decryptedEmail) {
$amicale['email'] = $decryptedEmail;
}
}
if (!empty($amicale['phone'])) {
$amicale['phone'] = ApiService::decryptData($amicale['phone']);
}
if (!empty($amicale['mobile'])) {
$amicale['mobile'] = ApiService::decryptData($amicale['mobile']);
}
if (!empty($amicale['stripe_id'])) {
$amicale['stripe_id'] = ApiService::decryptData($amicale['stripe_id']);
}
}
$amicalesData = $amicales;
}
}
// Récupérer les entités de type 1 pour les utilisateurs avec fk_role > 2
$entitesData = [];
if ($user['fk_role'] > 2) {
$entitesStmt = $this->db->prepare(
'SELECT e.id, e.encrypted_name as name, e.adresse1, e.adresse2, e.code_postal, e.ville,
e.fk_region, r.libelle AS lib_region, e.fk_type, e.encrypted_phone as phone, e.encrypted_mobile as mobile,
e.encrypted_email as email, e.gps_lat, e.gps_lng,
e.encrypted_stripe_id as stripe_id, e.chk_demo, e.chk_mdp_manuel, e.chk_username_manuel,
e.chk_copie_mail_recu, e.chk_accept_sms, e.chk_active, e.chk_stripe, e.chk_user_delete_pass
FROM entites e
LEFT JOIN x_regions r ON e.fk_region = r.id
WHERE e.fk_type = 1 AND e.chk_active = 1'
);
$entitesStmt->execute();
$entites = $entitesStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($entites)) {
foreach ($entites as &$entite) {
if (!empty($entite['name'])) {
$entite['name'] = ApiService::decryptData($entite['name']);
}
if (!empty($entite['email'])) {
$decryptedEmail = ApiService::decryptSearchableData($entite['email']);
if ($decryptedEmail) {
$entite['email'] = $decryptedEmail;
}
}
if (!empty($entite['phone'])) {
$entite['phone'] = ApiService::decryptData($entite['phone']);
}
if (!empty($entite['mobile'])) {
$entite['mobile'] = ApiService::decryptData($entite['mobile']);
}
if (!empty($entite['stripe_id'])) {
$entite['stripe_id'] = ApiService::decryptData($entite['stripe_id']);
}
}
$entitesData = $entites;
}
}
// Préparation de la réponse (MÊME STRUCTURE QUE LOGIN)
$response = [
'status' => 'success',
'message' => 'Session rafraîchie',
'session_id' => session_id(),
'session_expiry' => date('c', strtotime('+24 hours')),
'user' => $userData
];
// Ajout des amicales avec logo
if (!empty($amicalesData)) {
$logoData = null;
if (!empty($user['fk_entite'])) {
$logoStmt = $this->db->prepare('
SELECT id, fichier, file_path, file_type, mime_type, processed_width, processed_height
FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$logoStmt->execute(['entite', $user['fk_entite'], 'logo']);
$logo = $logoStmt->fetch(PDO::FETCH_ASSOC);
if ($logo && file_exists($logo['file_path'])) {
$imageData = file_get_contents($logo['file_path']);
if ($imageData !== false) {
$base64 = base64_encode($imageData);
$dataUrl = 'data:' . $logo['mime_type'] . ';base64,' . $base64;
$logoData = [
'id' => $logo['id'],
'data_url' => $dataUrl,
'file_name' => $logo['fichier'],
'mime_type' => $logo['mime_type'],
'width' => $logo['processed_width'],
'height' => $logo['processed_height']
];
}
}
}
if (count($amicalesData) === 1) {
$response['amicale'] = $amicalesData[0];
if ($logoData !== null) {
$response['amicale']['logo'] = $logoData;
}
} else {
$response['amicale'] = $amicalesData;
if ($logoData !== null && !empty($user['fk_entite'])) {
foreach ($response['amicale'] as &$amicale) {
if ($amicale['id'] == $user['fk_entite']) {
$amicale['logo'] = $logoData;
break;
}
}
}
}
}
$response['clients'] = $entitesData;
if (!empty($membresData)) {
$response['membres'] = $membresData;
}
if (!empty($operationsData)) {
$response['operations'] = $operationsData;
}
if (!empty($sectorsData)) {
$response['sectors'] = $sectorsData;
}
if (!empty($passagesData)) {
$response['passages'] = $passagesData;
}
if (!empty($usersSectorsData)) {
$response['users_sectors'] = $usersSectorsData;
}
// Récupérer les régions selon le rôle
$regionsData = [];
if ($user['fk_role'] <= 2 && !empty($user['fk_entite'])) {
$amicaleStmt = $this->db->prepare('SELECT code_postal FROM entites WHERE id = ?');
$amicaleStmt->execute([$user['fk_entite']]);
$amicale = $amicaleStmt->fetch(PDO::FETCH_ASSOC);
if (!empty($amicale) && !empty($amicale['code_postal'])) {
$departement = substr($amicale['code_postal'], 0, 2);
$regionStmt = $this->db->prepare(
'SELECT id, fk_pays, libelle, libelle_long, table_osm, departements, chk_active
FROM x_regions
WHERE FIND_IN_SET(?, departements) > 0 AND chk_active = 1'
);
$regionStmt->execute([$departement]);
$regions = $regionStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($regions)) {
$regionsData = $regions;
}
}
} else {
$regionStmt = $this->db->prepare(
'SELECT id, fk_pays, libelle, libelle_long, table_osm, departements, chk_active
FROM x_regions
WHERE chk_active = 1'
);
$regionStmt->execute();
$regions = $regionStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($regions)) {
$regionsData = $regions;
}
}
if (!empty($regionsData)) {
$response['regions'] = $regionsData;
}
// Ajout des informations du module chat
$chatData = [];
$roomCountStmt = $this->db->prepare('
SELECT COUNT(DISTINCT r.id) as total_rooms
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
');
$roomCountStmt->execute(['user_id' => $user['id']]);
$roomCount = $roomCountStmt->fetch(PDO::FETCH_ASSOC);
$chatData['total_rooms'] = (int)($roomCount['total_rooms'] ?? 0);
$unreadStmt = $this->db->prepare('
SELECT COUNT(*) as unread_count
FROM chat_messages m
INNER JOIN chat_participants p ON m.room_id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND m.sender_id != :sender_id
AND m.sent_at > COALESCE(p.last_read_at, p.joined_at)
AND m.is_deleted = 0
');
$unreadStmt->execute([
'user_id' => $user['id'],
'sender_id' => $user['id']
]);
$unreadResult = $unreadStmt->fetch(PDO::FETCH_ASSOC);
$chatData['unread_messages'] = (int)($unreadResult['unread_count'] ?? 0);
$lastRoomStmt = $this->db->prepare('
SELECT
r.id,
r.title,
r.type,
(SELECT m.content
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message,
(SELECT m.sent_at
FROM chat_messages m
WHERE m.room_id = r.id
AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 1) as last_message_at
FROM chat_rooms r
INNER JOIN chat_participants p ON r.id = p.room_id
WHERE p.user_id = :user_id
AND p.left_at IS NULL
AND r.is_active = 1
ORDER BY COALESCE(
(SELECT MAX(m.sent_at) FROM chat_messages m WHERE m.room_id = r.id),
r.created_at
) DESC
LIMIT 1
');
$lastRoomStmt->execute(['user_id' => $user['id']]);
$lastRoom = $lastRoomStmt->fetch(PDO::FETCH_ASSOC);
if ($lastRoom) {
$chatData['last_active_room'] = [
'id' => $lastRoom['id'],
'title' => $lastRoom['title'],
'type' => $lastRoom['type'],
'last_message' => $lastRoom['last_message'],
'last_message_at' => $lastRoom['last_message_at']
];
}
$chatData['chat_enabled'] = true;
$response['chat'] = $chatData;
// 6. Envoi de la réponse
Response::json($response);
} catch (PDOException $e) {
LogService::log('Erreur base de données lors du rafraîchissement de session', [
'level' => 'error',
'error' => $e->getMessage(),
'code' => $e->getCode()
]);
Response::json([
'status' => 'error',
'message' => 'Erreur serveur'
], 500);
} catch (Exception $e) {
LogService::log('Erreur inattendue lors du rafraîchissement de session', [
'level' => 'error',
'error' => $e->getMessage()
]);
Response::json([
'status' => 'error',
'message' => 'Une erreur inattendue est survenue'
], 500);
}
}
public function lostPassword(): void {
try {
$data = Request::getJson();

File diff suppressed because it is too large Load Diff

View File

@@ -119,9 +119,17 @@ class PassageController {
$errors[] = 'La ville est obligatoire';
}
// Validation du nom (chiffré)
if (!isset($data['encrypted_name']) && !isset($data['name'])) {
$errors[] = 'Le nom est obligatoire';
// Validation du nom (chiffré) - obligatoire seulement si (type=1 Effectué ou 5 Lot) ET email présent
$fk_type = isset($data['fk_type']) ? (int)$data['fk_type'] : 0;
$hasEmail = (isset($data['email']) && !empty(trim($data['email']))) ||
(isset($data['encrypted_email']) && !empty($data['encrypted_email']));
if (($fk_type === 1 || $fk_type === 5) && $hasEmail) {
if (!isset($data['encrypted_name']) && !isset($data['name'])) {
$errors[] = 'Le nom est obligatoire pour ce type de passage avec email';
} elseif (isset($data['name']) && empty(trim($data['name']))) {
$errors[] = 'Le nom ne peut pas être vide pour ce type de passage avec email';
}
}
// Validation du montant
@@ -157,6 +165,15 @@ class PassageController {
}
}
// Validation de l'ID Stripe si fourni
if (isset($data['stripe_payment_id']) && !empty($data['stripe_payment_id'])) {
$stripeId = trim($data['stripe_payment_id']);
// L'ID PaymentIntent Stripe doit commencer par 'pi_'
if (!preg_match('/^pi_[a-zA-Z0-9]{24,}$/', $stripeId)) {
$errors[] = 'Format d\'ID de paiement Stripe invalide';
}
}
return empty($errors) ? null : $errors;
}
@@ -210,13 +227,13 @@ class PassageController {
// Requête principale avec jointures
$stmt = $this->db->prepare("
SELECT
SELECT
p.id, p.fk_operation, p.fk_sector, p.fk_user, p.fk_adresse,
p.passed_at, p.fk_type, p.numero, p.rue, p.rue_bis, p.ville,
p.fk_habitat, p.appt, p.niveau, p.residence, p.gps_lat, p.gps_lng,
p.encrypted_name, p.montant, p.fk_type_reglement, p.remarque,
p.encrypted_email, p.encrypted_phone, p.nom_recu, p.date_recu,
p.chk_email_sent, p.docremis, p.date_repasser, p.nb_passages,
p.chk_email_sent, p.stripe_payment_id, p.docremis, p.date_repasser, p.nb_passages,
p.chk_mobile, p.anomalie, p.created_at, p.updated_at, p.chk_active,
o.libelle as operation_libelle,
u.encrypted_name as user_name, u.first_name as user_first_name
@@ -389,11 +406,11 @@ class PassageController {
$offset = ($page - 1) * $limit;
$stmt = $this->db->prepare('
SELECT
SELECT
p.id, p.fk_operation, p.fk_sector, p.fk_user, p.passed_at,
p.numero, p.rue, p.rue_bis, p.ville, p.gps_lat, p.gps_lng,
p.encrypted_name, p.montant, p.fk_type_reglement, p.remarque,
p.encrypted_email, p.encrypted_phone, p.chk_email_sent,
p.encrypted_email, p.encrypted_phone, p.stripe_payment_id, p.chk_email_sent,
p.docremis, p.date_repasser, p.nb_passages, p.chk_mobile,
p.anomalie, p.created_at, p.updated_at,
u.encrypted_name as user_name, u.first_name as user_first_name
@@ -494,7 +511,13 @@ class PassageController {
}
// Chiffrement des données sensibles
$encryptedName = isset($data['name']) ? ApiService::encryptData($data['name']) : (isset($data['encrypted_name']) ? $data['encrypted_name'] : '');
$encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) {
$encryptedName = ApiService::encryptData($data['name']);
} elseif (isset($data['encrypted_name']) && !empty($data['encrypted_name'])) {
$encryptedName = $data['encrypted_name'];
}
// Le nom peut rester vide si les conditions ne l'exigent pas
$encryptedEmail = isset($data['email']) && !empty($data['email']) ?
ApiService::encryptSearchableData($data['email']) : '';
$encryptedPhone = isset($data['phone']) && !empty($data['phone']) ?
@@ -524,6 +547,7 @@ class PassageController {
'remarque' => $data['remarque'] ?? '',
'encrypted_email' => $encryptedEmail,
'encrypted_phone' => $encryptedPhone,
'stripe_payment_id' => isset($data['stripe_payment_id']) ? trim($data['stripe_payment_id']) : null,
'nom_recu' => $data['nom_recu'] ?? null,
'date_recu' => isset($data['date_recu']) ? $data['date_recu'] : null,
'docremis' => isset($data['docremis']) ? (int)$data['docremis'] : 0,
@@ -646,7 +670,7 @@ class PassageController {
}
$stmt = $this->db->prepare('
SELECT p.id, p.fk_operation
SELECT p.id, p.fk_operation, p.fk_type, p.fk_user
FROM ope_pass p
INNER JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND o.fk_entite = ? AND p.chk_active = 1
@@ -673,6 +697,19 @@ class PassageController {
return;
}
// Si le passage était de type 2 et que l'utilisateur actuel est différent du créateur
// On force l'attribution du passage à l'utilisateur actuel
if ((int)$passage['fk_type'] === 2 && (int)$passage['fk_user'] !== $userId) {
$data['fk_user'] = $userId;
LogService::log('Attribution automatique d\'un passage type 2 à l\'utilisateur', [
'level' => 'info',
'passageId' => $passageId,
'ancien_user' => $passage['fk_user'],
'nouveau_user' => $userId
]);
}
// Construction de la requête de mise à jour dynamique
$updateFields = [];
$params = [];
@@ -697,6 +734,7 @@ class PassageController {
'montant',
'fk_type_reglement',
'remarque',
'stripe_payment_id',
'nom_recu',
'date_recu',
'docremis',
@@ -714,9 +752,10 @@ class PassageController {
}
// Gestion des champs chiffrés
if (isset($data['name'])) {
if (array_key_exists('name', $data)) {
$updateFields[] = "encrypted_name = ?";
$params[] = ApiService::encryptData($data['name']);
// Permettre de vider le nom si les conditions le permettent
$params[] = !empty(trim($data['name'])) ? ApiService::encryptData($data['name']) : '';
}
if (isset($data['email'])) {

View File

@@ -544,24 +544,85 @@ class SectorController
$stmt->execute($params);
}
// Gestion des membres
if (isset($data['membres'])) {
// Gestion des membres (reçus comme 'users' depuis Flutter)
if (isset($data['users'])) {
$this->logService->info('[UPDATE USERS] Début modification des membres', [
'sector_id' => $id,
'users_demandes' => $data['users'],
'nb_users' => count($data['users'])
]);
// Récupérer l'opération du secteur pour l'INSERT
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [
'query' => $opQuery,
'params' => ['sector_id' => $id]
]);
$opStmt = $this->db->prepare($opQuery);
$opStmt->execute(['sector_id' => $id]);
$operationId = $opStmt->fetch()['fk_operation'];
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [
'operation_id' => $operationId
]);
// Supprimer les affectations existantes
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [
'query' => $deleteQuery,
'params' => ['sector_id' => $id]
]);
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['sector_id' => $id]);
$deletedCount = $deleteStmt->rowCount();
$this->logService->info('[UPDATE USERS] Membres supprimés', [
'nb_deleted' => $deletedCount
]);
// Ajouter les nouvelles affectations
if (!empty($data['membres'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_user, fk_sector) VALUES (:user_id, :sector_id)";
if (!empty($data['users'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), :user_creat, 1)";
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
'query' => $insertQuery
]);
$insertStmt = $this->db->prepare($insertQuery);
foreach ($data['membres'] as $memberId) {
$insertStmt->execute([
'user_id' => $memberId,
'sector_id' => $id
]);
$insertedUsers = [];
$failedUsers = [];
foreach ($data['users'] as $memberId) {
try {
$params = [
'operation_id' => $operationId,
'user_id' => $memberId,
'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null
];
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
'params' => $params
]);
$insertStmt->execute($params);
$insertedUsers[] = $memberId;
$this->logService->info('[UPDATE USERS] User inséré avec succès', [
'user_id' => $memberId
]);
} catch (\PDOException $e) {
$failedUsers[] = $memberId;
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
'sector_id' => $id,
'user_id' => $memberId,
'error' => $e->getMessage(),
'error_code' => $e->getCode()
]);
}
}
$this->logService->info('[UPDATE USERS] Résultat des insertions', [
'users_demandes' => $data['users'],
'users_inseres' => $insertedUsers,
'users_echoues' => $failedUsers,
'nb_succes' => count($insertedUsers),
'nb_echecs' => count($failedUsers)
]);
}
}
@@ -651,7 +712,8 @@ class SectorController
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
}
// Commit des modifications (users et/ou secteur)
$this->db->commit();
// Récupérer le secteur mis à jour
@@ -711,14 +773,29 @@ class SectorController
$passagesDecrypted[] = $passage;
}
// Récupérer les users affectés
// Récupérer les users affectés (avec READ UNCOMMITTED pour forcer la lecture des données fraîches)
$usersQuery = "SELECT u.id, u.first_name, u.sect_name, u.encrypted_name, ous.fk_sector
FROM ope_users_sectors ous
JOIN users u ON ous.fk_user = u.id
WHERE ous.fk_sector = :sector_id";
WHERE ous.fk_sector = :sector_id
ORDER BY u.id";
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [
'query' => $usersQuery,
'params' => ['sector_id' => $id]
]);
$usersStmt = $this->db->prepare($usersQuery);
$usersStmt->execute(['sector_id' => $id]);
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
$userIds = array_column($usersSectors, 'id');
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [
'sector_id' => $id,
'users_ids' => $userIds,
'nb_users' => count($userIds),
'users_demandes_initialement' => $data['users'] ?? []
]);
// Déchiffrer les noms des utilisateurs
$usersDecrypted = [];
@@ -1066,6 +1143,7 @@ class SectorController
/**
* Mettre à jour les passages affectés à un secteur lors de la modification du périmètre
* VERSION OPTIMISÉE avec requêtes groupées
* Retourne un tableau avec les compteurs détaillés
*/
private function updatePassagesForSector($sectorId, $newSectorCoords): array
@@ -1080,18 +1158,18 @@ class SectorController
try {
// Récupérer l'opération et l'entité du secteur
$sectorQuery = "SELECT o.id as operation_id, o.fk_entite, s.fk_operation
$sectorQuery = "SELECT o.id as operation_id, o.fk_entite, s.fk_operation
FROM ope_sectors s
JOIN operations o ON s.fk_operation = o.id
WHERE s.id = :sector_id";
$sectorStmt = $this->db->prepare($sectorQuery);
$sectorStmt->execute(['sector_id' => $sectorId]);
$sectorInfo = $sectorStmt->fetch();
if (!$sectorInfo) {
return 0;
return $counters;
}
$operationId = $sectorInfo['operation_id'];
$entityId = $sectorInfo['fk_entite'];
@@ -1099,7 +1177,7 @@ class SectorController
$points = explode('#', rtrim($newSectorCoords, '#'));
$coordinates = [];
$polygonPoints = [];
foreach ($points as $point) {
if (!empty($point)) {
list($lat, $lng) = explode('/', $point);
@@ -1110,170 +1188,249 @@ class SectorController
$polygonPoints[] = $polygonPoints[0]; // Fermer le polygone
$polygonString = 'POLYGON((' . implode(',', $polygonPoints) . '))';
// 1. VÉRIFICATION GÉOGRAPHIQUE DES PASSAGES EXISTANTS
$checkPassagesQuery = "SELECT id, gps_lat, gps_lng, fk_type, encrypted_name
FROM ope_pass
WHERE fk_sector = :sector_id
AND gps_lat IS NOT NULL
AND gps_lng IS NOT NULL";
// 1. VÉRIFICATION GÉOGRAPHIQUE DES PASSAGES EXISTANTS (OPTIMISÉE)
// Utiliser une seule requête pour vérifier tous les passages
$checkPassagesQuery = "
SELECT
p.id,
p.gps_lat,
p.gps_lng,
p.fk_type,
p.encrypted_name,
ST_Contains(ST_GeomFromText(:polygon, 4326),
POINT(CAST(p.gps_lng AS DECIMAL(10,8)),
CAST(p.gps_lat AS DECIMAL(10,8)))) as is_inside
FROM ope_pass p
WHERE p.fk_sector = :sector_id
AND p.gps_lat IS NOT NULL
AND p.gps_lng IS NOT NULL";
$checkStmt = $this->db->prepare($checkPassagesQuery);
$checkStmt->execute(['sector_id' => $sectorId]);
$checkStmt->execute([
'sector_id' => $sectorId,
'polygon' => $polygonString
]);
$existingPassages = $checkStmt->fetchAll();
$passagesToDelete = [];
$passagesToOrphan = [];
foreach ($existingPassages as $passage) {
// Vérifier si le passage est dans le nouveau polygone
$pointInPolygonQuery = "SELECT ST_Contains(ST_GeomFromText(:polygon, 4326),
POINT(CAST(:lng AS DECIMAL(10,8)),
CAST(:lat AS DECIMAL(10,8)))) as is_inside";
$pointStmt = $this->db->prepare($pointInPolygonQuery);
$pointStmt->execute([
'polygon' => $polygonString,
'lng' => $passage['gps_lng'],
'lat' => $passage['gps_lat']
]);
$result = $pointStmt->fetch();
if ($result['is_inside'] == 0) {
if ($passage['is_inside'] == 0) {
// Le passage est hors du nouveau périmètre
// Vérifier si c'est un passage non visité (fk_type=2 ET encrypted_name vide)
if ($passage['fk_type'] == 2 && ($passage['encrypted_name'] === '' || $passage['encrypted_name'] === null)) {
// Passage non visité : à supprimer
$passagesToDelete[] = $passage['id'];
$counters['passages_deleted'] = ($counters['passages_deleted'] ?? 0) + 1;
$counters['passages_deleted']++;
} else {
// Passage visité : mettre en orphelin
$orphanQuery = "UPDATE ope_pass SET fk_sector = NULL WHERE id = :passage_id";
$orphanStmt = $this->db->prepare($orphanQuery);
$orphanStmt->execute(['passage_id' => $passage['id']]);
// Passage visité : à mettre en orphelin
$passagesToOrphan[] = $passage['id'];
$counters['passages_orphaned']++;
}
} else {
$counters['passages_kept']++;
}
}
// Supprimer les passages non visités qui sont hors zone
// Supprimer les passages non visités en une seule requête
if (!empty($passagesToDelete)) {
$deleteQuery = "DELETE FROM ope_pass WHERE id IN (" . implode(',', $passagesToDelete) . ")";
$this->db->exec($deleteQuery);
$placeholders = str_repeat('?,', count($passagesToDelete) - 1) . '?';
$deleteQuery = "DELETE FROM ope_pass WHERE id IN ($placeholders)";
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute($passagesToDelete);
}
// Mettre en orphelin les passages visités en une seule requête
if (!empty($passagesToOrphan)) {
$placeholders = str_repeat('?,', count($passagesToOrphan) - 1) . '?';
$orphanQuery = "UPDATE ope_pass SET fk_sector = NULL WHERE id IN ($placeholders)";
$orphanStmt = $this->db->prepare($orphanQuery);
$orphanStmt->execute($passagesToOrphan);
}
// 2. CRÉATION/MISE À JOUR DES PASSAGES POUR LES NOUVELLES ADRESSES
// 2. CRÉATION/MISE À JOUR DES PASSAGES POUR LES NOUVELLES ADRESSES (OPTIMISÉE)
// Récupérer toutes les adresses du secteur depuis sectors_adresses
$addressesQuery = "SELECT * FROM sectors_adresses WHERE fk_sector = :sector_id";
$addressesStmt = $this->db->prepare($addressesQuery);
$addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll();
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
'sector_id' => $sectorId,
'nb_addresses' => count($addresses)
]);
// Récupérer le premier utilisateur affecté au secteur
$userQuery = "SELECT fk_user FROM ope_users_sectors WHERE fk_sector = :sector_id LIMIT 1";
$userStmt = $this->db->prepare($userQuery);
$userStmt->execute(['sector_id' => $sectorId]);
$firstUser = $userStmt->fetch();
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Création passages pour user', [
$this->logService->info('[updatePassagesForSector] Optimisation passages', [
'user_id' => $firstUserId,
'nb_addresses_to_process' => count($addresses)
'nb_addresses' => count($addresses)
]);
// Préparer la requête de création de passage (même format que dans create)
$createPassageQuery = "INSERT INTO ope_pass (
fk_operation, fk_sector, fk_user, fk_adresse,
numero, rue, rue_bis, ville,
gps_lat, gps_lng, fk_type, encrypted_name,
created_at, fk_user_creat, chk_active
) VALUES (
:operation_id, :sector_id, :user_id, :fk_adresse,
:numero, :rue, :rue_bis, :ville,
:gps_lat, :gps_lng, 2, '',
NOW(), :user_creat, 1
)";
$createStmt = $this->db->prepare($createPassageQuery);
// OPTIMISATION : Récupérer TOUS les passages existants en UNE requête
$addressIds = array_filter(array_column($addresses, 'fk_adresse'));
// Construire la requête pour récupérer tous les passages existants
$existingQuery = "
SELECT id, fk_adresse, numero, rue, rue_bis, ville
FROM ope_pass
WHERE fk_operation = :operation_id
AND (";
$params = ['operation_id' => $operationId];
$conditions = [];
// Condition pour les fk_adresse
if (!empty($addressIds)) {
$placeholders = [];
foreach ($addressIds as $idx => $addrId) {
$key = 'addr_' . $idx;
$placeholders[] = ':' . $key;
$params[$key] = $addrId;
}
$conditions[] = "fk_adresse IN (" . implode(',', $placeholders) . ")";
}
// Condition pour les données d'adresse (numero, rue, ville)
$addressConditions = [];
foreach ($addresses as $idx => $addr) {
$numKey = 'num_' . $idx;
$rueKey = 'rue_' . $idx;
$bisKey = 'bis_' . $idx;
$villeKey = 'ville_' . $idx;
$addressConditions[] = "(numero = :$numKey AND rue = :$rueKey AND rue_bis = :$bisKey AND ville = :$villeKey)";
$params[$numKey] = $addr['numero'];
$params[$rueKey] = $addr['rue'];
$params[$bisKey] = $addr['rue_bis'];
$params[$villeKey] = $addr['ville'];
}
if (!empty($addressConditions)) {
$conditions[] = "(" . implode(' OR ', $addressConditions) . ")";
}
$existingQuery .= implode(' OR ', $conditions) . ")";
$existingStmt = $this->db->prepare($existingQuery);
$existingStmt->execute($params);
$existingPassages = $existingStmt->fetchAll();
// Indexer les passages existants pour recherche rapide
$passagesByAddress = [];
$passagesByData = [];
foreach ($existingPassages as $p) {
if (!empty($p['fk_adresse'])) {
$passagesByAddress[$p['fk_adresse']] = $p;
}
$dataKey = $p['numero'] . '|' . $p['rue'] . '|' . $p['rue_bis'] . '|' . $p['ville'];
$passagesByData[$dataKey] = $p;
}
// Préparer les listes pour batch insert/update
$toInsert = [];
$toUpdate = [];
foreach ($addresses as $address) {
// 2.1 Vérification primaire par fk_adresse
if (!empty($address['fk_adresse'])) {
$checkByAddressQuery = "SELECT id FROM ope_pass
WHERE fk_operation = :operation_id
AND fk_adresse = :fk_adresse";
$checkByAddressStmt = $this->db->prepare($checkByAddressQuery);
$checkByAddressStmt->execute([
'operation_id' => $operationId,
'fk_adresse' => $address['fk_adresse']
]);
if ($checkByAddressStmt->fetch()) {
continue; // Passage déjà existant, passer au suivant
}
// Vérification en mémoire PHP (0 requête)
if (!empty($address['fk_adresse']) && isset($passagesByAddress[$address['fk_adresse']])) {
continue; // Déjà existant avec bon fk_adresse
}
// 2.2 Vérification secondaire par données d'adresse
$checkByDataQuery = "SELECT id FROM ope_pass
WHERE fk_operation = :operation_id
AND numero = :numero
AND rue_bis = :rue_bis
AND rue = :rue
AND ville = :ville";
$checkByDataStmt = $this->db->prepare($checkByDataQuery);
$checkByDataStmt->execute([
'operation_id' => $operationId,
'numero' => $address['numero'],
'rue_bis' => $address['rue_bis'],
'rue' => $address['rue'],
'ville' => $address['ville']
]);
$matchingPassages = $checkByDataStmt->fetchAll();
if (!empty($matchingPassages)) {
// Mettre à jour les passages trouvés avec le fk_adresse
if (!empty($address['fk_adresse'])) {
$updateQuery = "UPDATE ope_pass SET fk_adresse = :fk_adresse WHERE id = :passage_id";
$updateStmt = $this->db->prepare($updateQuery);
foreach ($matchingPassages as $matchingPassage) {
$updateStmt->execute([
'fk_adresse' => $address['fk_adresse'],
'passage_id' => $matchingPassage['id']
]);
$counters['passages_updated']++;
}
$dataKey = $address['numero'] . '|' . $address['rue'] . '|' . $address['rue_bis'] . '|' . $address['ville'];
if (isset($passagesByData[$dataKey])) {
// Passage existant mais sans fk_adresse ou avec fk_adresse différent
if (!empty($address['fk_adresse']) && $passagesByData[$dataKey]['fk_adresse'] != $address['fk_adresse']) {
$toUpdate[] = [
'id' => $passagesByData[$dataKey]['id'],
'fk_adresse' => $address['fk_adresse']
];
}
continue;
} else {
// Nouveau passage à créer
$toInsert[] = $address;
}
// 2.3 Création du passage (aucun passage existant trouvé)
}
// INSERT MULTIPLE en une seule requête
if (!empty($toInsert)) {
$values = [];
$insertParams = [];
$paramIndex = 0;
foreach ($toInsert as $addr) {
$values[] = "(:op$paramIndex, :sect$paramIndex, :usr$paramIndex, :addr$paramIndex,
:num$paramIndex, :rue$paramIndex, :bis$paramIndex, :ville$paramIndex,
:lat$paramIndex, :lng$paramIndex, 2, '', NOW(), :creat$paramIndex, 1)";
$insertParams["op$paramIndex"] = $operationId;
$insertParams["sect$paramIndex"] = $sectorId;
$insertParams["usr$paramIndex"] = $firstUserId;
$insertParams["addr$paramIndex"] = $addr['fk_adresse'];
$insertParams["num$paramIndex"] = $addr['numero'];
$insertParams["rue$paramIndex"] = $addr['rue'];
$insertParams["bis$paramIndex"] = $addr['rue_bis'];
$insertParams["ville$paramIndex"] = $addr['ville'];
$insertParams["lat$paramIndex"] = $addr['gps_lat'];
$insertParams["lng$paramIndex"] = $addr['gps_lng'];
$insertParams["creat$paramIndex"] = $_SESSION['user_id'] ?? null;
$paramIndex++;
}
$insertQuery = "INSERT INTO ope_pass
(fk_operation, fk_sector, fk_user, fk_adresse, numero, rue, rue_bis,
ville, gps_lat, gps_lng, fk_type, encrypted_name, created_at, fk_user_creat, chk_active)
VALUES " . implode(',', $values);
try {
$createStmt->execute([
'operation_id' => $operationId,
'sector_id' => $sectorId,
'user_id' => $firstUserId,
'fk_adresse' => $address['fk_adresse'],
'numero' => $address['numero'],
'rue' => $address['rue'],
'rue_bis' => $address['rue_bis'],
'ville' => $address['ville'],
'gps_lat' => $address['gps_lat'],
'gps_lng' => $address['gps_lng'],
'user_creat' => $_SESSION['user_id'] ?? null
]);
$counters['passages_created']++;
$insertStmt = $this->db->prepare($insertQuery);
$insertStmt->execute($insertParams);
$counters['passages_created'] = count($toInsert);
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage pendant update', [
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [
'sector_id' => $sectorId,
'address' => $address,
'error' => $e->getMessage()
]);
}
}
// UPDATE MULTIPLE avec CASE WHEN
if (!empty($toUpdate)) {
$updateIds = array_column($toUpdate, 'id');
$placeholders = str_repeat('?,', count($updateIds) - 1) . '?';
$caseWhen = [];
$updateParams = [];
foreach ($toUpdate as $upd) {
$caseWhen[] = "WHEN id = ? THEN ?";
$updateParams[] = $upd['id'];
$updateParams[] = $upd['fk_adresse'];
}
$updateQuery = "UPDATE ope_pass
SET fk_adresse = CASE " . implode(' ', $caseWhen) . " END
WHERE id IN ($placeholders)";
try {
$updateStmt = $this->db->prepare($updateQuery);
$updateStmt->execute(array_merge($updateParams, $updateIds));
$counters['passages_updated'] = count($toUpdate);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
}
}
} else {
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',

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

View File

@@ -225,36 +225,32 @@ class UserController {
'has_password' => isset($data['password'])
]);
// Validation des données requises
if (!isset($data['email']) || empty(trim($data['email']))) {
LogService::log('Erreur création utilisateur : Email manquant', [
'level' => 'warning',
'createdBy' => $currentUserId
]);
Response::json([
'status' => 'error',
'message' => 'Email requis',
'field' => 'email'
], 400);
return;
}
if (!isset($data['name']) || empty(trim($data['name']))) {
LogService::log('Erreur création utilisateur : Nom manquant', [
// Validation : au moins name OU sect_name requis
if ((!isset($data['name']) || empty(trim($data['name']))) &&
(!isset($data['sect_name']) || empty(trim($data['sect_name'])))) {
LogService::log('Erreur création utilisateur : Aucun nom fourni', [
'level' => 'warning',
'createdBy' => $currentUserId,
'email' => $data['email'] ?? 'non fourni'
'email' => $data['email'] ?? 'non fourni',
'has_name' => isset($data['name']),
'has_sect_name' => isset($data['sect_name'])
]);
Response::json([
'status' => 'error',
'message' => 'Nom requis',
'field' => 'name'
'message' => 'Au moins un nom (name ou sect_name) est requis',
'field' => 'name_or_sect_name'
], 400);
return;
}
$email = trim(strtolower($data['email']));
$name = trim($data['name']);
// L'email est maintenant optionnel
$email = isset($data['email']) && !empty(trim($data['email']))
? trim(strtolower($data['email']))
: '';
// Le name peut être vide si sect_name est fourni
$name = isset($data['name']) && !empty(trim($data['name']))
? trim($data['name'])
: '';
$firstName = isset($data['first_name']) ? trim($data['first_name']) : '';
$role = isset($data['role']) ? (int)$data['role'] : 1;
$entiteId = isset($data['fk_entite']) ? (int)$data['fk_entite'] : 1;
@@ -288,8 +284,8 @@ class UserController {
return;
}
// Validation de l'email
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Validation de l'email seulement s'il est fourni
if (!empty($email) && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Erreur création utilisateur : Format email invalide', [
'level' => 'warning',
'createdBy' => $currentUserId,
@@ -305,8 +301,10 @@ class UserController {
}
// Chiffrement des données sensibles
$encryptedEmail = ApiService::encryptSearchableData($email);
$encryptedName = ApiService::encryptData($name);
// Chiffrer l'email seulement s'il n'est pas vide
$encryptedEmail = !empty($email) ? ApiService::encryptSearchableData($email) : null;
// Chiffrer le name seulement s'il n'est pas vide
$encryptedName = !empty($name) ? ApiService::encryptData($name) : null;
// Vérification de l'existence de l'email
// DÉSACTIVÉ : Le client souhaite permettre plusieurs comptes avec le même email
@@ -503,36 +501,43 @@ class UserController {
]);
$userId = $this->db->lastInsertId();
// Envoi des emails séparés pour plus de sécurité
// 1er email : TOUJOURS envoyer l'identifiant (username)
$usernameEmailData = [
'email' => $email,
'username' => $username,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_username', $usernameEmailData);
// 2ème email : Envoyer le mot de passe (toujours, qu'il soit manuel ou généré)
// Attendre un peu entre les deux emails pour éviter qu'ils arrivent dans le mauvais ordre
sleep(1);
$passwordEmailData = [
'email' => $email,
'password' => $password,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_password', $passwordEmailData);
// Envoi des emails séparés pour plus de sécurité (seulement si un email est fourni)
if (!empty($email)) {
// 1er email : Envoyer l'identifiant (username)
$usernameEmailData = [
'email' => $email,
'username' => $username,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_username', $usernameEmailData);
// 2ème email : Envoyer le mot de passe (toujours, qu'il soit manuel ou généré)
// Attendre un peu entre les deux emails pour éviter qu'ils arrivent dans le mauvais ordre
sleep(1);
$passwordEmailData = [
'email' => $email,
'password' => $password,
'name' => $name
];
ApiService::sendEmail($email, $name, 'welcome_password', $passwordEmailData);
} else {
LogService::log('Utilisateur créé sans email - pas d\'envoi de credentials', [
'level' => 'info',
'userId' => $userId,
'username' => $username
]);
}
LogService::log('Utilisateur GeoSector créé', [
'level' => 'info',
'createdBy' => $currentUserId,
'newUserId' => $userId,
'email' => $email,
'email' => !empty($email) ? $email : 'non fourni',
'username' => $username,
'usernameManual' => $chkUsernameManuel === 1 ? 'oui' : 'non',
'passwordManual' => $chkMdpManuel === 1 ? 'oui' : 'non',
'emailsSent' => '2 emails (username + password)'
'emailsSent' => !empty($email) ? '2 emails (username + password)' : 'aucun (pas d\'email)'
]);
// Préparer la réponse avec les informations de connexion si générées automatiquement
@@ -639,6 +644,58 @@ class UserController {
$params['encrypted_mobile'] = ApiService::encryptData(trim($data['mobile']));
}
// Gestion de la modification du username
if (isset($data['username'])) {
$username = trim($data['username']);
// Validation de la longueur en caractères UTF-8
$usernameLength = mb_strlen($username, 'UTF-8');
if ($usernameLength < 8) {
Response::json([
'status' => 'error',
'message' => 'Identifiant trop court',
'field' => 'username',
'details' => 'Minimum 8 caractères'
], 400);
return;
}
if ($usernameLength > 30) {
Response::json([
'status' => 'error',
'message' => 'Identifiant trop long',
'field' => 'username',
'details' => 'Maximum 30 caractères'
], 400);
return;
}
$encryptedUsername = ApiService::encryptSearchableData($username);
// Vérifier l'unicité du username (sauf pour l'utilisateur courant)
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_user_name = ? AND id != ?');
$checkStmt->execute([$encryptedUsername, $id]);
if ($checkStmt->fetch()) {
Response::json([
'status' => 'error',
'message' => 'Cet identifiant est déjà utilisé par un autre utilisateur',
'field' => 'username'
], 409);
return;
}
$updateFields[] = "encrypted_user_name = :encrypted_user_name";
$params['encrypted_user_name'] = $encryptedUsername;
LogService::log('Modification du username', [
'level' => 'info',
'userId' => $id,
'newUsername' => $username,
'modifiedBy' => $currentUserId
]);
}
// Traitement des champs non chiffrés
$nonEncryptedFields = [
'first_name',
@@ -1142,6 +1199,209 @@ class UserController {
}
}
/**
* Enregistre les informations du device de l'utilisateur
* POST /api/users/device-info
*/
public function saveDeviceInfo(): void {
Session::requireAuth();
$userId = Session::getUserId();
if (!$userId) {
LogService::log('Device info error: Invalid session', [
'level' => 'error',
'session_id' => session_id()
]);
Response::json([
'status' => 'error',
'message' => 'Session invalide'
], 401);
return;
}
// Récupération des données du payload
$data = Request::getJson();
// Validation des données requises
if (empty($data['platform'])) {
LogService::log('Device info error: Missing platform', [
'level' => 'error',
'user_id' => $userId,
'data' => $data
]);
Response::json([
'status' => 'error',
'message' => 'Platform requis',
'field' => 'platform'
], 400);
return;
}
// Validation des IPs (IPv4 uniquement)
// Pour la plateforme Web, device_ip_local contient "Web Platform" → on le traite comme NULL
$deviceIpLocal = $data['device_ip_local'] ?? null;
$deviceIpPublic = $data['device_ip_public'] ?? null;
if ($deviceIpLocal && !filter_var($deviceIpLocal, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
LogService::log('Device IP local invalide, ignorée', [
'level' => 'debug',
'user_id' => $userId,
'device_ip_local' => $deviceIpLocal
]);
$deviceIpLocal = null; // Ignorer si ce n'est pas une IPv4 valide
}
if ($deviceIpPublic && !filter_var($deviceIpPublic, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
LogService::log('Device IP public invalide, ignorée', [
'level' => 'debug',
'user_id' => $userId,
'device_ip_public' => $deviceIpPublic
]);
$deviceIpPublic = null; // Ignorer si ce n'est pas une IPv4 valide
}
// Validation du niveau de batterie (0-100)
$batteryLevel = isset($data['battery_level']) ? intval($data['battery_level']) : null;
if ($batteryLevel !== null && ($batteryLevel < 0 || $batteryLevel > 100)) {
$batteryLevel = null;
}
// Conversion des booléens
$nfcCapable = isset($data['device_nfc_capable']) ? ($data['device_nfc_capable'] ? 1 : 0) : null;
$tapToPay = isset($data['device_supports_tap_to_pay']) ? ($data['device_supports_tap_to_pay'] ? 1 : 0) : null;
$batteryCharging = isset($data['battery_charging']) ? ($data['battery_charging'] ? 1 : 0) : null;
// Conversion de la date de check
$lastDeviceInfoCheck = null;
if (!empty($data['last_device_info_check'])) {
try {
$date = new \DateTime($data['last_device_info_check']);
$lastDeviceInfoCheck = $date->format('Y-m-d H:i:s');
} catch (\Exception $e) {
// Ignorer si le format de date est invalide
}
}
try {
$this->db->beginTransaction();
// Pour les plateformes Web sans device_identifier, générer un identifiant unique basé sur user_id + platform
$deviceIdentifier = $data['device_identifier'] ?? null;
// Si device_identifier est vide ou NULL pour la plateforme Web, générer un identifiant
if (empty($deviceIdentifier)) {
$platform = $data['platform'] ?? 'unknown';
if ($platform === 'Web') {
// Un seul device Web par utilisateur
$deviceIdentifier = 'web_' . $userId . '_' . md5($userId . '_web');
} else {
// Pour les autres plateformes sans identifier, générer un identifiant aléatoire
$deviceIdentifier = strtolower($platform) . '_' . $userId . '_' . uniqid();
}
LogService::log('Device identifier généré automatiquement', [
'level' => 'debug',
'user_id' => $userId,
'platform' => $platform,
'device_identifier' => $deviceIdentifier
]);
}
// Requête INSERT ... ON DUPLICATE KEY UPDATE
$sql = "INSERT INTO user_devices (
fk_user, platform, device_model, device_name, device_manufacturer,
device_identifier, device_ip_local, device_ip_public, device_wifi_name,
device_wifi_bssid, ios_version, device_nfc_capable, device_supports_tap_to_pay,
battery_level, battery_charging, battery_state, app_version, app_build,
last_device_info_check
) VALUES (
:fk_user, :platform, :device_model, :device_name, :device_manufacturer,
:device_identifier, :device_ip_local, :device_ip_public, :device_wifi_name,
:device_wifi_bssid, :ios_version, :device_nfc_capable, :device_supports_tap_to_pay,
:battery_level, :battery_charging, :battery_state, :app_version, :app_build,
:last_device_info_check
) ON DUPLICATE KEY UPDATE
platform = VALUES(platform),
device_model = VALUES(device_model),
device_name = VALUES(device_name),
device_manufacturer = VALUES(device_manufacturer),
device_ip_local = VALUES(device_ip_local),
device_ip_public = VALUES(device_ip_public),
device_wifi_name = VALUES(device_wifi_name),
device_wifi_bssid = VALUES(device_wifi_bssid),
ios_version = VALUES(ios_version),
device_nfc_capable = VALUES(device_nfc_capable),
device_supports_tap_to_pay = VALUES(device_supports_tap_to_pay),
battery_level = VALUES(battery_level),
battery_charging = VALUES(battery_charging),
battery_state = VALUES(battery_state),
app_version = VALUES(app_version),
app_build = VALUES(app_build),
last_device_info_check = VALUES(last_device_info_check),
updated_at = CURRENT_TIMESTAMP";
$stmt = $this->db->prepare($sql);
$params = [
':fk_user' => $userId,
':platform' => $data['platform'] ?? null,
':device_model' => $data['device_model'] ?? null,
':device_name' => $data['device_name'] ?? null,
':device_manufacturer' => $data['device_manufacturer'] ?? null,
':device_identifier' => $deviceIdentifier,
':device_ip_local' => $deviceIpLocal,
':device_ip_public' => $deviceIpPublic,
':device_wifi_name' => $data['device_wifi_name'] ?? null,
':device_wifi_bssid' => $data['device_wifi_bssid'] ?? null,
':ios_version' => $data['ios_version'] ?? null,
':device_nfc_capable' => $nfcCapable,
':device_supports_tap_to_pay' => $tapToPay,
':battery_level' => $batteryLevel,
':battery_charging' => $batteryCharging,
':battery_state' => $data['battery_state'] ?? null,
':app_version' => $data['app_version'] ?? null,
':app_build' => $data['app_build'] ?? null,
':last_device_info_check' => $lastDeviceInfoCheck
];
$stmt->execute($params);
$this->db->commit();
LogService::log('Device info enregistrées avec succès', [
'level' => 'info',
'user_id' => $userId,
'platform' => $data['platform'],
'device_identifier' => $deviceIdentifier
]);
Response::json([
'status' => 'success',
'message' => 'Informations du device enregistrées'
], 200);
} catch (\PDOException $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
LogService::log('Error saving device info', [
'level' => 'error',
'user_id' => $userId,
'error' => $e->getMessage(),
'code' => $e->getCode(),
'platform' => $data['platform'] ?? 'unknown'
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de l\'enregistrement des informations du device',
'details' => $e->getMessage()
], 500);
}
}
// Méthodes auxiliaires
private function validateUpdateData(array $data): ?string {
// Validation de l'email

View File

@@ -37,6 +37,7 @@ class Router {
// Routes privées utilisateurs
// IMPORTANT: Les routes spécifiques doivent être déclarées AVANT les routes avec paramètres
$this->post('users/check-username', ['UserController', 'checkUsername']); // Déplacé avant les routes avec :id
$this->post('users/device-info', ['UserController', 'saveDeviceInfo']); // Endpoint pour sauvegarder les infos du device
$this->get('users', ['UserController', 'getUsers']);
$this->get('users/:id', ['UserController', 'getUserById']);
$this->post('users', ['UserController', 'createUser']);
@@ -44,6 +45,7 @@ class Router {
$this->delete('users/:id', ['UserController', 'deleteUser']);
$this->post('users/:id/reset-password', ['UserController', 'resetPassword']);
$this->post('logout', ['LoginController', 'logout']);
$this->get('user/session', ['LoginController', 'refreshSession']);
// Routes entités
$this->get('entites', ['EntiteController', 'getEntites']);
@@ -128,21 +130,19 @@ class Router {
$this->post('stripe/accounts', ['StripeController', 'createAccount']);
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
$this->post('stripe/locations', ['StripeController', 'createLocation']);
// Terminal et Tap to Pay
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
// Tap to Pay - Vérification compatibilité
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
// Statistiques et configuration
$this->get('stripe/stats', ['StripeController', 'getPaymentStats']);
$this->get('stripe/config', ['StripeController', 'getPublicConfig']);
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhook', ['StripeWebhookController', 'handleWebhook']);
}

View File

@@ -14,26 +14,125 @@ use Database;
* Bloque temporairement ou définitivement des adresses IP suspectes
*/
class IPBlocker {
private static ?PDO $db = null;
private static array $cache = [];
private static ?int $lastCacheClean = null;
// IPs en whitelist (jamais bloquées)
private static ?array $dynamicWhitelist = null;
private static ?int $whitelistLastCheck = null;
// IPs en whitelist statique (jamais bloquées)
const WHITELIST = [
'127.0.0.1',
'::1',
'localhost'
];
// Configuration pour récupérer l'IP depuis IN3
const IN3_CONFIG = [
'host' => '195.154.80.116',
'user' => 'root',
'ip_file' => '/var/bat/IP',
'cache_duration' => 3600, // 1 heure de cache
'local_cache_file' => __DIR__ . '/../../../config/whitelist_ip_cache.txt'
];
/**
* Récupérer la whitelist dynamique depuis IN3
*/
private static function getDynamicWhitelist(): array {
$now = time();
// Vérifier le cache en mémoire (valide 1 heure)
if (self::$dynamicWhitelist !== null &&
self::$whitelistLastCheck !== null &&
($now - self::$whitelistLastCheck) < self::IN3_CONFIG['cache_duration']) {
return self::$dynamicWhitelist;
}
$dynamicIps = [];
// D'abord, essayer de lire le cache local
$cacheFile = self::IN3_CONFIG['local_cache_file'];
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData && isset($cacheData['ip']) && isset($cacheData['timestamp'])) {
// Si le cache a moins d'1 heure, l'utiliser
if (($now - $cacheData['timestamp']) < self::IN3_CONFIG['cache_duration']) {
$dynamicIps[] = $cacheData['ip'];
self::$dynamicWhitelist = $dynamicIps;
self::$whitelistLastCheck = $now;
return $dynamicIps;
}
}
}
// Sinon, récupérer depuis IN3 via SSH
try {
$command = sprintf(
'ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no %s@%s "cat %s 2>/dev/null"',
escapeshellarg(self::IN3_CONFIG['user']),
escapeshellarg(self::IN3_CONFIG['host']),
escapeshellarg(self::IN3_CONFIG['ip_file'])
);
$output = shell_exec($command);
if ($output) {
$ip = trim($output);
// Valider que c'est une IP valide
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$dynamicIps[] = $ip;
// Sauvegarder dans le cache local
$cacheDir = dirname($cacheFile);
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
file_put_contents($cacheFile, json_encode([
'ip' => $ip,
'timestamp' => $now,
'retrieved_at' => date('Y-m-d H:i:s')
]));
error_log("Whitelist IP mise à jour depuis IN3: $ip");
}
}
} catch (\Exception $e) {
error_log("Erreur lors de la récupération de l'IP depuis IN3: " . $e->getMessage());
// En cas d'erreur, utiliser le cache local même s'il est expiré
if (file_exists($cacheFile)) {
$cacheData = json_decode(file_get_contents($cacheFile), true);
if ($cacheData && isset($cacheData['ip'])) {
$dynamicIps[] = $cacheData['ip'];
error_log("Utilisation du cache local expiré: " . $cacheData['ip']);
}
}
}
self::$dynamicWhitelist = $dynamicIps;
self::$whitelistLastCheck = $now;
return $dynamicIps;
}
/**
* Vérifier si une IP est bloquée
*/
public static function isBlocked(string $ip): bool {
// Vérifier la whitelist
// Vérifier la whitelist statique
if (in_array($ip, self::WHITELIST)) {
return false;
}
// Vérifier la whitelist dynamique depuis IN3
$dynamicWhitelist = self::getDynamicWhitelist();
if (in_array($ip, $dynamicWhitelist)) {
return false;
}
// Vérifier le cache en mémoire
if (isset(self::$cache[$ip])) {
@@ -322,7 +421,33 @@ class IPBlocker {
return false;
}
}
/**
* Vérifier si une IP est dans la whitelist (méthode publique)
*/
public static function isWhitelisted(string $ip): bool {
// Vérifier la whitelist statique
if (in_array($ip, self::WHITELIST)) {
return true;
}
// Vérifier la whitelist dynamique
$dynamicWhitelist = self::getDynamicWhitelist();
return in_array($ip, $dynamicWhitelist);
}
/**
* Forcer la mise à jour de la whitelist dynamique
*/
public static function refreshDynamicWhitelist(): array {
// Forcer l'expiration du cache
self::$whitelistLastCheck = 0;
self::$dynamicWhitelist = null;
// Récupérer la nouvelle whitelist
return self::getDynamicWhitelist();
}
/**
* Obtenir la liste des IPs bloquées
*/

View File

@@ -463,7 +463,7 @@ class StripeService {
'success' => true,
'tap_to_pay_supported' => false,
'message' => 'Appareil non certifié pour Tap to Pay en France',
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 15.4+'
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
];
} catch (Exception $e) {

33
api/test_device_info.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Test de l'endpoint device-info
# Remplacez SESSION_ID par un ID de session valide
SESSION_ID="YOUR_SESSION_ID_HERE"
API_URL="http://localhost/api/users/device-info"
curl -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SESSION_ID" \
-d '{
"platform": "iOS",
"device_model": "iPhone13,2",
"device_name": "iPhone de Pierre",
"device_manufacturer": "Apple",
"device_identifier": "iPhone13,2",
"device_ip_local": "192.168.1.42",
"device_ip_public": "86.245.168.123",
"device_wifi_name": "Bbox-A1B2C3",
"device_wifi_bssid": "00:1A:2B:3C:4D:5E",
"ios_version": "17.2.1",
"device_nfc_capable": true,
"device_supports_tap_to_pay": true,
"battery_level": 85,
"battery_charging": false,
"battery_state": "discharging",
"last_device_info_check": "2024-12-28T10:30:45.123Z",
"app_version": "3.2.8",
"app_build": "328"
}'
echo ""