Compare commits

...

10 Commits

Author SHA1 Message Date
Serreau Jovann
88ebba6ce5 init 2026-04-10 17:47:57 +02:00
Serreau Jovann
857683cf70 init 2026-04-10 17:47:54 +02:00
Serreau Jovann
053da2ba8f feat: tab Methodes de paiement dans fiche client
Tableau avec : type (SEPA/CB), details (last4, brand), pays,
par defaut, date ajout, Stripe ID.
Info : le moyen par defaut est utilise pour les prelevements auto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:12:02 +02:00
Serreau Jovann
6e5e389b7d feat: CustomerPaymentMethod + prelevement auto avis dernier jour du mois
Entity CustomerPaymentMethod:
- customer, stripePaymentMethodId, type (sepa_debit/card)
- last4, brand, country, isDefault
- getDisplayLabel() pour affichage

Sauvegarde automatique du moyen de paiement:
- Contrat SEPA setup: cree CustomerPaymentMethod type SEPA
- Contrat CB premier paiement: webhook sauvegarde la carte
- Retire le default des anciens moyens de paiement

Commande cron app:advert:auto-payment:
- S'execute uniquement le dernier jour du mois
- Trouve les avis envoyes (state=send) avec client ayant un
  moyen de paiement par defaut
- Envoie un email d'annonce de prelevement au client
- Cree un PaymentIntent off_session avec le moyen de paiement
- Le webhook payment_intent.succeeded traite le paiement

Admin fiche client tab info:
- Affiche les moyens de paiement enregistres (type, last4, defaut)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:10:08 +02:00
Serreau Jovann
f51f28fc0b feat: page configuration paiement contrat (CB + SEPA) + email automatique
Apres signature du contrat, le webhook envoie un email avec lien
vers /process/contrat/{id}/setup-payment

Page setup-payment :
- Resume montant mensuel HT
- Choix CB (Stripe Checkout avec setup_future_usage) ou SEPA
- Formulaire IBAN Stripe Elements avec mandat SEPA
- Confirmation SEPA via endpoint POST /confirm
- Page succes apres paiement

Routes :
- /process/contrat/{id}/setup-payment : page choix CB/SEPA
- /process/contrat/{id}/setup-payment/confirm : confirmation SEPA
- /process/contrat/{id}/payment-success : page succes

Email contrat_setup_payment : lien vers la page de configuration,
detail montant, mention 1er paiement CB/SEPA obligatoire

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:03:00 +02:00
Serreau Jovann
17dff8ef8a feat: creation automatique du client apres signature contrat
Webhook contrat form.completed :
- Extrait les valeurs DocuSeal (RaisonSociale, SIRET, Adresse, Email,
  Telephone, Representant) remplies par le client
- Cherche un Customer existant par SIRET, puis par email
- Si existant : lie au contrat, met a jour SIRET si manquant
- Si inexistant : cree User (ROLE_CUSTOMER) + Customer avec toutes
  les infos, lie au contrat
- Envoie l'email de bienvenue avec lien creation mot de passe
- Le contrat est automatiquement lie au Customer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:59:09 +02:00
Serreau Jovann
23a5e92292 feat: authentification par code email pour contrats (verify/resend)
- Route /process/contrat/{id}/verify : saisie code 6 chiffres
- Code envoye par email au client, expire 15 minutes
- Bouton "Renvoyer le code"
- Protection process et sign derriere la verification session
- Template verify.html.twig + email contrat_verify_code.html.twig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:55:24 +02:00
Serreau Jovann
14527227a8 feat: page publique /process/contrat/{id} avant signature
Page process contrat avec :
- Header avec reference et type
- Statut (signe/annule)
- Infos association (SIRET, RNA, president, contact)
- Infos client (raison sociale, email)
- Tableau services inclus avec total HT/mois
- Conditions importantes (paiement, impayes, avertissements)
- Liens utiles (page migration, tarifs, CGV)
- Bouton "Signer le contrat" (redirige vers DocuSeal)
- Contact

Controller ContratProcessController :
- /process/contrat/{id} : page de detail
- /process/contrat/{id}/sign : redirection vers DocuSeal

