Compare commits

..

10 Commits

Author SHA1 Message Date
Serreau Jovann
eb884faea1 Align prod PHP container UID with bot user (1001) and harden restore.sh
Some checks failed
CI / sonarqube (push) Failing after 52s
Sur le serveur prod, l'utilisateur 'bot' (cree par Ansible) a UID 1001
alors que l'utilisateur 'appuser' du conteneur PHP etait hardcode a UID 1000,
ce qui causait des erreurs 'Unable to write in var/cache/prod' apres restore
ou deploiement: les fichiers du host appartenaient a bot:bot (1001) et
appuser (1000) ne pouvait pas y ecrire.

docker/php/prod/Dockerfile:
  - appuser UID/GID passe de 1000 a 1001 pour matcher bot
  - dev/Dockerfile reste a 1000 (les users dev sont generalement 1000)

ansible/deploy.yml:
  - chown des dossiers public/uploads/* et var/payouts passe de 1000 a 1001

restore.sh:
  - Nouvelle option --owner USER:GRP (defaut: bot:bot, override via RESTORE_OWNER)
  - Chown automatique en fin de restore sur var/, public/uploads/, config/cert/, .env.local
  - Purge automatique de var/cache (sera reconstruit par PHP)
  - Verification root au demarrage (sudo requis pour chown)
  - Verification de l'existence du user et group cibles
  - Option --skip-chown pour bypass (deconseille)
  - Etapes post-migration mises a jour (ajout de make build_prod en premier)

Procedure de migration sur le serveur apres ce commit:
  cd /var/www/e-ticket
  git pull origin master
  make build_prod              # rebuild image PHP avec UID 1001
  make stop_prod
  sudo chown -R bot:bot var public/uploads config/cert .env.local
  sudo rm -rf var/cache
  make start_prod
  make clear_prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:04:14 +02:00
Serreau Jovann
238ded3f54 Fix cp recursive flag for config/cert/ in backup.sh and restore.sh
Remplace 'cp -p' par 'cp -rp' lors de la copie de config/cert/.
Sans l'option -r, cp omet le repertoire avec l'erreur:
  cp: -r non specifie ; omission du repertoire 'config/cert/.'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:29:45 +02:00
Serreau Jovann
bb082c8368 Add server migration scripts: backup.sh (export) and restore.sh (import)
backup.sh genere un bundle de migration tar.gz autonome contenant:
- dump PostgreSQL gzippe (pg_dump --clean --if-exists --no-owner)
- archive public/uploads (events, billets, logos) repere via flysystem.yaml
- archive var/ (payouts, billets, invoices, unsubscribed.json)
- .env.local et config/cert/ (clef S/MIME), exclus si SKIP_SECRETS=1
- manifest.txt avec metadata (host, git commit/branche, date) et checksums SHA-256

restore.sh restaure un bundle sur un nouveau serveur:
- verifie les checksums SHA-256 avant toute action
- affiche un plan de restauration et demande confirmation (sauf --yes)
- options --skip-db, --skip-uploads, --skip-var, --skip-secrets
- cree des sauvegardes de securite avant ecrasement (.env.local, public/uploads, var/)
- verifie que le service db-master tourne avant restore PostgreSQL
- rappelle les etapes post-migration (migrate_prod, clear_prod)

Les deux scripts utilisent des variables d'env override (COMPOSE_FILE, DB_SERVICE,
DB_USER, DB_NAME) et s'appuient sur docker-compose-prod.yml par defaut.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:27:33 +02:00
Serreau Jovann
cda80990c7 Remove pending orders sync from StripeSyncCommand and add pessimistic lock to webhook payment handler
- Remove syncPendingOrders and its helpers (handleSucceeded, handleCancelled, handleFailed) from StripeSyncCommand
- Clean up unused dependencies (BilletOrderService, MailerService, AuditService, BilletBuyer)
- Add PESSIMISTIC_WRITE lock in handlePaymentIntentSucceeded to prevent duplicate ticket generation when Stripe sends concurrent webhook calls
- Update tests to match simplified command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:19:40 +02:00
Serreau Jovann
e32a2a2722 Remove unsupported ping_threshold option from mailer config to fix Symfony 8 cache:clear error
The ping_threshold option is not a valid framework.mailer configuration key in Symfony Mailer.
This caused composer install to fail during cache:clear on deployment.
The option should be set as a DSN query parameter on the MAILER_DSN environment variable instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:52:32 +02:00
Serreau Jovann
240489d330 Add ping_threshold to mailer config to prevent SES SMTP timeout errors
Set ping_threshold to 10 seconds so Symfony checks and reconnects stale
SMTP connections before reuse, fixing "451 4.4.2 Timeout waiting for
data from client" errors from Amazon SES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:46:15 +02:00
Serreau Jovann
7e2706b04f Pin Meilisearch image to v1.40.0 to match database version and prevent incompatible auto-upgrades
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:06:16 +02:00
Serreau Jovann
4b52b72266 Add attestation test with sold tickets to cover buildAttestationStats and buildAttestationTicketDetails
Creates a paid order with BilletOrder tickets before generating the
attestation, exercising the soldCounts loop (line 503) and ticket
details loop (lines 546-551) in AccountEventOperationsController.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:02:33 +02:00
Serreau Jovann
a11535726d Remove unused CONTENT_TYPE_PDF and PDF_SUFFIX constants from AccountController
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:01:16 +02:00
Serreau Jovann
0cf1160853 Reduce AccountController to 20 methods, remove unused AdminOrdersController constant
- Move export, exportPdf, payoutPdf from AccountController to
  AccountEventOperationsController (9 -> 12 methods)
- Remove getAllowedBilletTypes delegate from AccountController
- Update tests to reference AccountEventCatalogController for that method
- Remove unused DQL_EXCLUDE_INVITATIONS from AdminOrdersController

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:35:26 +02:00
12 changed files with 642 additions and 454 deletions

View File

@@ -157,8 +157,8 @@
file:
path: "/var/www/e-ticket/public/uploads/{{ item }}"
state: directory
owner: "1000"
group: "1000"
owner: "1001"
group: "1001"
mode: "0755"
recurse: true
loop:
@@ -168,8 +168,8 @@
file:
path: /var/www/e-ticket/var/payouts
state: directory
owner: "1000"
group: "1000"
owner: "1001"
group: "1001"
mode: "0755"
- name: Ensure Caddy sites directory exists

View File

@@ -181,7 +181,7 @@ services:
retries: 5
meilisearch:
image: getmeili/meilisearch:latest
image: getmeili/meilisearch:v1.40.0
restart: unless-stopped
deploy:
resources:

173
backup.sh Executable file
View File

@@ -0,0 +1,173 @@
#!/bin/bash
# E-Ticket - Script d'export pour migration de serveur
# Genere un bundle complet et autonome pour migrer l'application sur un autre serveur.
#
# Le bundle contient:
# - dump de la base PostgreSQL (db.sql.gz)
# - uploads Vich/Flysystem (events, billets, logos) (public_uploads.tar.gz)
# - etat applicatif var/ (var_state.tar.gz)
# payouts/, billets/, invoices/, unsubscribed.json
# - configuration runtime sensible (env.local, cert/)
# - manifest avec checksums SHA-256 (manifest.txt)
#
# Le tout est ensuite empaquete dans une archive .tar.gz transferable par scp/rsync.
#
# Usage:
# ./backup.sh [DOSSIER_DESTINATION]
#
# Variables d'environnement supportees:
# COMPOSE_FILE : fichier docker-compose a utiliser (defaut: docker-compose-prod.yml)
# DB_SERVICE : service Docker de la base (defaut: db-master)
# DB_USER : utilisateur PostgreSQL (defaut: e-ticket)
# DB_NAME : base de donnees a exporter (defaut: e-ticket)
# SKIP_SECRETS : '1' pour exclure .env.local + cert (defaut: inclus)
set -euo pipefail
# --- Configuration ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST_DIR="${1:-${SCRIPT_DIR}/var/migration}"
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/docker-compose-prod.yml}"
DB_SERVICE="${DB_SERVICE:-db-master}"
DB_USER="${DB_USER:-e-ticket}"
DB_NAME="${DB_NAME:-e-ticket}"
SKIP_SECRETS="${SKIP_SECRETS:-0}"
DATE="$(date +%Y%m%d_%H%M%S)"
BUNDLE_NAME="e_ticket_migration_${DATE}"
WORK_DIR="${DEST_DIR}/${BUNDLE_NAME}"
BUNDLE_FILE="${DEST_DIR}/${BUNDLE_NAME}.tar.gz"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
err() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERREUR: $*" >&2
}
cleanup() {
if [ -d "${WORK_DIR}" ]; then
rm -rf "${WORK_DIR}"
fi
}
# Cleanup du WORK_DIR meme en cas d'erreur (le BUNDLE_FILE final reste)
trap cleanup EXIT
# --- Verifications prealables ---
if [ ! -f "${COMPOSE_FILE}" ]; then
err "Fichier docker-compose introuvable: ${COMPOSE_FILE}"
exit 1
fi
if ! docker compose -f "${COMPOSE_FILE}" ps --services --status running 2>/dev/null | grep -qx "${DB_SERVICE}"; then
err "Le service '${DB_SERVICE}' n'est pas demarre"
err "Demarrer la stack avec: make start_prod"
exit 1
fi
mkdir -p "${WORK_DIR}"
log "=== Export de migration E-Ticket ==="
log "Bundle: ${BUNDLE_NAME}"
log "Destination: ${DEST_DIR}"
# --- 1. Dump base de donnees ---
log "[1/5] Dump PostgreSQL '${DB_NAME}' depuis ${DB_SERVICE}..."
if ! docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
pg_dump -U "${DB_USER}" -d "${DB_NAME}" \
--clean --if-exists --no-owner --no-privileges --quote-all-identifiers \
| gzip > "${WORK_DIR}/db.sql.gz"; then
err "Echec du dump PostgreSQL"
exit 1
fi
if [ ! -s "${WORK_DIR}/db.sql.gz" ]; then
err "Le dump PostgreSQL est vide"
exit 1
fi
log " OK ($(du -h "${WORK_DIR}/db.sql.gz" | cut -f1))"
# --- 2. Uploads publics (events, billets, logos) ---
log "[2/5] Archivage public/uploads (events, billets, logos)..."
if [ -d "${SCRIPT_DIR}/public/uploads" ]; then
tar -czf "${WORK_DIR}/public_uploads.tar.gz" -C "${SCRIPT_DIR}/public" uploads
log " OK ($(du -h "${WORK_DIR}/public_uploads.tar.gz" | cut -f1))"
else
err "public/uploads introuvable"
exit 1
fi
# --- 3. Etat applicatif var/ (PDFs generes, desinscrits...) ---
log "[3/5] Archivage etat applicatif var/ (payouts, billets, invoices, unsubscribed.json)..."
VAR_ITEMS=()
for item in payouts billets invoices unsubscribed.json; do
if [ -e "${SCRIPT_DIR}/var/${item}" ]; then
VAR_ITEMS+=("${item}")
fi
done
if [ ${#VAR_ITEMS[@]} -gt 0 ]; then
tar -czf "${WORK_DIR}/var_state.tar.gz" -C "${SCRIPT_DIR}/var" "${VAR_ITEMS[@]}"
log " OK - elements inclus: ${VAR_ITEMS[*]} ($(du -h "${WORK_DIR}/var_state.tar.gz" | cut -f1))"
else
log " Aucun element a archiver dans var/ (saut)"
fi
# --- 4. Configuration runtime sensible ---
if [ "${SKIP_SECRETS}" = "1" ]; then
log "[4/5] Secrets exclus (SKIP_SECRETS=1)"
log " ATTENTION: vous devrez deployer .env.local et config/cert/ manuellement sur le nouveau serveur"
else
log "[4/5] Archivage configuration sensible (.env.local + config/cert/)..."
SECRET_ITEMS=()
if [ -f "${SCRIPT_DIR}/.env.local" ]; then
cp -p "${SCRIPT_DIR}/.env.local" "${WORK_DIR}/env.local"
chmod 600 "${WORK_DIR}/env.local"
SECRET_ITEMS+=(".env.local")
fi
if [ -d "${SCRIPT_DIR}/config/cert" ]; then
mkdir -p "${WORK_DIR}/cert"
cp -rp "${SCRIPT_DIR}/config/cert/." "${WORK_DIR}/cert/"
chmod -R go-rwx "${WORK_DIR}/cert"
SECRET_ITEMS+=("config/cert/")
fi
if [ ${#SECRET_ITEMS[@]} -gt 0 ]; then
log " OK - elements inclus: ${SECRET_ITEMS[*]}"
else
log " Aucun secret trouve a archiver (saut)"
fi
fi
# --- 5. Manifest avec checksums et metadata ---
log "[5/5] Generation du manifest..."
{
echo "# E-Ticket migration bundle"
echo "bundle_name: ${BUNDLE_NAME}"
echo "created_at: $(date --iso-8601=seconds)"
echo "source_host: $(hostname)"
echo "git_commit: $(git -C "${SCRIPT_DIR}" rev-parse HEAD 2>/dev/null || echo 'unknown')"
echo "git_branch: $(git -C "${SCRIPT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')"
echo "db_name: ${DB_NAME}"
echo "db_user: ${DB_USER}"
echo "skip_secrets: ${SKIP_SECRETS}"
echo
echo "# SHA-256 checksums"
(cd "${WORK_DIR}" && find . -type f ! -name 'manifest.txt' -print0 | sort -z | xargs -0 sha256sum)
} > "${WORK_DIR}/manifest.txt"
log " OK"
# --- Empaquetage final ---
log "Empaquetage du bundle dans ${BUNDLE_FILE}..."
tar -czf "${BUNDLE_FILE}" -C "${DEST_DIR}" "${BUNDLE_NAME}"
BUNDLE_SIZE="$(du -h "${BUNDLE_FILE}" | cut -f1)"
BUNDLE_SHA="$(sha256sum "${BUNDLE_FILE}" | cut -d' ' -f1)"
log "=== Export termine ==="
log "Bundle : ${BUNDLE_FILE}"
log "Taille : ${BUNDLE_SIZE}"
log "SHA-256 : ${BUNDLE_SHA}"
log
log "Pour transferer vers le nouveau serveur:"
log " scp '${BUNDLE_FILE}' user@nouveau-serveur:/var/www/e-ticket/"
log "Puis sur le nouveau serveur:"
log " ./restore.sh '${BUNDLE_NAME}.tar.gz'"

View File

@@ -24,7 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
opcache \
&& pecl install redis imagick \
&& docker-php-ext-enable redis imagick \
&& groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
&& groupadd -g 1001 appuser && useradd -u 1001 -g appuser -m appuser
COPY php.ini /usr/local/etc/php/conf.d/app.ini
COPY opcache.ini /usr/local/etc/php/conf.d/opcache.ini

327
restore.sh Executable file
View File

@@ -0,0 +1,327 @@
#!/bin/bash
# E-Ticket - Script d'import pour migration de serveur
# Restaure un bundle de migration genere par backup.sh sur un nouveau serveur.
#
# Le bundle est attendu au format e_ticket_migration_<DATE>.tar.gz et contient:
# db.sql.gz, public_uploads.tar.gz, var_state.tar.gz, env.local, cert/, manifest.txt
#
# A la fin, le script chown automatiquement les fichiers restaures vers RESTORE_OWNER
# (defaut: bot) afin que l'utilisateur PHP du conteneur puisse y ecrire, et purge
# var/cache pour eviter les fichiers obsoletes appartenant a un autre utilisateur.
#
# Usage:
# ./restore.sh <bundle.tar.gz> [options]
#
# Variables d'environnement supportees:
# COMPOSE_FILE : fichier docker-compose a utiliser (defaut: docker-compose-prod.yml)
# DB_SERVICE : service Docker de la base (defaut: db-master)
# DB_USER : utilisateur PostgreSQL (defaut: e-ticket)
# DB_NAME : base de donnees a restaurer (defaut: e-ticket)
# RESTORE_OWNER : user:group pour le chown final (defaut: bot:bot)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/docker-compose-prod.yml}"
DB_SERVICE="${DB_SERVICE:-db-master}"
DB_USER="${DB_USER:-e-ticket}"
DB_NAME="${DB_NAME:-e-ticket}"
RESTORE_OWNER="${RESTORE_OWNER:-bot:bot}"
BUNDLE=""
ASSUME_YES="false"
SKIP_DB="false"
SKIP_UPLOADS="false"
SKIP_VAR="false"
SKIP_SECRETS="false"
SKIP_CHOWN="false"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
err() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERREUR: $*" >&2
}
usage() {
cat <<'EOF'
Usage:
sudo ./restore.sh <bundle.tar.gz> [options]
Options:
--yes, -y Mode non interactif (pas de confirmation)
--skip-db Ne pas restaurer la base de donnees
--skip-uploads Ne pas restaurer public/uploads
--skip-var Ne pas restaurer var/ (payouts, billets, invoices, unsubscribed.json)
--skip-secrets Ne pas restaurer .env.local et config/cert/
--skip-chown Ne pas faire le chown final (deconseille)
--owner USER:GRP Utilisateur:groupe pour le chown final (defaut: bot:bot)
-h, --help Affiche cette aide
Variables d'environnement (alternatives aux flags):
RESTORE_OWNER Equivalent de --owner
ATTENTION: La restauration ECRASE les donnees existantes !
ATTENTION: Le chown final necessite root (sudo).
EOF
}
# --- Parsing des arguments ---
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) ASSUME_YES="true"; shift ;;
--skip-db) SKIP_DB="true"; shift ;;
--skip-uploads) SKIP_UPLOADS="true"; shift ;;
--skip-var) SKIP_VAR="true"; shift ;;
--skip-secrets) SKIP_SECRETS="true"; shift ;;
--skip-chown) SKIP_CHOWN="true"; shift ;;
--owner)
if [ $# -lt 2 ]; then
err "--owner requiert une valeur (ex: --owner bot:bot)"
exit 1
fi
RESTORE_OWNER="$2"
shift 2
;;
-h|--help) usage; exit 0 ;;
--*) err "Option inconnue: $1"; usage; exit 1 ;;
*)
if [ -z "${BUNDLE}" ]; then
BUNDLE="$1"
else
err "Argument en trop: $1"
usage
exit 1
fi
shift
;;
esac
done
# --- Verification root pour le chown final ---
if [ "${SKIP_CHOWN}" = "false" ] && [ "$(id -u)" != "0" ]; then
err "Ce script doit etre execute en root (sudo) pour le chown final vers ${RESTORE_OWNER}"
err "Soit relancer avec sudo, soit ajouter --skip-chown (deconseille)"
exit 1
fi
# --- Verification que le user/group cible existe ---
if [ "${SKIP_CHOWN}" = "false" ]; then
OWNER_USER="${RESTORE_OWNER%%:*}"
OWNER_GROUP="${RESTORE_OWNER##*:}"
if ! id -u "${OWNER_USER}" >/dev/null 2>&1; then
err "Utilisateur '${OWNER_USER}' introuvable sur le systeme"
exit 1
fi
if ! getent group "${OWNER_GROUP}" >/dev/null 2>&1; then
err "Groupe '${OWNER_GROUP}' introuvable sur le systeme"
exit 1
fi
fi
if [ -z "${BUNDLE}" ]; then
err "Aucun bundle specifie"
usage
exit 1
fi
if [ ! -f "${BUNDLE}" ]; then
err "Bundle introuvable: ${BUNDLE}"
exit 1
fi
# --- Extraction du bundle dans un dossier temporaire ---
TMP_DIR="$(mktemp -d -t e_ticket_restore_XXXXXX)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
log "=== Restauration du bundle de migration E-Ticket ==="
log "Bundle: ${BUNDLE}"
log "Extraction dans ${TMP_DIR}..."
if ! tar -xzf "${BUNDLE}" -C "${TMP_DIR}"; then
err "Echec d'extraction du bundle"
exit 1
fi
# Trouve le dossier extrait (e_ticket_migration_<DATE>)
EXTRACTED_DIR="$(find "${TMP_DIR}" -maxdepth 1 -mindepth 1 -type d -name 'e_ticket_migration_*' | head -n1)"
if [ -z "${EXTRACTED_DIR}" ] || [ ! -d "${EXTRACTED_DIR}" ]; then
err "Bundle invalide: dossier e_ticket_migration_* introuvable"
exit 1
fi
# --- Verification du manifest et des checksums ---
if [ ! -f "${EXTRACTED_DIR}/manifest.txt" ]; then
err "Manifest manquant dans le bundle"
exit 1
fi
log "Verification des checksums..."
(cd "${EXTRACTED_DIR}" && grep -E '^[a-f0-9]{64} ' manifest.txt | sha256sum -c --quiet) || {
err "Checksums invalides - bundle corrompu"
exit 1
}
log " OK - bundle integre"
echo
echo "================================================================"
echo " Manifest du bundle:"
echo "================================================================"
grep -E '^(bundle_name|created_at|source_host|git_commit|git_branch|db_name|skip_secrets):' "${EXTRACTED_DIR}/manifest.txt" || true
echo "================================================================"
# --- Verification des composants disponibles ---
HAS_DB="false"
HAS_UPLOADS="false"
HAS_VAR="false"
HAS_ENV="false"
HAS_CERT="false"
[ -f "${EXTRACTED_DIR}/db.sql.gz" ] && HAS_DB="true"
[ -f "${EXTRACTED_DIR}/public_uploads.tar.gz" ] && HAS_UPLOADS="true"
[ -f "${EXTRACTED_DIR}/var_state.tar.gz" ] && HAS_VAR="true"
[ -f "${EXTRACTED_DIR}/env.local" ] && HAS_ENV="true"
[ -d "${EXTRACTED_DIR}/cert" ] && HAS_CERT="true"
echo
echo " Plan de restauration:"
echo "----------------------------------------------------------------"
[ "${HAS_DB}" = "true" ] && [ "${SKIP_DB}" = "false" ] && echo " [X] Base de donnees -> ${DB_NAME} (ECRASE)" || echo " [ ] Base de donnees"
[ "${HAS_UPLOADS}" = "true" ] && [ "${SKIP_UPLOADS}" = "false" ] && echo " [X] public/uploads/ (ECRASE, sauvegarde de securite)" || echo " [ ] public/uploads/"
[ "${HAS_VAR}" = "true" ] && [ "${SKIP_VAR}" = "false" ] && echo " [X] var/ (payouts, billets, invoices, unsubscribed.json)" || echo " [ ] var/"
[ "${HAS_ENV}" = "true" ] && [ "${SKIP_SECRETS}" = "false" ] && echo " [X] .env.local (ECRASE)" || echo " [ ] .env.local"
[ "${HAS_CERT}" = "true" ] && [ "${SKIP_SECRETS}" = "false" ] && echo " [X] config/cert/ (ECRASE)" || echo " [ ] config/cert/"
echo "----------------------------------------------------------------"
echo
if [ "${ASSUME_YES}" != "true" ]; then
read -r -p "Confirmer la restauration ? (tapez 'oui' pour continuer) : " CONFIRM
if [ "${CONFIRM}" != "oui" ]; then
log "Restauration annulee par l'utilisateur"
exit 0
fi
fi
# --- 1. Secrets (en premier pour que la stack puisse demarrer si besoin) ---
if [ "${SKIP_SECRETS}" = "false" ]; then
if [ "${HAS_ENV}" = "true" ]; then
log "[secrets] Restauration de .env.local..."
if [ -f "${SCRIPT_DIR}/.env.local" ]; then
cp -p "${SCRIPT_DIR}/.env.local" "${SCRIPT_DIR}/.env.local.before_restore_$(date +%Y%m%d_%H%M%S)"
fi
cp -p "${EXTRACTED_DIR}/env.local" "${SCRIPT_DIR}/.env.local"
chmod 600 "${SCRIPT_DIR}/.env.local"
log " OK"
fi
if [ "${HAS_CERT}" = "true" ]; then
log "[secrets] Restauration de config/cert/..."
mkdir -p "${SCRIPT_DIR}/config/cert"
cp -rp "${EXTRACTED_DIR}/cert/." "${SCRIPT_DIR}/config/cert/"
chmod -R go-rwx "${SCRIPT_DIR}/config/cert"
log " OK"
fi
fi
# --- 2. Base de donnees ---
if [ "${HAS_DB}" = "true" ] && [ "${SKIP_DB}" = "false" ]; then
log "[db] Verification du service ${DB_SERVICE}..."
if [ ! -f "${COMPOSE_FILE}" ]; then
err "Fichier docker-compose introuvable: ${COMPOSE_FILE}"
exit 1
fi
if ! docker compose -f "${COMPOSE_FILE}" ps --services --status running 2>/dev/null | grep -qx "${DB_SERVICE}"; then
err "Le service '${DB_SERVICE}' n'est pas demarre. Lancer: make start_prod"
exit 1
fi
log "[db] Restauration de la base ${DB_NAME}..."
if ! gunzip -c "${EXTRACTED_DIR}/db.sql.gz" \
| docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
psql -U "${DB_USER}" -d "${DB_NAME}" -v ON_ERROR_STOP=1 >/dev/null; then
err "Echec de la restauration PostgreSQL"
exit 1
fi
log " OK"
fi
# --- 3. public/uploads ---
if [ "${HAS_UPLOADS}" = "true" ] && [ "${SKIP_UPLOADS}" = "false" ]; then
log "[uploads] Restauration de public/uploads..."
if [ -d "${SCRIPT_DIR}/public/uploads" ]; then
SAFETY="${SCRIPT_DIR}/public/uploads.before_restore_$(date +%Y%m%d_%H%M%S)"
log " Sauvegarde de securite: ${SAFETY}"
mv "${SCRIPT_DIR}/public/uploads" "${SAFETY}"
fi
mkdir -p "${SCRIPT_DIR}/public"
if ! tar -xzf "${EXTRACTED_DIR}/public_uploads.tar.gz" -C "${SCRIPT_DIR}/public"; then
err "Echec d'extraction de public_uploads.tar.gz"
exit 1
fi
log " OK"
fi
# --- 4. var/ (PDFs generes, unsubscribed.json...) ---
if [ "${HAS_VAR}" = "true" ] && [ "${SKIP_VAR}" = "false" ]; then
log "[var] Restauration de var/ (payouts, billets, invoices, unsubscribed.json)..."
mkdir -p "${SCRIPT_DIR}/var"
# Sauvegarde de securite des elements existants qui seront ecrases
SAFETY_VAR="${SCRIPT_DIR}/var/.before_restore_$(date +%Y%m%d_%H%M%S)"
mkdir -p "${SAFETY_VAR}"
for item in payouts billets invoices unsubscribed.json; do
if [ -e "${SCRIPT_DIR}/var/${item}" ]; then
mv "${SCRIPT_DIR}/var/${item}" "${SAFETY_VAR}/"
fi
done
# Si rien n'a ete sauvegarde, on supprime le dossier vide
if [ -z "$(ls -A "${SAFETY_VAR}")" ]; then
rmdir "${SAFETY_VAR}"
else
log " Sauvegarde de securite: ${SAFETY_VAR}"
fi
if ! tar -xzf "${EXTRACTED_DIR}/var_state.tar.gz" -C "${SCRIPT_DIR}/var"; then
err "Echec d'extraction de var_state.tar.gz"
exit 1
fi
log " OK"
fi
# --- 5. Purge du cache Symfony ---
# Indispensable: var/cache contient des fichiers compiles avec les anciens
# uid/gid et peut empecher PHP d'ecrire au demarrage suivant.
if [ -d "${SCRIPT_DIR}/var/cache" ]; then
log "[cache] Purge de var/cache (sera reconstruit par PHP au demarrage)..."
rm -rf "${SCRIPT_DIR}/var/cache"
log " OK"
fi
# --- 6. Chown final pour matcher l'utilisateur PHP du conteneur ---
if [ "${SKIP_CHOWN}" = "false" ]; then
log "[chown] Application de '${RESTORE_OWNER}' sur les fichiers restaures..."
CHOWN_TARGETS=()
[ -d "${SCRIPT_DIR}/var" ] && CHOWN_TARGETS+=("${SCRIPT_DIR}/var")
[ -d "${SCRIPT_DIR}/public/uploads" ] && CHOWN_TARGETS+=("${SCRIPT_DIR}/public/uploads")
[ -d "${SCRIPT_DIR}/config/cert" ] && CHOWN_TARGETS+=("${SCRIPT_DIR}/config/cert")
[ -f "${SCRIPT_DIR}/.env.local" ] && CHOWN_TARGETS+=("${SCRIPT_DIR}/.env.local")
if [ ${#CHOWN_TARGETS[@]} -gt 0 ]; then
chown -R "${RESTORE_OWNER}" "${CHOWN_TARGETS[@]}"
log " OK - cibles: ${CHOWN_TARGETS[*]}"
else
log " Aucune cible a chown"
fi
else
log "[chown] Saut du chown final (--skip-chown)"
log " ATTENTION: PHP pourrait ne pas avoir les droits d'ecriture sur var/, public/uploads/..."
fi
log "=== Migration terminee avec succes ==="
log
log "Etapes recommandees post-migration:"
log " 1. make build_prod (si l'image PHP n'a pas ete reconstruite avec le bon UID)"
log " 2. make start_prod (si pas deja fait)"
log " 3. make migrate_prod (appliquer les migrations Doctrine eventuelles)"
log " 4. make clear_prod (vider le cache)"
log " 5. Verifier le bon fonctionnement de l'application"

View File

@@ -2,11 +2,7 @@
namespace App\Command;
use App\Entity\BilletBuyer;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\BilletOrderService;
use App\Service\MailerService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -17,16 +13,13 @@ use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:stripe:sync',
description: 'Sync Stripe account status for all organizers and reconcile pending orders',
description: 'Sync Stripe account status for all organizers',
)]
class StripeSyncCommand extends Command
{
public function __construct(
private EntityManagerInterface $em,
private StripeService $stripeService,
private BilletOrderService $billetOrderService,
private MailerService $mailerService,
private AuditService $audit,
) {
parent::__construct();
}
@@ -36,7 +29,6 @@ class StripeSyncCommand extends Command
$io = new SymfonyStyle($input, $output);
$hasErrors = $this->syncAccounts($io);
$hasErrors = $this->syncPendingOrders($io) || $hasErrors;
return $hasErrors ? Command::FAILURE : Command::SUCCESS;
}
@@ -101,115 +93,4 @@ class StripeSyncCommand extends Command
return $errors > 0;
}
private function syncPendingOrders(SymfonyStyle $io): bool
{
$pendingOrders = $this->em->getRepository(BilletBuyer::class)->findBy([
'status' => BilletBuyer::STATUS_PENDING,
]);
if (0 === \count($pendingOrders)) {
$io->info('No pending orders to reconcile.');
return false;
}
$io->info(sprintf('Checking %d pending order(s) on Stripe...', \count($pendingOrders)));
$processed = 0;
$errors = 0;
foreach ($pendingOrders as $order) {
$paymentIntentId = $order->getStripeSessionId();
$stripeAccountId = $order->getEvent()?->getAccount()?->getStripeAccountId();
if (!$paymentIntentId || !$stripeAccountId) {
$io->text(sprintf(' [<fg=yellow>SKIP</>] Order #%s — no payment intent ID or Stripe account', $order->getOrderNumber()));
continue;
}
try {
$paymentIntent = $this->stripeService->retrievePaymentIntent($paymentIntentId, $stripeAccountId);
$stripeStatus = $paymentIntent->status;
match ($stripeStatus) {
'succeeded' => $this->handleSucceeded($order, $paymentIntent, $io),
'canceled' => $this->handleCancelled($order, $io),
'requires_payment_method' => $this->handleFailed($order, $paymentIntent, $io),
default => $io->text(sprintf(
' [<fg=blue>PENDING</>] Order #%s — Stripe status: %s',
$order->getOrderNumber(),
$stripeStatus,
)),
};
++$processed;
} catch (\Throwable $e) {
$io->text(sprintf(
' [<fg=red>ERROR</>] Order #%s — %s',
$order->getOrderNumber(),
$e->getMessage(),
));
++$errors;
}
}
$this->em->flush();
$io->success(sprintf('Orders: %d checked, %d error(s).', $processed, $errors));
return $errors > 0;
}
private function handleSucceeded(BilletBuyer $order, \Stripe\PaymentIntent $paymentIntent, SymfonyStyle $io): void
{
$debtOrganizerId = $paymentIntent->metadata->debt_organizer_id ?? null;
if ($debtOrganizerId) {
$organizer = $this->em->getRepository(User::class)->find((int) $debtOrganizerId);
if ($organizer) {
$organizer->reduceDebt($paymentIntent->amount ?? 0);
}
}
$this->billetOrderService->generateOrderTickets($order);
$this->billetOrderService->generateAndSendTickets($order);
$this->billetOrderService->notifyOrganizer($order);
$io->text(sprintf(' [<fg=green>PAID</>] Order #%s — tickets generated and sent', $order->getOrderNumber()));
}
private function handleCancelled(BilletBuyer $order, SymfonyStyle $io): void
{
$order->setStatus(BilletBuyer::STATUS_CANCELLED);
$this->em->flush();
$this->audit->log('payment_cancelled_sync', 'BilletBuyer', $order->getId(), [
'orderNumber' => $order->getOrderNumber(),
]);
$io->text(sprintf(' [<fg=red>CANCELLED</>] Order #%s', $order->getOrderNumber()));
}
private function handleFailed(BilletBuyer $order, \Stripe\PaymentIntent $paymentIntent, SymfonyStyle $io): void
{
$errorMessage = $paymentIntent->last_payment_error->message ?? 'Paiement refuse';
$order->setStatus(BilletBuyer::STATUS_CANCELLED);
$this->em->flush();
$this->audit->log('payment_failed_sync', 'BilletBuyer', $order->getId(), [
'orderNumber' => $order->getOrderNumber(),
'error' => $errorMessage,
]);
if ($order->getEmail()) {
$this->mailerService->sendEmail(
$order->getEmail(),
'Echec de paiement - '.$order->getEvent()->getTitle(),
'Votre paiement pour la commande '.$order->getOrderNumber().' a echoue : '.$errorMessage,
);
}
$io->text(sprintf(' [<fg=red>FAILED</>] Order #%s — %s', $order->getOrderNumber(), $errorMessage));
}
}

View File

@@ -13,10 +13,8 @@ use App\Entity\Payout;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\EventIndexService;
use App\Service\ExportService;
use App\Service\MailerService;
use App\Service\OrderIndexService;
use App\Service\PayoutPdfService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
@@ -37,8 +35,6 @@ class AccountController extends AbstractController
private const EVENT_BASE_URL = '/mon-compte/evenement/';
private const DQL_EXCLUDE_INVITATIONS = 'o.isInvitation = false OR o.isInvitation IS NULL';
private const DQL_BB_EXCLUDE_INVITATIONS = 'bb.isInvitation = false OR bb.isInvitation IS NULL';
private const CONTENT_TYPE_PDF = 'application/pdf';
private const PDF_SUFFIX = '.pdf"';
#[Route('/mon-compte', name: 'app_account')]
public function index(Request $request, StripeService $stripeService, EntityManagerInterface $em, PaginatorInterface $paginator, EventIndexService $eventIndex): Response
@@ -595,73 +591,6 @@ class AccountController extends AbstractController
}
}
/** @codeCoverageIgnore Generates PDF with dompdf */
#[Route('/mon-compte/payout/{id}/attestation', name: 'app_account_payout_pdf')]
public function payoutPdf(Payout $payout, PayoutPdfService $pdfService): Response
{
/** @var User $user */
$user = $this->getUser();
if ($payout->getOrganizer()->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
return new Response($pdfService->generate($payout), 200, [
'Content-Type' => self::CONTENT_TYPE_PDF,
'Content-Disposition' => 'inline; filename="attestation_'.$payout->getStripePayoutId().self::PDF_SUFFIX,
]);
}
#[Route('/mon-compte/export/{year}/{month}', name: 'app_account_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function export(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
return $redirect;
} // @codeCoverageIgnoreEnd
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$csv = $exportService->generateCsv($stats['orders']);
$filename = sprintf('export_%04d_%02d.csv', $year, $month);
return new Response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
#[Route('/mon-compte/export/{year}/{month}/pdf', name: 'app_account_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function exportPdf(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
return $redirect;
} // @codeCoverageIgnoreEnd
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$pdf = $exportService->generatePdf($stats, $year, $month, $user);
$filename = sprintf('recap_%04d_%02d.pdf', $year, $month);
return new Response($pdf, 200, [
'Content-Type' => self::CONTENT_TYPE_PDF,
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
/**
* @return string[]
*/
public static function getAllowedBilletTypes(?string $offer): array
{
return AccountEventCatalogController::getAllowedBilletTypes($offer);
}
/**
* @param list<BilletBuyer> $paidOrders
*

View File

@@ -10,8 +10,12 @@ use App\Entity\BilletBuyerItem;
use App\Entity\BilletOrder;
use App\Entity\Category;
use App\Entity\Event;
use App\Entity\Payout;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\BilletOrderService;
use App\Service\ExportService;
use App\Service\PayoutPdfService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -549,4 +553,63 @@ class AccountEventOperationsController extends AbstractController
return $details;
}
/** @codeCoverageIgnore Generates PDF with dompdf */
#[Route('/mon-compte/payout/{id}/attestation', name: 'app_account_payout_pdf')]
public function payoutPdf(Payout $payout, PayoutPdfService $pdfService): Response
{
/** @var User $user */
$user = $this->getUser();
if ($payout->getOrganizer()->getId() !== $user->getId()) {
throw $this->createAccessDeniedException();
}
return new Response($pdfService->generate($payout), 200, [
'Content-Type' => self::CONTENT_TYPE_PDF,
'Content-Disposition' => 'inline; filename="attestation_'.$payout->getStripePayoutId().self::PDF_SUFFIX,
]);
}
#[Route('/mon-compte/export/{year}/{month}', name: 'app_account_export', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function export(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
return $redirect;
} // @codeCoverageIgnoreEnd
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$csv = $exportService->generateCsv($stats['orders']);
$filename = sprintf('export_%04d_%02d.csv', $year, $month);
return new Response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
#[Route('/mon-compte/export/{year}/{month}/pdf', name: 'app_account_export_pdf', requirements: ['year' => '\d{4}', 'month' => '\d{1,2}'], methods: ['GET'])]
public function exportPdf(int $year, int $month, ExportService $exportService): Response
{
$this->denyAccessUnlessGranted('ROLE_ORGANIZER');
if ($redirect = $this->requireStripeReady()) { // @codeCoverageIgnoreStart
return $redirect;
} // @codeCoverageIgnoreEnd
/** @var User $user */
$user = $this->getUser();
$stats = $exportService->getMonthlyStats($year, $month, $user);
$pdf = $exportService->generatePdf($stats, $year, $month, $user);
$filename = sprintf('recap_%04d_%02d.pdf', $year, $month);
return new Response($pdf, 200, [
'Content-Type' => self::CONTENT_TYPE_PDF,
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}

View File

@@ -25,7 +25,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AdminOrdersController extends AbstractController
{
private const DQL_STATUS_PAID = 'o.status = :paid';
private const DQL_EXCLUDE_INVITATIONS = 'o.isInvitation = false OR o.isInvitation IS NULL';
#[Route('/commandes', name: 'app_admin_orders', methods: ['GET'])]
public function orders(Request $request, EntityManagerInterface $em, PaginatorInterface $paginator): Response

View File

@@ -12,6 +12,7 @@ use App\Service\BilletOrderService;
use App\Service\MailerService;
use App\Service\PayoutPdfService;
use App\Service\StripeService;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -157,6 +158,23 @@ class StripeWebhookController extends AbstractController
return;
}
$canProcess = $em->wrapInTransaction(function () use ($order, $em): bool {
$em->refresh($order, LockMode::PESSIMISTIC_WRITE);
if (BilletBuyer::STATUS_PENDING !== $order->getStatus()) {
return false;
}
$order->setStatus(BilletBuyer::STATUS_PAID);
$order->setPaidAt(new \DateTimeImmutable());
return true;
});
if (!$canProcess) {
return;
}
$debtOrganizerId = $paymentIntent->metadata->debt_organizer_id ?? null;
if ($debtOrganizerId) {
$organizer = $em->getRepository(User::class)->find((int) $debtOrganizerId);

View File

@@ -3,12 +3,7 @@
namespace App\Tests\Command;
use App\Command\StripeSyncCommand;
use App\Entity\BilletBuyer;
use App\Entity\Event;
use App\Entity\User;
use App\Service\AuditService;
use App\Service\BilletOrderService;
use App\Service\MailerService;
use App\Service\StripeService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@@ -19,29 +14,19 @@ use Symfony\Component\Console\Tester\CommandTester;
class StripeSyncCommandTest extends TestCase
{
private StripeService $stripeService;
private BilletOrderService $billetOrderService;
private MailerService $mailerService;
private AuditService $audit;
private EntityManagerInterface $em;
private EntityRepository $userRepo;
private EntityRepository $buyerRepo;
protected function setUp(): void
{
$this->stripeService = $this->createMock(StripeService::class);
$this->billetOrderService = $this->createMock(BilletOrderService::class);
$this->mailerService = $this->createMock(MailerService::class);
$this->audit = $this->createMock(AuditService::class);
$this->userRepo = $this->createMock(EntityRepository::class);
$this->buyerRepo = $this->createMock(EntityRepository::class);
$this->buyerRepo->method('findBy')->willReturn([]);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->em->method('getRepository')->willReturnCallback(function (string $class) {
return match ($class) {
User::class => $this->userRepo,
BilletBuyer::class => $this->buyerRepo,
default => $this->createMock(EntityRepository::class),
};
});
@@ -63,29 +48,11 @@ class StripeSyncCommandTest extends TestCase
return $user;
}
private function createPendingOrder(?string $paymentIntentId = null): BilletBuyer
{
$event = $this->createMock(Event::class);
$event->method('getTitle')->willReturn('Test Event');
$event->method('getAccount')->willReturn($this->createOrganizer('acct_org'));
$order = new BilletBuyer();
$order->setStatus(BilletBuyer::STATUS_PENDING);
$order->setStripeSessionId($paymentIntentId);
$order->setEmail('buyer@test.fr');
$order->setEvent($event);
return $order;
}
private function createCommandTester(): CommandTester
{
$command = new StripeSyncCommand(
$this->em,
$this->stripeService,
$this->billetOrderService,
$this->mailerService,
$this->audit,
);
$app = new Application();
@@ -94,23 +61,6 @@ class StripeSyncCommandTest extends TestCase
return new CommandTester($app->find('app:stripe:sync'));
}
private function setBuyerRepo(array $orders): void
{
$this->buyerRepo = $this->createMock(EntityRepository::class);
$this->buyerRepo->method('findBy')->willReturn($orders);
$this->em = $this->createMock(EntityManagerInterface::class);
$this->em->method('getRepository')->willReturnCallback(function (string $class) {
return match ($class) {
User::class => $this->userRepo,
BilletBuyer::class => $this->buyerRepo,
default => $this->createMock(EntityRepository::class),
};
});
}
// --- Account sync tests ---
public function testSyncUpdatesStripeStatus(): void
{
$user = $this->createOrganizer('acct_123');
@@ -195,206 +145,4 @@ class StripeSyncCommandTest extends TestCase
self::assertTrue($userWithStripe->isStripeChargesEnabled());
self::assertFalse($userWithStripe->isStripePayoutsEnabled());
}
// --- Pending orders sync tests ---
public function testNoPendingOrders(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertStringContainsString('No pending orders', $tester->getDisplay());
}
public function testPendingOrderSucceeded(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder('pi_succeeded');
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_succeeded',
'status' => 'succeeded',
'amount' => 5000,
'metadata' => [],
]);
$this->stripeService->method('retrievePaymentIntent')
->with('pi_succeeded')
->willReturn($paymentIntent);
$this->billetOrderService->expects(self::once())->method('generateOrderTickets')->with($order);
$this->billetOrderService->expects(self::once())->method('generateAndSendTickets')->with($order);
$this->billetOrderService->expects(self::once())->method('notifyOrganizer')->with($order);
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertStringContainsString('PAID', $tester->getDisplay());
}
public function testPendingOrderSucceededWithDebtOrganizer(): void
{
$organizer = $this->createOrganizer('acct_debt');
$this->userRepo->method('findAll')->willReturn([]);
$this->userRepo->method('find')->with(42)->willReturn($organizer);
$order = $this->createPendingOrder('pi_debt');
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_debt',
'status' => 'succeeded',
'amount' => 3000,
'metadata' => ['debt_organizer_id' => '42'],
]);
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertStringContainsString('PAID', $tester->getDisplay());
}
public function testPendingOrderCanceled(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder('pi_canceled');
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_canceled',
'status' => 'canceled',
]);
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
$this->audit->expects(self::once())->method('log')
->with('payment_cancelled_sync', 'BilletBuyer', self::anything(), self::anything());
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
self::assertStringContainsString('CANCELLED', $tester->getDisplay());
}
public function testPendingOrderFailed(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder('pi_failed');
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_failed',
'status' => 'requires_payment_method',
'last_payment_error' => ['message' => 'Card declined'],
]);
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
$this->audit->expects(self::once())->method('log')
->with('payment_failed_sync', 'BilletBuyer', self::anything(), self::anything());
$this->mailerService->expects(self::once())->method('sendEmail');
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
self::assertStringContainsString('FAILED', $tester->getDisplay());
}
public function testPendingOrderSkippedWithoutPaymentIntentId(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder(null);
$this->setBuyerRepo([$order]);
$this->stripeService->expects(self::never())->method('retrievePaymentIntent');
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertStringContainsString('SKIP', $tester->getDisplay());
}
public function testPendingOrderStillPending(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder('pi_processing');
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_processing',
'status' => 'processing',
]);
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertSame(BilletBuyer::STATUS_PENDING, $order->getStatus());
self::assertStringContainsString('PENDING', $tester->getDisplay());
}
public function testPendingOrderStripeApiError(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$order = $this->createPendingOrder('pi_error');
$this->setBuyerRepo([$order]);
$this->stripeService->method('retrievePaymentIntent')
->willThrowException(new \RuntimeException('Stripe API error'));
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertStringContainsString('ERROR', $tester->getDisplay());
self::assertSame(1, $tester->getStatusCode());
}
public function testPendingOrderFailedWithoutEmail(): void
{
$this->userRepo->method('findAll')->willReturn([]);
$organizer = $this->createOrganizer('acct_no_email');
$event = $this->createMock(Event::class);
$event->method('getTitle')->willReturn('Test Event');
$event->method('getAccount')->willReturn($organizer);
$order = new BilletBuyer();
$order->setStatus(BilletBuyer::STATUS_PENDING);
$order->setStripeSessionId('pi_no_email');
$order->setEvent($event);
$this->setBuyerRepo([$order]);
$paymentIntent = \Stripe\PaymentIntent::constructFrom([
'id' => 'pi_no_email',
'status' => 'requires_payment_method',
'last_payment_error' => null,
]);
$this->stripeService->method('retrievePaymentIntent')->willReturn($paymentIntent);
$this->audit->expects(self::once())->method('log')
->with('payment_failed_sync', 'BilletBuyer', self::anything(), self::anything());
$this->mailerService->expects(self::never())->method('sendEmail');
$tester = $this->createCommandTester();
$tester->execute([]);
self::assertSame(BilletBuyer::STATUS_CANCELLED, $order->getStatus());
self::assertStringContainsString('FAILED', $tester->getDisplay());
}
}

