Compare commits
10 Commits
f703856e8c
...
88ebba6ce5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ebba6ce5 | ||
|
|
857683cf70 | ||
|
|
053da2ba8f | ||
|
|
6e5e389b7d | ||
|
|
f51f28fc0b | ||
|
|
17dff8ef8a | ||
|
|
23a5e92292 | ||
|
|
14527227a8 | ||
|
|
8b62211f8f | ||
|
|
3f2d8672d0 |
@@ -1,34 +1,100 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# CRM SITECONSEIL database backup script
|
# CRM SITECONSEIL - Backup script (database + files)
|
||||||
# Runs every 30 minutes via cron
|
# Usage: /usr/local/bin/crm-siteconseil-backup.sh
|
||||||
|
# Schedule via cron (ex: every 30 minutes)
|
||||||
|
|
||||||
BACKUP_DIR="/var/backups/crm-siteconseil"
|
set -euo pipefail
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
|
||||||
FILENAME="crm_siteconseil_${DATE}.sql.gz"
|
|
||||||
KEEP_DAYS=1
|
|
||||||
|
|
||||||
# Dump database via Docker
|
#######################################
|
||||||
docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T db-master pg_dump -U {{ db_user | default('crm-siteconseil') }} {{ db_name | default('crm-siteconseil') }} | gzip > "${BACKUP_DIR}/${FILENAME}"
|
# Configuration
|
||||||
|
#######################################
|
||||||
|
APP_DIR="{{ app_dir | default('/var/www/crm-siteconseil') }}"
|
||||||
|
COMPOSE_FILE="${APP_DIR}/docker-compose-prod.yml"
|
||||||
|
BACKUP_DIR="{{ backup_dir | default('/var/backups/crm-siteconseil') }}"
|
||||||
|
KEEP_DAYS="{{ backup_keep_days | default(7) }}"
|
||||||
|
|
||||||
# Check if backup was created
|
DB_SERVICE="{{ db_service | default('db-master') }}"
|
||||||
if [ -f "${BACKUP_DIR}/${FILENAME}" ] && [ -s "${BACKUP_DIR}/${FILENAME}" ]; then
|
DB_USER="{{ db_user | default('crm-siteconseil') }}"
|
||||||
echo "[$(date)] DB Backup OK: ${FILENAME} ($(du -h "${BACKUP_DIR}/${FILENAME}" | cut -f1))"
|
DB_NAME="{{ db_name | default('crm-siteconseil') }}"
|
||||||
else
|
|
||||||
echo "[$(date)] ERROR: DB Backup failed"
|
DATE="$(date +%Y%m%d_%H%M%S)"
|
||||||
exit 1
|
TARGET_DIR="${BACKUP_DIR}/${DATE}"
|
||||||
|
LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]"
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Helpers
|
||||||
|
#######################################
|
||||||
|
log() { echo "${LOG_PREFIX} $*"; }
|
||||||
|
fail() { echo "${LOG_PREFIX} ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "${TARGET_DIR}"
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 1. Database (PostgreSQL custom format)
|
||||||
|
#######################################
|
||||||
|
DB_FILE="${TARGET_DIR}/database.dump"
|
||||||
|
log "Dumping database ${DB_NAME} from service ${DB_SERVICE}..."
|
||||||
|
|
||||||
|
if ! docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
|
||||||
|
pg_dump -U "${DB_USER}" -d "${DB_NAME}" --format=custom --no-owner --no-acl \
|
||||||
|
> "${DB_FILE}"; then
|
||||||
|
fail "pg_dump failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Backup uploads
|
if [ ! -s "${DB_FILE}" ]; then
|
||||||
UPLOADS_FILENAME="uploads_${DATE}.tar.gz"
|
fail "database dump is empty"
|
||||||
tar -czf "${BACKUP_DIR}/${UPLOADS_FILENAME}" -C /var/www/crm-siteconseil/public uploads 2>/dev/null
|
|
||||||
|
|
||||||
if [ -f "${BACKUP_DIR}/${UPLOADS_FILENAME}" ]; then
|
|
||||||
echo "[$(date)] Uploads Backup OK: ${UPLOADS_FILENAME} ($(du -h "${BACKUP_DIR}/${UPLOADS_FILENAME}" | cut -f1))"
|
|
||||||
else
|
|
||||||
echo "[$(date)] WARNING: Uploads backup failed"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove backups older than KEEP_DAYS days
|
log "DB Backup OK: $(du -h "${DB_FILE}" | cut -f1)"
|
||||||
find "${BACKUP_DIR}" -name "crm_siteconseil_*.sql.gz" -mtime +${KEEP_DAYS} -delete
|
|
||||||
find "${BACKUP_DIR}" -name "uploads_*.tar.gz" -mtime +${KEEP_DAYS} -delete
|
#######################################
|
||||||
echo "[$(date)] Cleaned backups older than ${KEEP_DAYS} days"
|
# 2. Files: public/uploads
|
||||||
|
#######################################
|
||||||
|
UPLOADS_FILE="${TARGET_DIR}/uploads.tar.gz"
|
||||||
|
if [ -d "${APP_DIR}/public/uploads" ]; then
|
||||||
|
log "Archiving public/uploads..."
|
||||||
|
if tar -czf "${UPLOADS_FILE}" -C "${APP_DIR}/public" uploads; then
|
||||||
|
log "Uploads Backup OK: $(du -h "${UPLOADS_FILE}" | cut -f1)"
|
||||||
|
else
|
||||||
|
log "WARNING: uploads archive failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: ${APP_DIR}/public/uploads not found, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 3. Files: var/share (APP_SHARE_DIR)
|
||||||
|
#######################################
|
||||||
|
SHARE_FILE="${TARGET_DIR}/share.tar.gz"
|
||||||
|
if [ -d "${APP_DIR}/var/share" ]; then
|
||||||
|
log "Archiving var/share..."
|
||||||
|
if tar -czf "${SHARE_FILE}" -C "${APP_DIR}/var" share; then
|
||||||
|
log "Share Backup OK: $(du -h "${SHARE_FILE}" | cut -f1)"
|
||||||
|
else
|
||||||
|
log "WARNING: share archive failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: ${APP_DIR}/var/share not found, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 4. Manifest
|
||||||
|
#######################################
|
||||||
|
MANIFEST="${TARGET_DIR}/manifest.txt"
|
||||||
|
{
|
||||||
|
echo "CRM SITECONSEIL backup"
|
||||||
|
echo "Date : ${DATE}"
|
||||||
|
echo "Hostname : $(hostname)"
|
||||||
|
echo "DB : ${DB_NAME}@${DB_SERVICE}"
|
||||||
|
echo "Files :"
|
||||||
|
ls -lh "${TARGET_DIR}" | tail -n +2
|
||||||
|
} > "${MANIFEST}"
|
||||||
|
|
||||||
|
log "Backup directory: ${TARGET_DIR} ($(du -sh "${TARGET_DIR}" | cut -f1))"
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 5. Retention (delete backups older than KEEP_DAYS)
|
||||||
|
#######################################
|
||||||
|
log "Cleaning backups older than ${KEEP_DAYS} days..."
|
||||||
|
find "${BACKUP_DIR}" -mindepth 1 -maxdepth 1 -type d -mtime "+${KEEP_DAYS}" -exec rm -rf {} +
|
||||||
|
log "Done."
|
||||||
|
|||||||
184
ansible/restore.sh.j2
Normal file
184
ansible/restore.sh.j2
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# CRM SITECONSEIL - Restore script (database + files)
|
||||||
|
# Usage:
|
||||||
|
# /usr/local/bin/crm-siteconseil-restore.sh # restore the latest backup
|
||||||
|
# /usr/local/bin/crm-siteconseil-restore.sh 20260410_143000 # restore a specific backup
|
||||||
|
# /usr/local/bin/crm-siteconseil-restore.sh --list # list available backups
|
||||||
|
# /usr/local/bin/crm-siteconseil-restore.sh -y 20260410_143000 # skip confirmation
|
||||||
|
#
|
||||||
|
# WARNING: this script will REPLACE the current database and files.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Configuration
|
||||||
|
#######################################
|
||||||
|
APP_DIR="{{ app_dir | default('/var/www/crm-siteconseil') }}"
|
||||||
|
COMPOSE_FILE="${APP_DIR}/docker-compose-prod.yml"
|
||||||
|
BACKUP_DIR="{{ backup_dir | default('/var/backups/crm-siteconseil') }}"
|
||||||
|
|
||||||
|
DB_SERVICE="{{ db_service | default('db-master') }}"
|
||||||
|
DB_USER="{{ db_user | default('crm-siteconseil') }}"
|
||||||
|
DB_NAME="{{ db_name | default('crm-siteconseil') }}"
|
||||||
|
|
||||||
|
LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')]"
|
||||||
|
ASSUME_YES=0
|
||||||
|
TARGET=""
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Helpers
|
||||||
|
#######################################
|
||||||
|
log() { echo "${LOG_PREFIX} $*"; }
|
||||||
|
fail() { echo "${LOG_PREFIX} ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [options] [BACKUP_ID]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-y, --yes Skip confirmation prompt
|
||||||
|
-l, --list List available backups and exit
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
If no BACKUP_ID is given, the most recent backup is used.
|
||||||
|
BACKUP_ID is the timestamped folder name (ex: 20260410_143000).
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
list_backups() {
|
||||||
|
if [ ! -d "${BACKUP_DIR}" ]; then
|
||||||
|
fail "Backup directory not found: ${BACKUP_DIR}"
|
||||||
|
fi
|
||||||
|
log "Available backups in ${BACKUP_DIR}:"
|
||||||
|
find "${BACKUP_DIR}" -mindepth 1 -maxdepth 1 -type d -printf '%f\t%TY-%Tm-%Td %TH:%TM\t%s bytes\n' \
|
||||||
|
| sort -r \
|
||||||
|
| awk -F'\t' '{ printf " %-20s %s\n", $1, $2 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Args parsing
|
||||||
|
#######################################
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-y|--yes) ASSUME_YES=1; shift ;;
|
||||||
|
-l|--list) list_backups; exit 0 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*) fail "Unknown option: $1" ;;
|
||||||
|
*) TARGET="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Resolve backup directory
|
||||||
|
#######################################
|
||||||
|
if [ -z "${TARGET}" ]; then
|
||||||
|
TARGET="$(find "${BACKUP_DIR}" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r | head -n 1 || true)"
|
||||||
|
[ -z "${TARGET}" ] && fail "No backup found in ${BACKUP_DIR}"
|
||||||
|
log "No backup specified, using latest: ${TARGET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_DIR="${BACKUP_DIR}/${TARGET}"
|
||||||
|
[ -d "${SOURCE_DIR}" ] || fail "Backup not found: ${SOURCE_DIR}"
|
||||||
|
|
||||||
|
DB_FILE="${SOURCE_DIR}/database.dump"
|
||||||
|
UPLOADS_FILE="${SOURCE_DIR}/uploads.tar.gz"
|
||||||
|
SHARE_FILE="${SOURCE_DIR}/share.tar.gz"
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# Confirmation
|
||||||
|
#######################################
|
||||||
|
log "About to restore from: ${SOURCE_DIR}"
|
||||||
|
[ -f "${DB_FILE}" ] && log " - database.dump ($(du -h "${DB_FILE}" | cut -f1))"
|
||||||
|
[ -f "${UPLOADS_FILE}" ] && log " - uploads.tar.gz ($(du -h "${UPLOADS_FILE}" | cut -f1))"
|
||||||
|
[ -f "${SHARE_FILE}" ] && log " - share.tar.gz ($(du -h "${SHARE_FILE}" | cut -f1))"
|
||||||
|
log "Target: database ${DB_NAME}@${DB_SERVICE}, files in ${APP_DIR}"
|
||||||
|
log "WARNING: existing database and files will be REPLACED."
|
||||||
|
|
||||||
|
if [ "${ASSUME_YES}" -ne 1 ]; then
|
||||||
|
read -r -p "Type 'RESTORE' to confirm: " CONFIRM
|
||||||
|
[ "${CONFIRM}" = "RESTORE" ] || fail "Aborted by user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 1. Restore database
|
||||||
|
#######################################
|
||||||
|
if [ -f "${DB_FILE}" ]; then
|
||||||
|
log "Restoring database ${DB_NAME}..."
|
||||||
|
|
||||||
|
# Drop and recreate the database to ensure a clean state
|
||||||
|
docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
|
||||||
|
psql -U "${DB_USER}" -d postgres -v ON_ERROR_STOP=1 -c \
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();" \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
|
||||||
|
psql -U "${DB_USER}" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
|
||||||
|
psql -U "${DB_USER}" -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
if ! docker compose -f "${COMPOSE_FILE}" exec -T "${DB_SERVICE}" \
|
||||||
|
pg_restore -U "${DB_USER}" -d "${DB_NAME}" --no-owner --no-acl --clean --if-exists \
|
||||||
|
< "${DB_FILE}"; then
|
||||||
|
fail "pg_restore failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Database restored OK"
|
||||||
|
else
|
||||||
|
log "WARNING: no database.dump in backup, skipping DB restore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 2. Restore public/uploads
|
||||||
|
#######################################
|
||||||
|
if [ -f "${UPLOADS_FILE}" ]; then
|
||||||
|
log "Restoring public/uploads..."
|
||||||
|
BACKUP_OLD="${APP_DIR}/public/uploads.bak.$(date +%s)"
|
||||||
|
if [ -d "${APP_DIR}/public/uploads" ]; then
|
||||||
|
mv "${APP_DIR}/public/uploads" "${BACKUP_OLD}"
|
||||||
|
log "Existing uploads moved to ${BACKUP_OLD}"
|
||||||
|
fi
|
||||||
|
if tar -xzf "${UPLOADS_FILE}" -C "${APP_DIR}/public"; then
|
||||||
|
log "Uploads restored OK"
|
||||||
|
else
|
||||||
|
log "ERROR: uploads extraction failed, rolling back"
|
||||||
|
rm -rf "${APP_DIR}/public/uploads"
|
||||||
|
[ -d "${BACKUP_OLD}" ] && mv "${BACKUP_OLD}" "${APP_DIR}/public/uploads"
|
||||||
|
fail "uploads restore failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: no uploads.tar.gz in backup, skipping uploads restore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 3. Restore var/share
|
||||||
|
#######################################
|
||||||
|
if [ -f "${SHARE_FILE}" ]; then
|
||||||
|
log "Restoring var/share..."
|
||||||
|
BACKUP_OLD="${APP_DIR}/var/share.bak.$(date +%s)"
|
||||||
|
if [ -d "${APP_DIR}/var/share" ]; then
|
||||||
|
mv "${APP_DIR}/var/share" "${BACKUP_OLD}"
|
||||||
|
log "Existing share moved to ${BACKUP_OLD}"
|
||||||
|
fi
|
||||||
|
if tar -xzf "${SHARE_FILE}" -C "${APP_DIR}/var"; then
|
||||||
|
log "Share restored OK"
|
||||||
|
else
|
||||||
|
log "ERROR: share extraction failed, rolling back"
|
||||||
|
rm -rf "${APP_DIR}/var/share"
|
||||||
|
[ -d "${BACKUP_OLD}" ] && mv "${BACKUP_OLD}" "${APP_DIR}/var/share"
|
||||||
|
fail "share restore failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: no share.tar.gz in backup, skipping share restore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
# 4. Post-restore: clear caches
|
||||||
|
#######################################
|
||||||
|
log "Clearing application cache..."
|
||||||
|
docker compose -f "${COMPOSE_FILE}" exec -T php php bin/console cache:clear --no-warmup >/dev/null 2>&1 || \
|
||||||
|
log "WARNING: cache:clear failed (php service down?)"
|
||||||
|
|
||||||
|
log "Restore completed from ${TARGET}"
|
||||||
37
migrations/Version20260409141035.php
Normal file
37
migrations/Version20260409141035.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260409141035 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE customer_payment_method (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, stripe_payment_method_id VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL, last4 VARCHAR(4) DEFAULT NULL, brand VARCHAR(50) DEFAULT NULL, country VARCHAR(2) DEFAULT NULL, is_default BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, customer_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_E20C953C9395C3F3 ON customer_payment_method (customer_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_customer_payment_method_default ON customer_payment_method (customer_id, is_default)');
|
||||||
|
$this->addSql('ALTER TABLE customer_payment_method ADD CONSTRAINT FK_E20C953C9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE contrat ALTER services DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE customer_payment_method DROP CONSTRAINT FK_E20C953C9395C3F3');
|
||||||
|
$this->addSql('DROP TABLE customer_payment_method');
|
||||||
|
$this->addSql('ALTER TABLE contrat ALTER services SET DEFAULT \'[]\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
sonar.projectKey=crm_siteconseil
|
sonar.projectKey=crm
|
||||||
sonar.token=sqp_3e02f4de4c73f6d9cc5b6ce6546a7871d6ac0756
|
sonar.token=sqp_a055f41ea46e79fd66c9cfead82a795f394155bd
|
||||||
|
sonar.host.url=https://sn.e-cosplay.fr/
|
||||||
sonar.projectName=CRM SITECONSEIL
|
sonar.projectName=CRM SITECONSEIL
|
||||||
sonar.sources=src,assets,templates
|
sonar.sources=src,assets,templates
|
||||||
sonar.tests=tests
|
sonar.tests=tests
|
||||||
|
|||||||
171
src/Command/AdvertAutoPaymentCommand.php
Normal file
171
src/Command/AdvertAutoPaymentCommand.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Advert;
|
||||||
|
use App\Entity\AdvertPayment;
|
||||||
|
use App\Entity\CustomerPaymentMethod;
|
||||||
|
use App\Service\MailerService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:advert:auto-payment',
|
||||||
|
description: 'Preleve automatiquement les avis de paiement envoyes pour les clients avec prelevement auto configure.',
|
||||||
|
)]
|
||||||
|
class AdvertAutoPaymentCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private MailerService $mailer,
|
||||||
|
private Environment $twig,
|
||||||
|
#[Autowire(env: 'STRIPE_SK')] private string $stripeSk,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if ('' === $this->stripeSk) {
|
||||||
|
$io->error('STRIPE_SK non configure.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier si c'est le dernier jour du mois
|
||||||
|
$today = new \DateTimeImmutable('today');
|
||||||
|
$lastDay = new \DateTimeImmutable($today->format('Y-m-t'));
|
||||||
|
|
||||||
|
if ($today->format('Y-m-d') !== $lastDay->format('Y-m-d')) {
|
||||||
|
$io->info('Pas le dernier jour du mois ('.$today->format('d/m/Y').'). Rien a faire.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
\Stripe\Stripe::setApiKey($this->stripeSk);
|
||||||
|
|
||||||
|
// Trouver tous les avis envoyes (state = send)
|
||||||
|
$adverts = $this->em->createQuery(
|
||||||
|
'SELECT a FROM App\Entity\Advert a
|
||||||
|
WHERE a.state = :state
|
||||||
|
ORDER BY a.createdAt ASC'
|
||||||
|
)
|
||||||
|
->setParameter('state', Advert::STATE_SEND)
|
||||||
|
->getResult();
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
/** @var Advert $advert */
|
||||||
|
foreach ($adverts as $advert) {
|
||||||
|
$customer = $advert->getCustomer();
|
||||||
|
if (null === $customer) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher le moyen de paiement par defaut du client
|
||||||
|
$defaultPm = $this->em->getRepository(CustomerPaymentMethod::class)->findOneBy([
|
||||||
|
'customer' => $customer,
|
||||||
|
'isDefault' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (null === $defaultPm) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripeCustomerId = $customer->getStripeCustomerId();
|
||||||
|
if (null === $stripeCustomerId) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le montant restant a payer
|
||||||
|
$totalTtc = (float) $advert->getTotalTtc();
|
||||||
|
$totalPaid = 0.0;
|
||||||
|
foreach ($advert->getPayments() as $payment) {
|
||||||
|
if (AdvertPayment::TYPE_SUCCESS === $payment->getType()) {
|
||||||
|
$totalPaid += (float) $payment->getAmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = $totalTtc - $totalPaid;
|
||||||
|
if ($remaining <= 0) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountCents = (int) round($remaining * 100);
|
||||||
|
|
||||||
|
// Envoyer un mail d'annonce de prelevement
|
||||||
|
if (null !== $customer->getEmail()) {
|
||||||
|
try {
|
||||||
|
$this->mailer->sendEmail(
|
||||||
|
$customer->getEmail(),
|
||||||
|
'Prelevement automatique - Avis '.$advert->getOrderNumber()->getNumOrder(),
|
||||||
|
$this->twig->render('emails/advert_auto_payment_notice.html.twig', [
|
||||||
|
'customer' => $customer,
|
||||||
|
'advert' => $advert,
|
||||||
|
'amount' => $remaining,
|
||||||
|
'methodLabel' => $defaultPm->getDisplayLabel(),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// silencieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pi = \Stripe\PaymentIntent::create([
|
||||||
|
'amount' => $amountCents,
|
||||||
|
'currency' => 'eur',
|
||||||
|
'customer' => $stripeCustomerId,
|
||||||
|
'payment_method' => $defaultPm->getStripePaymentMethodId(),
|
||||||
|
'off_session' => true,
|
||||||
|
'confirm' => true,
|
||||||
|
'payment_method_types' => [$defaultPm->getType()],
|
||||||
|
'metadata' => [
|
||||||
|
'advert_id' => (string) $advert->getId(),
|
||||||
|
'auto_payment' => '1',
|
||||||
|
'payment_method' => $defaultPm->getType(),
|
||||||
|
],
|
||||||
|
'description' => 'Avis '.$advert->getOrderNumber()->getNumOrder().' - Prelevement auto',
|
||||||
|
]);
|
||||||
|
|
||||||
|
++$created;
|
||||||
|
$this->logger->info('Auto-payment: PI cree pour avis '.$advert->getOrderNumber()->getNumOrder(), [
|
||||||
|
'pi_id' => $pi->id,
|
||||||
|
'amount' => number_format($remaining, 2),
|
||||||
|
'method' => $defaultPm->getTypeLabel(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
++$errors;
|
||||||
|
$this->logger->error('Auto-payment: erreur pour avis '.$advert->getOrderNumber()->getNumOrder().': '.$e->getMessage());
|
||||||
|
$io->warning($advert->getOrderNumber()->getNumOrder().': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success($created.' prelevement(s) lance(s), '.$skipped.' ignore(s), '.$errors.' erreur(s).');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -369,6 +369,7 @@ class ClientsController extends AbstractController
|
|||||||
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||||
$eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
$eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||||
$contratsList = $em->getRepository(\App\Entity\Contrat::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
$contratsList = $em->getRepository(\App\Entity\Contrat::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||||
|
$paymentMethods = $em->getRepository(\App\Entity\CustomerPaymentMethod::class)->findBy(['customer' => $customer], ['isDefault' => 'DESC', 'createdAt' => 'DESC']);
|
||||||
|
|
||||||
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
|
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
|
||||||
|
|
||||||
@@ -384,6 +385,7 @@ class ClientsController extends AbstractController
|
|||||||
'echeancierList' => $echeancierList,
|
'echeancierList' => $echeancierList,
|
||||||
'eflexList' => $eflexList,
|
'eflexList' => $eflexList,
|
||||||
'contratsList' => $contratsList,
|
'contratsList' => $contratsList,
|
||||||
|
'paymentMethods' => $paymentMethods,
|
||||||
'tab' => $tab,
|
'tab' => $tab,
|
||||||
'trustStatus' => $trustStatus,
|
'trustStatus' => $trustStatus,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -191,16 +191,17 @@ class ContratController extends AbstractController
|
|||||||
$contrat->setState(Contrat::STATE_SEND);
|
$contrat->setState(Contrat::STATE_SEND);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
// Envoyer email au client avec le lien
|
// Envoyer email au client avec le lien vers la page process
|
||||||
$slug = $docuSeal->getSubmitterSlug($submitterId);
|
$processUrl = $urlGenerator->generate('app_contrat_process', [
|
||||||
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
|
'id' => $contrat->getId(),
|
||||||
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
|
|
||||||
$mailer->sendEmail(
|
$mailer->sendEmail(
|
||||||
$contrat->getEmail(),
|
$contrat->getEmail(),
|
||||||
'Contrat a signer - '.$contrat->getTypeLabel().' - '.$contrat->getReference(),
|
'Contrat a signer - '.$contrat->getTypeLabel().' - '.$contrat->getReference(),
|
||||||
$twig->render('emails/contrat_signature.html.twig', [
|
$twig->render('emails/contrat_signature.html.twig', [
|
||||||
'contrat' => $contrat,
|
'contrat' => $contrat,
|
||||||
'signUrl' => $signUrl,
|
'processUrl' => $processUrl,
|
||||||
]),
|
]),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
316
src/Controller/ContratProcessController.php
Normal file
316
src/Controller/ContratProcessController.php
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Contrat;
|
||||||
|
use App\Entity\CustomerPaymentMethod;
|
||||||
|
use App\Service\DocuSealService;
|
||||||
|
use App\Service\MailerService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class ContratProcessController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private MailerService $mailer,
|
||||||
|
private Environment $twig,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/process/contrat/{id}/verify', name: 'app_contrat_verify', requirements: ['id' => '\d+'])]
|
||||||
|
public function verify(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if ($request->isMethod('POST')) {
|
||||||
|
$code = $request->request->getString('code');
|
||||||
|
$storedCode = $session->get('contrat_code_'.$id);
|
||||||
|
$expires = $session->get('contrat_code_expires_'.$id, 0);
|
||||||
|
|
||||||
|
if (time() > $expires) {
|
||||||
|
$error = 'Code expire. Veuillez en demander un nouveau.';
|
||||||
|
} elseif ($code !== $storedCode) {
|
||||||
|
$error = 'Code incorrect.';
|
||||||
|
} else {
|
||||||
|
$session->set('contrat_verified_'.$id, true);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_contrat_process', ['id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer un code si pas encore fait
|
||||||
|
if (null === $session->get('contrat_code_'.$id)) {
|
||||||
|
$this->sendVerificationCode($contrat, $session, $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('contrat/verify.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
'error' => $error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/process/contrat/{id}/verify/resend', name: 'app_contrat_resend_code', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function resendCode(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendVerificationCode($contrat, $request->getSession(), $id);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_contrat_verify', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/process/contrat/{id}', name: 'app_contrat_process', requirements: ['id' => '\d+'])]
|
||||||
|
public function process(int $id, Request $request): Response
|
||||||
|
{
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
if (!$session->get('contrat_verified_'.$contrat->getId(), false)) {
|
||||||
|
return $this->redirectToRoute('app_contrat_verify', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('contrat/process.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/process/contrat/{id}/sign', name: 'app_contrat_sign', requirements: ['id' => '\d+'])]
|
||||||
|
public function sign(
|
||||||
|
int $id,
|
||||||
|
Request $request,
|
||||||
|
DocuSealService $docuSeal,
|
||||||
|
#[Autowire(env: 'DOCUSEAL_URL')] string $docuSealUrl = '',
|
||||||
|
): Response {
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat || null === $contrat->getSubmissionId()) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
if (!$session->get('contrat_verified_'.$contrat->getId(), false)) {
|
||||||
|
return $this->redirectToRoute('app_contrat_verify', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
$slug = $docuSeal->getSubmitterSlug((int) $contrat->getSubmissionId());
|
||||||
|
if (null !== $slug) {
|
||||||
|
return $this->redirect(rtrim($docuSealUrl, '/').'/s/'.$slug);
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
|
throw $this->createNotFoundException('Lien de signature introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page de configuration du paiement (CB ou SEPA).
|
||||||
|
*/
|
||||||
|
#[Route('/process/contrat/{id}/setup-payment', name: 'app_contrat_setup_payment', requirements: ['id' => '\d+'])]
|
||||||
|
public function setupPayment(
|
||||||
|
int $id,
|
||||||
|
Request $request,
|
||||||
|
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||||
|
#[Autowire(env: 'STRIPE_PK')] string $stripePk = '',
|
||||||
|
): Response {
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat || Contrat::STATE_SIGNED !== $contrat->getState()) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
if (!$session->get('contrat_verified_'.$contrat->getId(), false)) {
|
||||||
|
return $this->redirectToRoute('app_contrat_verify', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $contrat->getCustomer();
|
||||||
|
if (null === $customer) {
|
||||||
|
throw $this->createNotFoundException('Client introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
\Stripe\Stripe::setApiKey($stripeSk);
|
||||||
|
|
||||||
|
$stripeCustomerId = $customer->getStripeCustomerId();
|
||||||
|
if (null === $stripeCustomerId) {
|
||||||
|
$stripeCustomer = \Stripe\Customer::create([
|
||||||
|
'email' => $customer->getEmail() ?? $contrat->getEmail(),
|
||||||
|
'name' => $contrat->getRaisonSociale(),
|
||||||
|
]);
|
||||||
|
$stripeCustomerId = $stripeCustomer->id;
|
||||||
|
$customer->setStripeCustomerId($stripeCustomerId);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creer un SetupIntent pour SEPA
|
||||||
|
$setupIntent = \Stripe\SetupIntent::create([
|
||||||
|
'customer' => $stripeCustomerId,
|
||||||
|
'payment_method_types' => ['sepa_debit'],
|
||||||
|
'metadata' => ['contrat_id' => (string) $contrat->getId()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Creer aussi un Checkout Session pour CB (premier paiement)
|
||||||
|
$totalCents = (int) round($contrat->getTotalHt() * 100);
|
||||||
|
|
||||||
|
$cbCheckoutUrl = null;
|
||||||
|
if ($totalCents > 0) {
|
||||||
|
$checkoutSession = \Stripe\Checkout\Session::create([
|
||||||
|
'mode' => 'payment',
|
||||||
|
'payment_method_types' => ['card'],
|
||||||
|
'customer' => $stripeCustomerId,
|
||||||
|
'line_items' => [[
|
||||||
|
'price_data' => [
|
||||||
|
'currency' => 'eur',
|
||||||
|
'unit_amount' => $totalCents,
|
||||||
|
'product_data' => [
|
||||||
|
'name' => 'Premier paiement - '.$contrat->getReference(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'quantity' => 1,
|
||||||
|
]],
|
||||||
|
'payment_intent_data' => [
|
||||||
|
'setup_future_usage' => 'off_session',
|
||||||
|
'metadata' => [
|
||||||
|
'contrat_id' => (string) $contrat->getId(),
|
||||||
|
'first_payment' => '1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'success_url' => $this->generateUrl('app_contrat_payment_success', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
'cancel_url' => $this->generateUrl('app_contrat_setup_payment', ['id' => $id], UrlGeneratorInterface::ABSOLUTE_URL),
|
||||||
|
]);
|
||||||
|
$cbCheckoutUrl = $checkoutSession->url;
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
|
return $this->render('contrat/setup_payment.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
'customer' => $customer,
|
||||||
|
'clientSecret' => $setupIntent->client_secret,
|
||||||
|
'stripePk' => $stripePk,
|
||||||
|
'cbCheckoutUrl' => $cbCheckoutUrl,
|
||||||
|
'totalHt' => $contrat->getTotalHt(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation SEPA pour contrat.
|
||||||
|
*/
|
||||||
|
#[Route('/process/contrat/{id}/setup-payment/confirm', name: 'app_contrat_setup_payment_confirm', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||||
|
public function setupPaymentConfirm(
|
||||||
|
int $id,
|
||||||
|
Request $request,
|
||||||
|
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||||
|
): Response {
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat) {
|
||||||
|
return new JsonResponse(['error' => 'Contrat introuvable'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->getSession();
|
||||||
|
if (!$session->get('contrat_verified_'.$contrat->getId(), false)) {
|
||||||
|
return new JsonResponse(['error' => 'Non autorise'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
$paymentMethodId = $data['payment_method'] ?? null;
|
||||||
|
|
||||||
|
if (null === $paymentMethodId) {
|
||||||
|
return new JsonResponse(['error' => 'payment_method manquant'], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $contrat->getCustomer();
|
||||||
|
if (null === $customer) {
|
||||||
|
return new JsonResponse(['error' => 'Client introuvable'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @codeCoverageIgnoreStart
|
||||||
|
try {
|
||||||
|
\Stripe\Stripe::setApiKey($stripeSk);
|
||||||
|
|
||||||
|
$stripeCustomerId = $customer->getStripeCustomerId();
|
||||||
|
$pm = \Stripe\PaymentMethod::retrieve($paymentMethodId);
|
||||||
|
$pm->attach(['customer' => $stripeCustomerId]);
|
||||||
|
\Stripe\Customer::update($stripeCustomerId, [
|
||||||
|
'invoice_settings' => ['default_payment_method' => $paymentMethodId],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sauvegarder le moyen de paiement
|
||||||
|
$sepa = $pm->sepa_debit ?? null;
|
||||||
|
$cpm = new CustomerPaymentMethod($customer, $paymentMethodId, CustomerPaymentMethod::TYPE_SEPA);
|
||||||
|
$cpm->setIsDefault(true);
|
||||||
|
if (null !== $sepa) {
|
||||||
|
$cpm->setLast4($sepa->last4 ?? null);
|
||||||
|
$cpm->setBrand($sepa->bank_code ?? null);
|
||||||
|
$cpm->setCountry($sepa->country ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retirer le default des autres
|
||||||
|
$existingMethods = $this->em->getRepository(CustomerPaymentMethod::class)->findBy(['customer' => $customer]);
|
||||||
|
foreach ($existingMethods as $m) {
|
||||||
|
$m->setIsDefault(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($cpm);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
// @codeCoverageIgnoreEnd
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page succes apres premier paiement CB.
|
||||||
|
*/
|
||||||
|
#[Route('/process/contrat/{id}/payment-success', name: 'app_contrat_payment_success', requirements: ['id' => '\d+'])]
|
||||||
|
public function paymentSuccess(int $id): Response
|
||||||
|
{
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($id);
|
||||||
|
if (null === $contrat) {
|
||||||
|
throw $this->createNotFoundException('Contrat introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('contrat/payment_success.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendVerificationCode(Contrat $contrat, object $session, int $id): void
|
||||||
|
{
|
||||||
|
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||||
|
$session->set('contrat_code_'.$id, $code);
|
||||||
|
$session->set('contrat_code_expires_'.$id, time() + 900);
|
||||||
|
|
||||||
|
$this->mailer->sendEmail(
|
||||||
|
$contrat->getEmail(),
|
||||||
|
'Code de verification - Contrat '.$contrat->getReference(),
|
||||||
|
$this->twig->render('emails/contrat_verify_code.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
'code' => $code,
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Attestation;
|
use App\Entity\Attestation;
|
||||||
|
use App\Entity\Customer;
|
||||||
use App\Entity\Devis;
|
use App\Entity\Devis;
|
||||||
use App\Entity\DocusealEvent;
|
use App\Entity\DocusealEvent;
|
||||||
use App\Entity\Echeancier;
|
use App\Entity\Echeancier;
|
||||||
|
use App\Service\UserManagementService;
|
||||||
use App\Repository\AttestationRepository;
|
use App\Repository\AttestationRepository;
|
||||||
use App\Repository\DevisRepository;
|
use App\Repository\DevisRepository;
|
||||||
use App\Service\DocuSealService;
|
use App\Service\DocuSealService;
|
||||||
@@ -38,6 +40,7 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
#[Autowire(env: 'DOCUSEAL_WEBHOOKS_SECRET')] string $secret,
|
#[Autowire(env: 'DOCUSEAL_WEBHOOKS_SECRET')] string $secret,
|
||||||
#[Autowire('%kernel.project_dir%')] string $projectDir,
|
#[Autowire('%kernel.project_dir%')] string $projectDir,
|
||||||
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||||
|
?UserManagementService $userManagement = null,
|
||||||
): Response {
|
): Response {
|
||||||
$payload = $this->parseAndValidate($request, $secretHeader, $secret);
|
$payload = $this->parseAndValidate($request, $secretHeader, $secret);
|
||||||
if ($payload instanceof Response) {
|
if ($payload instanceof Response) {
|
||||||
@@ -65,7 +68,7 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
|
|
||||||
// Dispatch par type de document
|
// Dispatch par type de document
|
||||||
if ('contrat' === $docType) {
|
if ('contrat' === $docType) {
|
||||||
return $this->handleContratEvent($eventType, $data, $metadata, $mailer, $twig, $em, $projectDir);
|
return $this->handleContratEvent($eventType, $data, $metadata, $mailer, $twig, $em, $projectDir, $userManagement);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('attestation_custom' === $docType) {
|
if ('attestation_custom' === $docType) {
|
||||||
@@ -199,6 +202,7 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
Environment $twig,
|
Environment $twig,
|
||||||
EntityManagerInterface $em,
|
EntityManagerInterface $em,
|
||||||
string $projectDir,
|
string $projectDir,
|
||||||
|
?UserManagementService $userManagement = null,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
if ('form.completed' !== $eventType) {
|
if ('form.completed' !== $eventType) {
|
||||||
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'contrat']);
|
return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'doc_type' => 'contrat']);
|
||||||
@@ -214,6 +218,9 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
return new JsonResponse(['status' => 'ignored', 'reason' => 'contrat not found']);
|
return new JsonResponse(['status' => 'ignored', 'reason' => 'contrat not found']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extraire les valeurs remplies par le client dans DocuSeal
|
||||||
|
$submitterValues = $this->extractDocuSealValues($data);
|
||||||
|
|
||||||
// Telecharger les PDFs signes
|
// Telecharger les PDFs signes
|
||||||
$tmpFiles = [];
|
$tmpFiles = [];
|
||||||
|
|
||||||
@@ -248,6 +255,13 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
@unlink($f);
|
@unlink($f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creer automatiquement le client si il n'existe pas
|
||||||
|
$customer = $this->findOrCreateCustomer($contrat, $submitterValues, $em, $userManagement, $mailer, $twig);
|
||||||
|
if (null !== $customer) {
|
||||||
|
$contrat->setCustomer($customer);
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
// Pieces jointes
|
// Pieces jointes
|
||||||
$attachments = [];
|
$attachments = [];
|
||||||
if (null !== $contrat->getPdfSigned()) {
|
if (null !== $contrat->getPdfSigned()) {
|
||||||
@@ -263,7 +277,7 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mail client
|
// Mail client : contrat signe
|
||||||
try {
|
try {
|
||||||
$mailer->sendEmail(
|
$mailer->sendEmail(
|
||||||
$contrat->getEmail(),
|
$contrat->getEmail(),
|
||||||
@@ -297,7 +311,171 @@ class WebhookDocuSealController extends AbstractController
|
|||||||
// silencieux
|
// silencieux
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JsonResponse(['status' => 'ok', 'event' => 'contrat_signed', 'reference' => $contrat->getReference()]);
|
// Mail client : configuration du paiement
|
||||||
|
if (null !== $customer) {
|
||||||
|
try {
|
||||||
|
$paymentUrl = $this->generateUrl('app_contrat_setup_payment', [
|
||||||
|
'id' => $contrat->getId(),
|
||||||
|
], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
|
|
||||||
|
$mailer->sendEmail(
|
||||||
|
$contrat->getEmail(),
|
||||||
|
'Configurez votre paiement - '.$contrat->getReference(),
|
||||||
|
$twig->render('emails/contrat_setup_payment.html.twig', [
|
||||||
|
'contrat' => $contrat,
|
||||||
|
'customer' => $customer,
|
||||||
|
'paymentUrl' => $paymentUrl,
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// silencieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['status' => 'ok', 'event' => 'contrat_signed', 'reference' => $contrat->getReference(), 'customer_created' => null !== $customer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les valeurs remplies par le client dans DocuSeal (champs texte).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function extractDocuSealValues(array $data): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
// DocuSeal envoie les valeurs dans data.values ou data.fields
|
||||||
|
$fields = $data['values'] ?? ($data['fields'] ?? []);
|
||||||
|
if (\is_array($fields)) {
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (\is_array($field)) {
|
||||||
|
$name = $field['name'] ?? ($field['field'] ?? '');
|
||||||
|
$value = $field['value'] ?? '';
|
||||||
|
if ('' !== $name && '' !== $value) {
|
||||||
|
$values[$name] = (string) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve ou cree un Customer a partir des donnees du contrat et de DocuSeal.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $submitterValues
|
||||||
|
*
|
||||||
|
* @codeCoverageIgnore
|
||||||
|
*/
|
||||||
|
private function findOrCreateCustomer(
|
||||||
|
\App\Entity\Contrat $contrat,
|
||||||
|
array $submitterValues,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
?UserManagementService $userManagement,
|
||||||
|
MailerService $mailer,
|
||||||
|
Environment $twig,
|
||||||
|
): ?Customer {
|
||||||
|
if (null !== $contrat->getCustomer()) {
|
||||||
|
return $contrat->getCustomer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Donnees du client depuis DocuSeal ou le contrat
|
||||||
|
$email = $submitterValues['Email'] ?? $contrat->getEmail();
|
||||||
|
$raisonSociale = $submitterValues['RaisonSociale'] ?? $contrat->getRaisonSociale();
|
||||||
|
$siret = $submitterValues['SIRET'] ?? null;
|
||||||
|
$adresse = $submitterValues['Adresse'] ?? null;
|
||||||
|
$telephone = $submitterValues['Telephone'] ?? null;
|
||||||
|
$representant = $submitterValues['Representant'] ?? null;
|
||||||
|
|
||||||
|
// Chercher un client existant par SIRET
|
||||||
|
if (null !== $siret && '' !== $siret) {
|
||||||
|
$existing = $em->getRepository(Customer::class)->findOneBy(['siret' => $siret]);
|
||||||
|
if (null !== $existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher par email
|
||||||
|
$existingByEmail = $em->createQuery(
|
||||||
|
'SELECT c FROM App\Entity\Customer c JOIN c.user u WHERE u.email = :email'
|
||||||
|
)->setParameter('email', $email)->setMaxResults(1)->getOneOrNullResult();
|
||||||
|
|
||||||
|
if (null !== $existingByEmail) {
|
||||||
|
// Mettre a jour le SIRET si manquant
|
||||||
|
if (null !== $siret && null === $existingByEmail->getSiret()) {
|
||||||
|
$existingByEmail->setSiret($siret);
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existingByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creer le client
|
||||||
|
if (null === $userManagement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire prenom/nom du representant
|
||||||
|
$firstName = $raisonSociale;
|
||||||
|
$lastName = '';
|
||||||
|
if (null !== $representant && '' !== $representant) {
|
||||||
|
$parts = explode(' ', $representant, 2);
|
||||||
|
$firstName = $parts[0];
|
||||||
|
$lastName = $parts[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $userManagement->createBaseUser($email, $firstName, $lastName, ['ROLE_CUSTOMER']);
|
||||||
|
$customer = new Customer($user);
|
||||||
|
$customer->setRaisonSociale($raisonSociale);
|
||||||
|
|
||||||
|
if (null !== $siret && '' !== $siret) {
|
||||||
|
$customer->setSiret($siret);
|
||||||
|
}
|
||||||
|
if (null !== $adresse && '' !== $adresse) {
|
||||||
|
$customer->setAddress($adresse);
|
||||||
|
}
|
||||||
|
if (null !== $telephone && '' !== $telephone) {
|
||||||
|
$customer->setPhone($telephone);
|
||||||
|
}
|
||||||
|
if (null !== $firstName && '' !== $firstName) {
|
||||||
|
$customer->setFirstName($firstName);
|
||||||
|
}
|
||||||
|
if ('' !== $lastName) {
|
||||||
|
$customer->setLastName($lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($customer);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
// Envoyer l'email de bienvenue avec le lien de creation de mot de passe
|
||||||
|
$setPasswordUrl = $this->generateUrl('app_set_password', [
|
||||||
|
'token' => $user->getTempPassword(),
|
||||||
|
], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
|
|
||||||
|
$mailer->sendEmail(
|
||||||
|
$email,
|
||||||
|
'Bienvenue - Votre espace client E-Cosplay',
|
||||||
|
$twig->render('emails/client_created.html.twig', [
|
||||||
|
'customer' => $customer,
|
||||||
|
'user' => $user,
|
||||||
|
'setPasswordUrl' => $setPasswordUrl,
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $customer;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\Advert;
|
use App\Entity\Advert;
|
||||||
use App\Entity\AdvertPayment;
|
use App\Entity\AdvertPayment;
|
||||||
|
use App\Entity\Contrat;
|
||||||
|
use App\Entity\CustomerPaymentMethod;
|
||||||
use App\Entity\Echeancier;
|
use App\Entity\Echeancier;
|
||||||
use App\Entity\EcheancierLine;
|
use App\Entity\EcheancierLine;
|
||||||
use App\Entity\Facture;
|
use App\Entity\Facture;
|
||||||
@@ -124,6 +126,13 @@ class WebhookStripeController extends AbstractController
|
|||||||
return $this->handleEFlexPaymentSucceeded($paymentIntent, (int) $eflexId, (int) $eflexLineId, $channel);
|
return $this->handleEFlexPaymentSucceeded($paymentIntent, (int) $eflexId, (int) $eflexLineId, $channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gestion premier paiement contrat (sauvegarde CB comme moyen de paiement)
|
||||||
|
$contratId = $metadata['contrat_id'] ?? null;
|
||||||
|
$firstPayment = $metadata['first_payment'] ?? null;
|
||||||
|
if (null !== $contratId && '1' === $firstPayment) {
|
||||||
|
$this->saveContratPaymentMethod($paymentIntent, (int) $contratId);
|
||||||
|
}
|
||||||
|
|
||||||
$advertId = $metadata['advert_id'] ?? null;
|
$advertId = $metadata['advert_id'] ?? null;
|
||||||
$advert = null !== $advertId ? $this->em->getRepository(Advert::class)->find((int) $advertId) : null;
|
$advert = null !== $advertId ? $this->em->getRepository(Advert::class)->find((int) $advertId) : null;
|
||||||
|
|
||||||
@@ -855,6 +864,59 @@ class WebhookStripeController extends AbstractController
|
|||||||
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_failed', 'position' => $line->getPosition()]);
|
return new JsonResponse(['status' => 'ok', 'action' => 'eflex_failed', 'position' => $line->getPosition()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde le moyen de paiement CB du premier paiement contrat.
|
||||||
|
*
|
||||||
|
* @codeCoverageIgnore
|
||||||
|
*/
|
||||||
|
private function saveContratPaymentMethod(object $paymentIntent, int $contratId): void
|
||||||
|
{
|
||||||
|
$contrat = $this->em->getRepository(Contrat::class)->find($contratId);
|
||||||
|
if (null === $contrat || null === $contrat->getCustomer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $contrat->getCustomer();
|
||||||
|
$pmId = $paymentIntent->payment_method ?? null;
|
||||||
|
if (null === $pmId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier si ce moyen de paiement existe deja
|
||||||
|
$existing = $this->em->getRepository(CustomerPaymentMethod::class)->findOneBy([
|
||||||
|
'customer' => $customer,
|
||||||
|
'stripePaymentMethodId' => (string) $pmId,
|
||||||
|
]);
|
||||||
|
if (null !== $existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retirer le default des autres
|
||||||
|
$existingMethods = $this->em->getRepository(CustomerPaymentMethod::class)->findBy(['customer' => $customer]);
|
||||||
|
foreach ($existingMethods as $m) {
|
||||||
|
$m->setIsDefault(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cpm = new CustomerPaymentMethod($customer, (string) $pmId, CustomerPaymentMethod::TYPE_CARD);
|
||||||
|
$cpm->setIsDefault(true);
|
||||||
|
|
||||||
|
// Essayer de recuperer les details de la carte
|
||||||
|
try {
|
||||||
|
$pm = \Stripe\PaymentMethod::retrieve((string) $pmId);
|
||||||
|
$card = $pm->card ?? null;
|
||||||
|
if (null !== $card) {
|
||||||
|
$cpm->setLast4($card->last4 ?? null);
|
||||||
|
$cpm->setBrand($card->brand ?? null);
|
||||||
|
$cpm->setCountry($card->country ?? null);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// silencieux
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($cpm);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cree un AdvertPayment pour une ligne d'echeancier payee, si un avis est lie.
|
* Cree un AdvertPayment pour une ligne d'echeancier payee, si un avis est lie.
|
||||||
*
|
*
|
||||||
|
|||||||
146
src/Entity/CustomerPaymentMethod.php
Normal file
146
src/Entity/CustomerPaymentMethod.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Index(columns: ['customer_id', 'is_default'], name: 'idx_customer_payment_method_default')]
|
||||||
|
class CustomerPaymentMethod
|
||||||
|
{
|
||||||
|
public const TYPE_SEPA = 'sepa_debit';
|
||||||
|
public const TYPE_CARD = 'card';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private Customer $customer;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private string $stripePaymentMethodId;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $type;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 4, nullable: true)]
|
||||||
|
private ?string $last4 = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
private ?string $brand = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 2, nullable: true)]
|
||||||
|
private ?string $country = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $isDefault = false;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct(Customer $customer, string $stripePaymentMethodId, string $type)
|
||||||
|
{
|
||||||
|
$this->customer = $customer;
|
||||||
|
$this->stripePaymentMethodId = $stripePaymentMethodId;
|
||||||
|
$this->type = $type;
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomer(): Customer
|
||||||
|
{
|
||||||
|
return $this->customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStripePaymentMethodId(): string
|
||||||
|
{
|
||||||
|
return $this->stripePaymentMethodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTypeLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
self::TYPE_SEPA => 'Prelevement SEPA',
|
||||||
|
self::TYPE_CARD => 'Carte bancaire',
|
||||||
|
default => $this->type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLast4(): ?string
|
||||||
|
{
|
||||||
|
return $this->last4;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLast4(?string $last4): static
|
||||||
|
{
|
||||||
|
$this->last4 = $last4;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBrand(): ?string
|
||||||
|
{
|
||||||
|
return $this->brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBrand(?string $brand): static
|
||||||
|
{
|
||||||
|
$this->brand = $brand;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCountry(): ?string
|
||||||
|
{
|
||||||
|
return $this->country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCountry(?string $country): static
|
||||||
|
{
|
||||||
|
$this->country = $country;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDefault(): bool
|
||||||
|
{
|
||||||
|
return $this->isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsDefault(bool $isDefault): static
|
||||||
|
{
|
||||||
|
$this->isDefault = $isDefault;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDisplayLabel(): string
|
||||||
|
{
|
||||||
|
$label = $this->getTypeLabel();
|
||||||
|
if (null !== $this->last4) {
|
||||||
|
$label .= ' **** '.$this->last4;
|
||||||
|
}
|
||||||
|
if (null !== $this->brand) {
|
||||||
|
$label .= ' ('.$this->brand.')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
'impayes': 'Impayes',
|
'impayes': 'Impayes',
|
||||||
'echeancier': 'Echeancier',
|
'echeancier': 'Echeancier',
|
||||||
'contrats': 'Contrats',
|
'contrats': 'Contrats',
|
||||||
|
'paiement': 'Methodes de paiement',
|
||||||
'ndd': 'Noms de domaine',
|
'ndd': 'Noms de domaine',
|
||||||
'esyflex': 'E-Flex',
|
'esyflex': 'E-Flex',
|
||||||
'sites': 'Sites Internet',
|
'sites': 'Sites Internet',
|
||||||
@@ -219,6 +220,33 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Moyens de paiement #}
|
||||||
|
{% if paymentMethods|length > 0 %}
|
||||||
|
<div class="glass p-5 mt-6">
|
||||||
|
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Moyens de paiement enregistres</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for pm in paymentMethods %}
|
||||||
|
<div class="glass p-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if pm.type == 'sepa_debit' %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-bold">{{ pm.displayLabel }}</p>
|
||||||
|
<p class="text-[10px] text-gray-400">Ajoute le {{ pm.createdAt|date('d/m/Y') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if pm.isDefault %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[9px]">Par defaut</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Tab: Contacts #}
|
{# Tab: Contacts #}
|
||||||
{% elseif tab == 'contacts' %}
|
{% elseif tab == 'contacts' %}
|
||||||
<section class="glass p-6 mb-6">
|
<section class="glass p-6 mb-6">
|
||||||
@@ -1254,6 +1282,64 @@
|
|||||||
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contrat pour ce client.</div>
|
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contrat pour ce client.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Tab: Methodes de paiement #}
|
||||||
|
{% elseif tab == 'paiement' %}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-bold uppercase">Methodes de paiement</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if paymentMethods|length > 0 %}
|
||||||
|
<div class="glass overflow-x-auto overflow-hidden mb-6">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="glass-dark text-white">
|
||||||
|
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Details</th>
|
||||||
|
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Pays</th>
|
||||||
|
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Par defaut</th>
|
||||||
|
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Ajoute le</th>
|
||||||
|
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Stripe ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pm in paymentMethods %}
|
||||||
|
<tr class="border-b border-white/20 hover:bg-white/50">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if pm.type == 'sepa_debit' %}
|
||||||
|
<span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">SEPA</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-700 font-bold uppercase text-[10px]">CB</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-bold text-xs">
|
||||||
|
**** {{ pm.last4 ?: '****' }}
|
||||||
|
{% if pm.brand %}<span class="text-gray-400 ml-1">({{ pm.brand }})</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-center text-xs">{{ pm.country ?: '—' }}</td>
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
{% if pm.isDefault %}
|
||||||
|
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Oui</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 text-xs">Non</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-500">{{ pm.createdAt|date('d/m/Y H:i') }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-[10px] text-gray-400">{{ pm.stripePaymentMethodId }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass p-4">
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Le moyen de paiement par defaut sera utilise pour les prelevements automatiques des avis de paiement le dernier jour de chaque mois.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun moyen de paiement enregistre pour ce client.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Tab: E-Flex #}
|
{# Tab: E-Flex #}
|
||||||
{% elseif tab == 'esyflex' %}
|
{% elseif tab == 'esyflex' %}
|
||||||
{% set hasActiveEflex = false %}
|
{% set hasActiveEflex = false %}
|
||||||
|
|||||||
36
templates/contrat/payment_success.html.twig
Normal file
36
templates/contrat/payment_success.html.twig
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Paiement configure - {{ contrat.reference }} - Association E-Cosplay{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||||
|
<div class="glass-heavy w-full max-w-lg overflow-hidden">
|
||||||
|
<div class="glass-dark text-white px-8 py-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold uppercase tracking-widest">Paiement configure</h1>
|
||||||
|
<p class="text-xs text-white/60">{{ contrat.reference }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Votre mode de paiement a ete configure avec succes. Vos services seront actifs prochainement.
|
||||||
|
</p>
|
||||||
|
<div class="glass p-4 mb-4 text-left">
|
||||||
|
<p class="text-xs text-gray-500"><strong>Contrat :</strong> {{ contrat.reference }}</p>
|
||||||
|
<p class="text-xs text-gray-500"><strong>Montant :</strong> {{ contrat.totalHt|number_format(2, ',', ' ') }} € HT / mois</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Vous recevrez un email de confirmation a chaque prelevement. Votre espace client est accessible sur <a href="https://client.e-cosplay.fr" class="font-bold" style="color: #fabf04;">client.e-cosplay.fr</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-4">
|
||||||
|
Pour toute question : <a href="mailto:client@e-cosplay.fr" class="font-bold" style="color: #fabf04;">client@e-cosplay.fr</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
178
templates/contrat/process.html.twig
Normal file
178
templates/contrat/process.html.twig
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Contrat {{ contrat.reference }} - Association E-Cosplay{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="min-h-screen" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||||
|
|
||||||
|
{# Header #}
|
||||||
|
<div class="glass-dark text-white py-8">
|
||||||
|
<div class="max-w-4xl mx-auto px-6">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<img src="/logo.jpg" alt="E-Cosplay" class="h-14 w-auto">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold uppercase tracking-widest">Contrat de Service</h1>
|
||||||
|
<p class="text-sm text-white/60">{{ contrat.reference }} - {{ contrat.typeLabel }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto px-6 py-10">
|
||||||
|
|
||||||
|
{# Statut #}
|
||||||
|
{% if contrat.state == 'signed' %}
|
||||||
|
<div class="glass-heavy p-6 mb-8 flex items-center gap-4" style="border-left: 4px solid #16a34a;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-green-700">Contrat signe</p>
|
||||||
|
<p class="text-sm text-gray-500">Ce contrat a ete signe le {{ contrat.signedAt ? contrat.signedAt|date('d/m/Y H:i') : '' }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elseif contrat.state == 'cancelled' %}
|
||||||
|
<div class="glass-heavy p-6 mb-8 flex items-center gap-4" style="border-left: 4px solid #dc2626;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-red-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-bold text-red-700">Contrat annule</p>
|
||||||
|
<p class="text-sm text-gray-500">Ce contrat a ete annule.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Informations du contrat #}
|
||||||
|
<div class="glass-heavy p-8 mb-8">
|
||||||
|
<h2 class="text-xl font-bold uppercase tracking-wider mb-4">Informations</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="glass p-5">
|
||||||
|
<h3 class="text-sm font-bold uppercase tracking-wider mb-3" style="color: #fabf04;">Association E-Cosplay</h3>
|
||||||
|
<div class="space-y-1 text-xs text-gray-600">
|
||||||
|
<p>Association loi 1901 - RNA W022006988</p>
|
||||||
|
<p>42 rue de Saint-Quentin, 02800 Beautor</p>
|
||||||
|
<p>SIRET : 943 121 517 00011</p>
|
||||||
|
<p>President : <strong>Shoko Cosplay - Serreau Jovann</strong></p>
|
||||||
|
<p>Email : <a href="mailto:client@e-cosplay.fr" style="color: #fabf04;">client@e-cosplay.fr</a></p>
|
||||||
|
<p>Tel : 07 66 95 70 06</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="glass p-5">
|
||||||
|
<h3 class="text-sm font-bold uppercase tracking-wider mb-3">Client</h3>
|
||||||
|
<div class="space-y-1 text-xs text-gray-600">
|
||||||
|
<p><strong>{{ contrat.raisonSociale }}</strong></p>
|
||||||
|
<p>{{ contrat.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Services #}
|
||||||
|
{% if contrat.services|length > 0 %}
|
||||||
|
<div class="glass-heavy p-8 mb-8">
|
||||||
|
<h2 class="text-xl font-bold uppercase tracking-wider mb-4">Services inclus</h2>
|
||||||
|
<div class="glass overflow-hidden">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="glass-dark text-white">
|
||||||
|
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Service</th>
|
||||||
|
<th class="px-4 py-2 text-center font-bold uppercase text-[10px] tracking-widest">Quantite</th>
|
||||||
|
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Prix HT</th>
|
||||||
|
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Sous-total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set catalogLabels = {
|
||||||
|
'esite_vitrine': 'E-Site Vitrine',
|
||||||
|
'esite_ecommerce': 'E-Site E-Commerce',
|
||||||
|
'esite_hors_cms': 'Site hors CMS Esy-Web',
|
||||||
|
'email_3go': 'E-Mail 3 Go',
|
||||||
|
'email_50go': 'E-Mail 50 Go',
|
||||||
|
'ndd_gestion': 'Nom de domaine - Gestion',
|
||||||
|
'ndd_renouvellement': 'Nom de domaine - Renouvellement',
|
||||||
|
'eprotect': 'E-Protect Pro',
|
||||||
|
'ecalendar': 'E-Calendar',
|
||||||
|
'echat': 'E-Chat',
|
||||||
|
'emailer': 'E-Mailer'
|
||||||
|
} %}
|
||||||
|
{% for s in contrat.services %}
|
||||||
|
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
|
||||||
|
<td class="px-4 py-2 font-bold text-xs">{{ catalogLabels[s.service] ?? s.service }}</td>
|
||||||
|
<td class="px-4 py-2 text-center text-xs">{{ s.quantity }}</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs font-mono">{{ s.priceHt|number_format(2, ',', ' ') }} €</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs font-mono font-bold">{{ (s.priceHt * s.quantity)|number_format(2, ',', ' ') }} €</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="glass-dark text-white">
|
||||||
|
<td colspan="3" class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Total HT / mois</td>
|
||||||
|
<td class="px-4 py-2 text-right font-mono font-bold">{{ contrat.totalHt|number_format(2, ',', ' ') }} €</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Conditions importantes #}
|
||||||
|
<div class="glass-heavy p-8 mb-8">
|
||||||
|
<h2 class="text-xl font-bold uppercase tracking-wider mb-4">Conditions importantes</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="glass p-4" style="border-left: 3px solid #fabf04;">
|
||||||
|
<p class="text-xs font-bold mb-1">Paiement</p>
|
||||||
|
<p class="text-xs text-gray-500">Le premier paiement devra etre effectue par carte bancaire ou prelevement SEPA. Les virements ne sont pas acceptes pour le premier paiement.</p>
|
||||||
|
</div>
|
||||||
|
<div class="glass p-4" style="border-left: 3px solid #dc2626;">
|
||||||
|
<p class="text-xs font-bold mb-1">Impayes</p>
|
||||||
|
<p class="text-xs text-gray-500">En cas de non-paiement, les services seront automatiquement suspendus. Aucun renouvellement sans paiement prealable.</p>
|
||||||
|
</div>
|
||||||
|
<div class="glass p-4" style="border-left: 3px solid #ea580c;">
|
||||||
|
<p class="text-xs font-bold mb-1">Avertissements</p>
|
||||||
|
<p class="text-xs text-gray-500">En cas de manquement, un systeme d'avertissement s'applique. Au 3eme avertissement, la resiliation est definitive.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Liens utiles #}
|
||||||
|
<div class="glass-heavy p-8 mb-8">
|
||||||
|
<h2 class="text-xl font-bold uppercase tracking-wider mb-4">Documents et liens utiles</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<a href="{{ path('app_move_from_siteconseil') }}" target="_blank" class="glass p-4 text-center hover:bg-white/80 transition-all">
|
||||||
|
<p class="text-sm font-bold mb-1">Page migration</p>
|
||||||
|
<p class="text-xs text-gray-500">Toutes les informations sur la migration</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ path('app_legal_tarif') }}" target="_blank" class="glass p-4 text-center hover:bg-white/80 transition-all">
|
||||||
|
<p class="text-sm font-bold mb-1">Grille tarifaire</p>
|
||||||
|
<p class="text-xs text-gray-500">Tarifs en vigueur</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ path('app_legal_cgv') }}" target="_blank" class="glass p-4 text-center hover:bg-white/80 transition-all">
|
||||||
|
<p class="text-sm font-bold mb-1">CGV</p>
|
||||||
|
<p class="text-xs text-gray-500">Conditions generales de vente</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Bouton signer #}
|
||||||
|
{% if contrat.state == 'send' and contrat.submissionId %}
|
||||||
|
<div class="glass-heavy p-8 text-center mb-8">
|
||||||
|
<p class="text-sm text-gray-600 mb-4">En signant ce contrat, vous acceptez les conditions ci-dessus, les CGV et la grille tarifaire de l'Association E-Cosplay.</p>
|
||||||
|
<a href="{{ path('app_contrat_sign', {id: contrat.id}) }}"
|
||||||
|
class="inline-block px-8 py-4 text-white font-bold uppercase text-sm tracking-wider hover:opacity-90 transition-all" style="background: #fabf04; color: #111;">
|
||||||
|
Signer le contrat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Contact #}
|
||||||
|
<div class="glass-heavy p-8 text-center">
|
||||||
|
<p class="text-sm text-gray-600 mb-2">Une question sur ce contrat ?</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
<a href="mailto:client@e-cosplay.fr" class="font-bold text-lg" style="color: #fabf04;">client@e-cosplay.fr</a>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-2">Tel : 07 66 95 70 06</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
140
templates/contrat/setup_payment.html.twig
Normal file
140
templates/contrat/setup_payment.html.twig
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Configuration paiement - {{ contrat.reference }} - Association E-Cosplay{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||||
|
<div class="glass-heavy w-full max-w-2xl overflow-hidden">
|
||||||
|
<div class="glass-dark text-white px-8 py-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img src="/logo.jpg" alt="E-Cosplay" class="h-10 w-auto">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold uppercase tracking-widest">Configuration du paiement</h1>
|
||||||
|
<p class="text-xs text-white/60">{{ contrat.reference }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-8">
|
||||||
|
<p class="text-sm text-gray-600 mb-6">Choisissez votre mode de paiement pour activer vos services.</p>
|
||||||
|
|
||||||
|
{# Resume #}
|
||||||
|
<div class="glass p-4 mb-6 text-center">
|
||||||
|
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Montant mensuel</p>
|
||||||
|
<p class="text-3xl font-bold mt-1" style="color: #fabf04;">{{ totalHt|number_format(2, ',', ' ') }} € <span class="text-sm text-gray-500">HT / mois</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Choix methode #}
|
||||||
|
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Comment souhaitez-vous payer ?</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
{# Carte bancaire #}
|
||||||
|
<div class="glass p-5 text-center hover:bg-white/80 transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-2 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider mb-2">Carte bancaire</p>
|
||||||
|
<p class="text-[10px] text-gray-500 mb-3">Payez votre premier mois immediatement par carte bancaire via Stripe.</p>
|
||||||
|
{% if cbCheckoutUrl %}
|
||||||
|
<a href="{{ cbCheckoutUrl }}"
|
||||||
|
class="inline-block px-4 py-2 bg-purple-600 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-purple-700 transition-all">
|
||||||
|
Payer {{ totalHt|number_format(2, ',', ' ') }} € par CB
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# SEPA #}
|
||||||
|
<div class="glass p-5 text-center hover:bg-white/80 transition-all" style="border: 2px solid #fabf04;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-2" style="color: #fabf04;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider mb-2">Prelevement SEPA</p>
|
||||||
|
<span class="inline-block px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider mb-2" style="background: #fabf04; color: #111;">Recommande</span>
|
||||||
|
<p class="text-[10px] text-gray-500 mb-3">Renseignez votre IBAN une seule fois. Les prelevements seront automatiques chaque mois.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Formulaire SEPA #}
|
||||||
|
<div class="glass p-5 mb-6">
|
||||||
|
<h3 class="text-sm font-bold uppercase tracking-wider mb-3">Configurer le prelevement SEPA</h3>
|
||||||
|
<form id="sepa-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="account-name" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Titulaire du compte</label>
|
||||||
|
<input type="text" id="account-name" required value="{{ contrat.raisonSociale }}" class="input-glass w-full px-4 py-3 text-sm font-bold">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="account-email" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email</label>
|
||||||
|
<input type="email" id="account-email" required value="{{ contrat.email }}" class="input-glass w-full px-4 py-3 text-sm font-bold">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">IBAN</label>
|
||||||
|
<div id="iban-element" class="input-glass w-full px-4 py-3"></div>
|
||||||
|
<div id="iban-errors" class="text-red-500 text-xs mt-1 hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass p-3 mb-4 text-xs text-gray-500 leading-relaxed">
|
||||||
|
<p class="font-bold text-[9px] uppercase tracking-wider text-gray-400 mb-1">Mandat SEPA</p>
|
||||||
|
<p>En fournissant vos informations de paiement, vous autorisez Association E-Cosplay et Stripe a debiter votre compte conformement aux instructions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="form-error" class="mb-3 p-3 bg-red-500/20 text-red-700 font-bold text-xs hidden"></div>
|
||||||
|
|
||||||
|
<button type="submit" id="submit-btn" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900 disabled:opacity-50">
|
||||||
|
<span id="btn-text">Autoriser le prelevement SEPA</span>
|
||||||
|
<span id="btn-loading" class="hidden">Traitement en cours...</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-gray-400">Pour toute question : <a href="mailto:client@e-cosplay.fr" class="font-bold" style="color: #fabf04;">client@e-cosplay.fr</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://js.stripe.com/v3/" nonce="{{ csp_nonce('script') }}"></script>
|
||||||
|
<script nonce="{{ csp_nonce('script') }}">
|
||||||
|
(function() {
|
||||||
|
var stripe = Stripe('{{ stripePk }}');
|
||||||
|
var elements = stripe.elements();
|
||||||
|
var style = { base: { color: '#111827', fontSize: '14px', fontFamily: 'Arial, sans-serif', '::placeholder': { color: '#9ca3af' } }, invalid: { color: '#dc2626' } };
|
||||||
|
var iban = elements.create('iban', { style: style, supportedCountries: ['SEPA'] });
|
||||||
|
iban.mount('#iban-element');
|
||||||
|
|
||||||
|
var errorEl = document.getElementById('iban-errors');
|
||||||
|
iban.on('change', function(event) {
|
||||||
|
if (event.error) { errorEl.textContent = event.error.message; errorEl.classList.remove('hidden'); }
|
||||||
|
else { errorEl.classList.add('hidden'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
var form = document.getElementById('sepa-form');
|
||||||
|
var submitBtn = document.getElementById('submit-btn');
|
||||||
|
var btnText = document.getElementById('btn-text');
|
||||||
|
var btnLoading = document.getElementById('btn-loading');
|
||||||
|
var formError = document.getElementById('form-error');
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
btnText.classList.add('hidden');
|
||||||
|
btnLoading.classList.remove('hidden');
|
||||||
|
formError.classList.add('hidden');
|
||||||
|
|
||||||
|
stripe.confirmSepaDebitSetup('{{ clientSecret }}', {
|
||||||
|
payment_method: { sepa_debit: iban, billing_details: { name: document.getElementById('account-name').value, email: document.getElementById('account-email').value } }
|
||||||
|
}).then(function(result) {
|
||||||
|
if (result.error) {
|
||||||
|
formError.textContent = result.error.message; formError.classList.remove('hidden');
|
||||||
|
submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('{{ path('app_contrat_setup_payment_confirm', {id: contrat.id}) }}', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ payment_method: result.setupIntent.payment_method })
|
||||||
|
}).then(function(res) { return res.json(); }).then(function(data) {
|
||||||
|
if (data.status === 'ok') { window.location.href = '{{ path('app_contrat_payment_success', {id: contrat.id}) }}'; }
|
||||||
|
else { formError.textContent = data.error || 'Erreur.'; formError.classList.remove('hidden'); submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); }
|
||||||
|
}).catch(function() { formError.textContent = 'Erreur de connexion.'; formError.classList.remove('hidden'); submitBtn.disabled = false; btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
32
templates/contrat/verify.html.twig
Normal file
32
templates/contrat/verify.html.twig
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Verification - Contrat {{ contrat.reference }} - Association E-Cosplay{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(135deg, #f5f5f0 0%, #e8e8e0 100%);">
|
||||||
|
<div class="glass-heavy w-full max-w-md overflow-hidden">
|
||||||
|
<div class="glass-dark text-white px-8 py-6 text-center">
|
||||||
|
<h1 class="text-lg font-bold uppercase tracking-widest">Verification</h1>
|
||||||
|
<p class="text-xs text-white/60 mt-1">{{ contrat.reference }} - Un code a ete envoye a {{ contrat.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-8">
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Saisissez le code de verification a 6 chiffres recu par email pour acceder a votre contrat.</p>
|
||||||
|
<form method="post" action="{{ path('app_contrat_verify', {id: contrat.id}) }}">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="code" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Code de verification</label>
|
||||||
|
<input type="text" id="code" name="code" maxlength="6" pattern="[0-9]{6}" required autofocus
|
||||||
|
class="input-glass w-full px-4 py-3 text-center text-2xl font-bold tracking-[0.5em]" placeholder="000000">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full btn-gold px-4 py-3 font-bold uppercase text-xs tracking-wider text-gray-900">Verifier</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center text-xs text-gray-400 mt-4">Le code expire dans 15 minutes.</p>
|
||||||
|
<form method="post" action="{{ path('app_contrat_resend_code', {id: contrat.id}) }}" class="mt-3 text-center">
|
||||||
|
<button type="submit" class="text-xs font-bold uppercase tracking-wider text-gray-500 hover:text-gray-900 underline transition-all">Renvoyer le code</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
43
templates/emails/advert_auto_payment_notice.html.twig
Normal file
43
templates/emails/advert_auto_payment_notice.html.twig
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends 'email/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
{% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %}
|
||||||
|
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">{{ greeting }},</h1>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
|
||||||
|
Nous vous informons qu'un prelevement automatique sera effectue pour votre avis de paiement <strong>{{ advert.orderNumber.numOrder }}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 35%;">Avis</td>
|
||||||
|
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ advert.orderNumber.numOrder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Montant</td>
|
||||||
|
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #fabf04;">{{ amount|number_format(2, ',', ' ') }} €</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Moyen de paiement</td>
|
||||||
|
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ methodLabel }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Date du prelevement</td>
|
||||||
|
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ "now"|date('d/m/Y') }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
|
||||||
|
Ce prelevement sera effectue automatiquement via votre moyen de paiement enregistre. Vous recevrez un email de confirmation une fois le paiement traite.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
|
||||||
|
Pour toute question : <a href="mailto:client@e-cosplay.fr" style="color: #fabf04;">client@e-cosplay.fr</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
54
templates/emails/contrat_setup_payment.html.twig
Normal file
54
templates/emails/contrat_setup_payment.html.twig
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends 'email/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">Chez {{ contrat.raisonSociale }},</h1>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
|
||||||
|
Votre contrat <strong>{{ contrat.reference }}</strong> a ete signe avec succes. Pour activer vos services, veuillez configurer votre mode de paiement et effectuer le premier reglement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0; border: 1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb; width: 35%;">Reference</td>
|
||||||
|
<td style="padding: 10px 16px; font-family: monospace; font-size: 13px; font-weight: 700;">{{ contrat.reference }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #9ca3af; background: #f9fafb;">Total HT / mois</td>
|
||||||
|
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #fabf04;">{{ contrat.totalHt|number_format(2, ',', ' ') }} €</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 0 0 16px;">
|
||||||
|
Vous avez le choix entre :
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px; margin: 0 0 16px; padding-left: 20px;">
|
||||||
|
<li style="margin-bottom: 8px;"><strong>Payer par carte bancaire</strong> : reglez votre premier mois immediatement.</li>
|
||||||
|
<li style="margin-bottom: 8px;"><strong>Configurer le prelevement SEPA</strong> : renseignez votre IBAN une seule fois, les prelevements seront automatiques chaque mois.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if paymentUrl is defined and paymentUrl %}
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin: 24px auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #fabf04; padding: 14px 32px;">
|
||||||
|
<a href="{{ paymentUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Configurer mon paiement</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="background: #fef2f2; border-left: 4px solid #dc2626; padding: 12px 16px; margin: 20px 0;">
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; font-weight: 700; color: #991b1b; margin: 0 0 4px;">Important</p>
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 11px; color: #374151; margin: 0;">Le premier paiement doit etre effectue par carte bancaire ou prelevement SEPA. Les virements ne sont pas acceptes pour le premier paiement.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
|
||||||
|
Pour toute question : <a href="mailto:client@e-cosplay.fr" style="color: #fabf04;">client@e-cosplay.fr</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -33,14 +33,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% if signUrl %}
|
{% if processUrl is defined and processUrl %}
|
||||||
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
|
||||||
Une fois informe, veuillez signer le contrat en cliquant ci-dessous :
|
Veuillez consulter les details du contrat et le signer en cliquant ci-dessous :
|
||||||
</p>
|
</p>
|
||||||
<table cellpadding="0" cellspacing="0" style="margin: 20px auto;">
|
<table cellpadding="0" cellspacing="0" style="margin: 20px auto;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #fabf04; padding: 14px 32px;">
|
<td style="background-color: #fabf04; padding: 14px 32px;">
|
||||||
<a href="{{ signUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Signer le contrat</a>
|
<a href="{{ processUrl }}" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #111827; text-decoration: none;">Voir et signer le contrat</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
14
templates/emails/contrat_verify_code.html.twig
Normal file
14
templates/emails/contrat_verify_code.html.twig
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends 'email/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px; text-align: center;">
|
||||||
|
<h1 style="font-family: Arial, Helvetica, sans-serif; font-size: 22px; font-weight: 700; color: #111827; margin: 0 0 16px;">Code de verification</h1>
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; margin: 0 0 20px;">Contrat {{ contrat.reference }}</p>
|
||||||
|
<div style="background: #111827; color: #fabf04; font-family: monospace; font-size: 36px; font-weight: 700; letter-spacing: 12px; padding: 20px; display: inline-block;">{{ code }}</div>
|
||||||
|
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 20px 0 0;">Ce code expire dans 15 minutes.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -214,10 +214,10 @@
|
|||||||
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[9px]">Nouveau tarif</span>
|
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[9px]">Nouveau tarif</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end gap-2 mb-3">
|
<div class="flex items-end gap-2 mb-3">
|
||||||
<span class="text-3xl font-bold" style="color: #fabf04;">100 €</span>
|
<span class="text-3xl font-bold" style="color: #fabf04;">50 €</span>
|
||||||
<span class="text-sm text-gray-500 mb-1">/ mois HT</span>
|
<span class="text-sm text-gray-500 mb-1">/ mois HT</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mb-3">Soit <strong>1 200 € HT / an</strong></p>
|
<p class="text-xs text-gray-500 mb-3">Soit <strong>600 € HT / an</strong></p>
|
||||||
<div class="glass p-3">
|
<div class="glass p-3">
|
||||||
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Ancien tarif SITECONSEIL</p>
|
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Ancien tarif SITECONSEIL</p>
|
||||||
<p class="text-sm text-gray-400 line-through">365 € HT / an</p>
|
<p class="text-sm text-gray-400 line-through">365 € HT / an</p>
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
<p class="text-xs text-gray-500 mb-3">Soit <strong>1 200 € HT / an</strong> - Hebergement uniquement</p>
|
<p class="text-xs text-gray-500 mb-3">Soit <strong>1 200 € HT / an</strong> - Hebergement uniquement</p>
|
||||||
<div class="glass p-3 mb-3" style="border-left: 3px solid #dc2626;">
|
<div class="glass p-3 mb-3" style="border-left: 3px solid #dc2626;">
|
||||||
<p class="text-xs text-red-700 font-bold">Ce tarif est non negociable.</p>
|
<p class="text-xs text-red-700 font-bold">Ce tarif est non negociable.</p>
|
||||||
<p class="text-xs text-gray-500 mt-1">Votre site etant hors de notre CMS Esy-Web, il necessite une infrastructure dediee (machine a part).</p>
|
<p class="text-xs text-gray-500 mt-1">Votre site etant hors du CMS Esy-Web, il necessite une infrastructure dediee (machine a part).</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="glass p-3" style="border-left: 3px solid #ea580c;">
|
<div class="glass p-3" style="border-left: 3px solid #ea580c;">
|
||||||
<p class="text-xs text-orange-700 font-bold">Aucune maintenance incluse</p>
|
<p class="text-xs text-orange-700 font-bold">Aucune maintenance incluse</p>
|
||||||
@@ -314,11 +314,27 @@
|
|||||||
<div class="glass-heavy p-8 mb-8" style="border-left: 4px solid #7c3aed;">
|
<div class="glass-heavy p-8 mb-8" style="border-left: 4px solid #7c3aed;">
|
||||||
<h2 class="text-lg font-bold uppercase tracking-wider mb-3 text-purple-700">Developpement sur-mesure</h2>
|
<h2 class="text-lg font-bold uppercase tracking-wider mb-3 text-purple-700">Developpement sur-mesure</h2>
|
||||||
<p class="text-sm text-gray-600 leading-relaxed mb-3">
|
<p class="text-sm text-gray-600 leading-relaxed mb-3">
|
||||||
Si vous disposez d'un <strong>developpement sur-mesure lie a SARL SITECONSEIL</strong> (CRM, application metier, outil interne, etc.), l'Association E-Cosplay sera <strong>dans l'obligation de refuser de vous prendre en charge</strong>, sauf si le developpement est entierement termine et livre.
|
Si vous beneficiez d'un <strong>developpement sur-mesure realise par la SARL SITECONSEIL</strong> (CRM, application metier, outil interne, plateforme specifique, etc.), celui-ci devra etre <strong>integralement termine, livre et en production</strong> a la date de la migration pour pouvoir etre repris par l'association.
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600 leading-relaxed">
|
|
||||||
<strong>Raison :</strong> l'association n'aura pas la capacite de gerer ou poursuivre des developpements sur-mesure inities par la SARL SITECONSEIL. Seuls les services standards (sites vitrine, e-commerce, emails, domaines) sont concernes par la migration.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="glass p-4" style="border-left: 3px solid #7c3aed;">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-wider text-purple-700 mb-2">Et si mon developpement sur-mesure n'est pas termine ?</p>
|
||||||
|
<p class="text-xs text-gray-600 leading-relaxed mb-2">
|
||||||
|
Si votre projet sur-mesure est <strong>encore en cours de developpement</strong> a la date de la migration, plusieurs options s'offrent a vous :
|
||||||
|
</p>
|
||||||
|
<ul class="text-xs text-gray-600 leading-relaxed list-disc pl-5 space-y-1">
|
||||||
|
<li><strong>Finalisation anticipee par SITECONSEIL :</strong> demander a la SARL SITECONSEIL de terminer et livrer le developpement <strong>avant</strong> la date de migration, afin qu'il puisse etre integre au perimetre repris par l'association.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Reprise et finalisation par l'Association E-Cosplay :</strong> dans certains cas, l'association peut accepter de <strong>poursuivre et terminer le developpement</strong> du projet. Cette option est soumise a un <strong>double accord prealable</strong> : celui du client et celui de l'association, en fonction de la nature du projet, de son etat d'avancement et des ressources disponibles.
|
||||||
|
<br>
|
||||||
|
<span class="text-gray-500">Si l'accord est conclu, une <strong>facture ou un financement differe</strong> (echelonnement, paiement a la livraison, etc.) sera alors etabli pour couvrir les travaux restants.</span>
|
||||||
|
</li>
|
||||||
|
<li><strong>Reprise par un autre prestataire :</strong> confier la suite du projet a un developpeur ou une agence de votre choix. Le code source et les elements techniques pourront vous etre remis sur demande.</li>
|
||||||
|
<li><strong>Abandon du projet :</strong> si aucune des options ci-dessus n'est envisageable, le developpement sera <strong>interrompu</strong> et ne sera ni repris, ni heberge, ni maintenu par l'Association E-Cosplay.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs text-gray-500 leading-relaxed mt-3 italic">
|
||||||
|
Nous vous invitons a <strong>nous contacter au plus tot</strong> pour faire le point sur votre situation et anticiper la meilleure solution avant la migration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Avertissement important #}
|
{# Avertissement important #}
|
||||||
|
|||||||
Reference in New Issue
Block a user