Email contrat_signature : lien vers /process/contrat/{id}
au lieu du lien DocuSeal direct

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:52:53 +02:00
Serreau Jovann
8b62211f8f fix: retrait bloc INFORMATIONS LEGALES doublon du PDF contrat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:49:58 +02:00
Serreau Jovann
3f2d8672d0 feat: bloc informations legales association dans PDF contrat (style ComptaPdf)
Bloc entre le header et le preambule avec :
- Association, RNA, siege social, SIRET, Code APE
- President Shoko Cosplay - Serreau Jovann
- Email, telephone, site web
- Reference du document

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:42:48 +02:00
21 changed files with 1808 additions and 45 deletions

View File

@@ -1,34 +1,100 @@
#!/bin/bash
# CRM SITECONSEIL database backup script
# Runs every 30 minutes via cron
# CRM SITECONSEIL - Backup script (database + files)
# Usage: /usr/local/bin/crm-siteconseil-backup.sh
# Schedule via cron (ex: every 30 minutes)
BACKUP_DIR="/var/backups/crm-siteconseil"
DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="crm_siteconseil_${DATE}.sql.gz"
KEEP_DAYS=1
set -euo pipefail
# 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
if [ -f "${BACKUP_DIR}/${FILENAME}" ] && [ -s "${BACKUP_DIR}/${FILENAME}" ]; then
echo "[$(date)] DB Backup OK: ${FILENAME} ($(du -h "${BACKUP_DIR}/${FILENAME}" | cut -f1))"
else
echo "[$(date)] ERROR: DB Backup failed"
exit 1
DB_SERVICE="{{ db_service | default('db-master') }}"
DB_USER="{{ db_user | default('crm-siteconseil') }}"
DB_NAME="{{ db_name | default('crm-siteconseil') }}"
DATE="$(date +%Y%m%d_%H%M%S)"
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
# Backup uploads
UPLOADS_FILENAME="uploads_${DATE}.tar.gz"
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"
if [ ! -s "${DB_FILE}" ]; then
fail "database dump is empty"
fi
# Remove backups older than KEEP_DAYS days
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"
log "DB Backup OK: $(du -h "${DB_FILE}" | cut -f1)"
#######################################
# 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
View 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}"

View 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 \'[]\'');
}
}

View File

@@ -1,5 +1,6 @@
sonar.projectKey=crm_siteconseil
sonar.token=sqp_3e02f4de4c73f6d9cc5b6ce6546a7871d6ac0756
sonar.projectKey=crm
sonar.token=sqp_a055f41ea46e79fd66c9cfead82a795f394155bd
sonar.host.url=https://sn.e-cosplay.fr/
sonar.projectName=CRM SITECONSEIL
sonar.sources=src,assets,templates
sonar.tests=tests

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

View File

@@ -369,6 +369,7 @@ class ClientsController extends AbstractController
$echeancierList = $em->getRepository(\App\Entity\Echeancier::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']);
$paymentMethods = $em->getRepository(\App\Entity\CustomerPaymentMethod::class)->findBy(['customer' => $customer], ['isDefault' => 'DESC', 'createdAt' => 'DESC']);
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
@@ -384,6 +385,7 @@ class ClientsController extends AbstractController
'echeancierList' => $echeancierList,
'eflexList' => $eflexList,
'contratsList' => $contratsList,
'paymentMethods' => $paymentMethods,
'tab' => $tab,
'trustStatus' => $trustStatus,
]);

View File