View File

@@ -2049,25 +2049,25 @@ class AccountControllerTest extends WebTestCase
public function testGetAllowedBilletTypesBasic(): void
{
$types = \App\Controller\AccountController::getAllowedBilletTypes('basic');
$types = \App\Controller\AccountEventCatalogController::getAllowedBilletTypes('basic');
self::assertSame(['billet', 'reservation_brocante', 'vote'], $types);
}
public function testGetAllowedBilletTypesSurMesure(): void
{
$types = \App\Controller\AccountController::getAllowedBilletTypes('sur-mesure');
$types = \App\Controller\AccountEventCatalogController::getAllowedBilletTypes('sur-mesure');
self::assertSame(['billet', 'reservation_brocante', 'vote'], $types);
}
public function testGetAllowedBilletTypesFree(): void
{
$types = \App\Controller\AccountController::getAllowedBilletTypes('free');
$types = \App\Controller\AccountEventCatalogController::getAllowedBilletTypes('free');
self::assertSame(['billet'], $types);
}
public function testGetAllowedBilletTypesNull(): void
{
$types = \App\Controller\AccountController::getAllowedBilletTypes(null);
$types = \App\Controller\AccountEventCatalogController::getAllowedBilletTypes(null);
self::assertSame(['billet'], $types);
}
@@ -2750,6 +2750,56 @@ class AccountControllerTest extends WebTestCase
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=attestation');
}
public function testEventAttestationWithSoldTickets(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setCompanyName('Test Asso');
$user->setSiret('12345678901234');
$em->flush();
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$order = new \App\Entity\BilletBuyer();
$order->setEvent($event);
$order->setFirstName('Jean');
$order->setLastName('Dupont');
$order->setEmail('jean-att@test.fr');
$order->setOrderNumber('T-'.substr(uniqid(), -7));
$order->setTotalHT(1000);
$order->setStatus(\App\Entity\BilletBuyer::STATUS_PAID);
$order->setPaidAt(new \DateTimeImmutable());
$item = new \App\Entity\BilletBuyerItem();
$item->setBillet($billet);
$item->setBilletName('Test');
$item->setQuantity(2);
$item->setUnitPriceHT(500);
$order->addItem($item);
$em->persist($order);
$em->flush();
$ticket = new \App\Entity\BilletOrder();
$ticket->setBilletBuyer($order);
$ticket->setBillet($billet);
$ticket->setBilletName('Test');
$ticket->setUnitPriceHT(500);
$em->persist($ticket);
$em->flush();
$client->loginUser($user);
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/attestation', [
'categories' => [$category->getId()],
'mode' => 'detail',
]);
self::assertResponseIsSuccessful();
self::assertStringContainsString('application/pdf', $client->getResponse()->headers->get('Content-Type'));
}
/**
* @param list<string> $roles
*/