@@ -191,16 +191,17 @@ class ContratController extends AbstractController
$contrat->setState(Contrat::STATE_SEND);
$this->em->flush();
// Envoyer email au client avec le lien
$slug = $docuSeal->getSubmitterSlug($submitterId);
$signUrl = null !== $slug ? rtrim($docuSealUrl, '/').'/s/'.$slug : null;
// Envoyer email au client avec le lien vers la page process
$processUrl = $urlGenerator->generate('app_contrat_process', [
'id' => $contrat->getId(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$mailer->sendEmail(
$contrat->getEmail(),
'Contrat a signer - '.$contrat->getTypeLabel().' - '.$contrat->getReference(),
$twig->render('emails/contrat_signature.html.twig', [
'contrat' => $contrat,
'signUrl' => $signUrl,
'processUrl' => $processUrl,
]),
null,
null,

View 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,
);
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Controller;
use App\Entity\Attestation;
use App\Entity\Customer;
use App\Entity\Devis;
use App\Entity\DocusealEvent;
use App\Entity\Echeancier;
use App\Service\UserManagementService;
use App\Repository\AttestationRepository;
use App\Repository\DevisRepository;
use App\Service\DocuSealService;
@@ -38,6 +40,7 @@ class WebhookDocuSealController extends AbstractController
#[Autowire(env: 'DOCUSEAL_WEBHOOKS_SECRET')] string $secret,
#[Autowire('%kernel.project_dir%')] string $projectDir,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
?UserManagementService $userManagement = null,
): Response {
$payload = $this->parseAndValidate($request, $secretHeader, $secret);
if ($payload instanceof Response) {
@@ -65,7 +68,7 @@ class WebhookDocuSealController extends AbstractController
// Dispatch par type de document
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) {
@@ -199,6 +202,7 @@ class WebhookDocuSealController extends AbstractController
Environment $twig,
EntityManagerInterface $em,
string $projectDir,
?UserManagementService $userManagement = null,
): JsonResponse {
if ('form.completed' !== $eventType) {
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']);
}
// Extraire les valeurs remplies par le client dans DocuSeal
$submitterValues = $this->extractDocuSealValues($data);
// Telecharger les PDFs signes
$tmpFiles = [];
@@ -248,6 +255,13 @@ class WebhookDocuSealController extends AbstractController
@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
$attachments = [];
if (null !== $contrat->getPdfSigned()) {
@@ -263,7 +277,7 @@ class WebhookDocuSealController extends AbstractController
}
}
// Mail client
// Mail client : contrat signe
try {
$mailer->sendEmail(
$contrat->getEmail(),
@@ -297,7 +311,171 @@ class WebhookDocuSealController extends AbstractController
// 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;
}
}
/**

View File

@@ -4,6 +4,8 @@ namespace App\Controller;
use App\Entity\Advert;
use App\Entity\AdvertPayment;
use App\Entity\Contrat;
use App\Entity\CustomerPaymentMethod;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
use App\Entity\Facture;
@@ -124,6 +126,13 @@ class WebhookStripeController extends AbstractController
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;
$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()]);
}
/**
* 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.
*

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

View File

@@ -50,6 +50,7 @@
'impayes': 'Impayes',
'echeancier': 'Echeancier',
'contrats': 'Contrats',
'paiement': 'Methodes de paiement',
'ndd': 'Noms de domaine',
'esyflex': 'E-Flex',
'sites': 'Sites Internet',
@@ -219,6 +220,33 @@
{% endif %}
</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 #}
{% elseif tab == 'contacts' %}
<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>
{% 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 #}
{% elseif tab == 'esyflex' %}
{% set hasActiveEflex = false %}

View 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, ',', ' ') }} &euro; 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 %}

View 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, ',', ' ') }} &euro;</td>
<td class="px-4 py-2 text-right text-xs font-mono font-bold">{{ (s.priceHt * s.quantity)|number_format(2, ',', ' ') }} &euro;</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, ',', ' ') }} &euro;</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 %}

View 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, ',', ' ') }} &euro; <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, ',', ' ') }} &euro; 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 %}

View 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 %}

View 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, ',', ' ') }} &euro;</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 %}

View 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, ',', ' ') }} &euro;</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 %}

View File

@@ -33,14 +33,14 @@
</tr>
</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;">
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>
<table cellpadding="0" cellspacing="0" style="margin: 20px auto;">
<tr>
<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>
</tr>
</table>

View 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 %}

View File

@@ -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>
</div>
<div class="flex items-end gap-2 mb-3">
<span class="text-3xl font-bold" style="color: #fabf04;">100 &euro;</span>
<span class="text-3xl font-bold" style="color: #fabf04;">50 &euro;</span>
<span class="text-sm text-gray-500 mb-1">/ mois HT</span>
</div>
<p class="text-xs text-gray-500 mb-3">Soit <strong>1 200 &euro; HT / an</strong></p>
<p class="text-xs text-gray-500 mb-3">Soit <strong>600 &euro; HT / an</strong></p>
<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-sm text-gray-400 line-through">365 &euro; HT / an</p>
@@ -254,7 +254,7 @@
<p class="text-xs text-gray-500 mb-3">Soit <strong>1 200 &euro; HT / an</strong> - Hebergement uniquement</p>
<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-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 class="glass p-3" style="border-left: 3px solid #ea580c;">
<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;">
<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">
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.
<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>
{# Avertissement important #}