feat: echeancier de paiement (entites + controller + template + email)

Entites :
- Echeancier : customer, description, totalAmountHt, state (draft/send/
  signed/active/completed/cancelled/default), stripeSubscriptionId,
  stripePriceId, submitterCompanyId/CustomerId, 3 PDF Vich (unsigned/
  signed/audit), submissionId (DocuSeal)
- EcheancierLine : position, amount, scheduledAt, state (prepared/ok/ko),
  stripeInvoiceId, paidAt, failureReason

Controller EcheancierController :
- create : cree echeancier avec N echeances mensuelles (montant reparti)
- show : detail echeancier avec progression
- send : envoie email proposition au client
- cancel : annule echeancier + subscription Stripe
- activate : cree Stripe Subscription (price + subscription + cancel_at)

Templates :
- admin/echeancier/show.html.twig : detail avec resume, progression,
  tableau echeances, actions (envoyer/activer/annuler)
- admin/clients/show.html.twig : onglet echeancier avec liste + modal creation
- emails/echeancier_proposition.html.twig : email proposition avec detail

Vich mappings : echeancier_pdf, echeancier_signed_pdf, echeancier_audit_pdf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-08 19:31:28 +02:00
parent f56099f557
commit 0f2712bb36
9 changed files with 1179 additions and 0 deletions

View File

@@ -22,3 +22,15 @@ vich_uploader:
uri_prefix: /uploads/factures_prestataires
upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
echeancier_pdf:
uri_prefix: /uploads/echeanciers
upload_destination: '%kernel.project_dir%/public/uploads/echeanciers'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
echeancier_signed_pdf:
uri_prefix: /uploads/echeanciers/signed
upload_destination: '%kernel.project_dir%/public/uploads/echeanciers/signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
echeancier_audit_pdf:
uri_prefix: /uploads/echeanciers/audit
upload_destination: '%kernel.project_dir%/public/uploads/echeanciers/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

View File

@@ -0,0 +1,40 @@
<?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 Version20260408172800 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 echeancier (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, description VARCHAR(500) NOT NULL, total_amount_ht NUMERIC(10, 2) NOT NULL, state VARCHAR(20) DEFAULT \'draft\' NOT NULL, submitter_company_id INT DEFAULT NULL, submitter_customer_id INT DEFAULT NULL, stripe_subscription_id VARCHAR(255) DEFAULT NULL, stripe_customer_id VARCHAR(255) DEFAULT NULL, stripe_price_id VARCHAR(255) DEFAULT NULL, submission_id VARCHAR(255) DEFAULT NULL, pdf_unsigned VARCHAR(255) DEFAULT NULL, pdf_signed VARCHAR(255) DEFAULT NULL, pdf_audit VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, customer_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_4694F00C9395C3F3 ON echeancier (customer_id)');
$this->addSql('CREATE TABLE echeancier_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, position SMALLINT NOT NULL, amount NUMERIC(10, 2) NOT NULL, scheduled_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, state VARCHAR(20) DEFAULT \'prepared\' NOT NULL, stripe_invoice_id VARCHAR(255) DEFAULT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, failure_reason VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, echeancier_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_939EC7E48C858AF2 ON echeancier_line (echeancier_id)');
$this->addSql('CREATE INDEX idx_echeancier_line_state ON echeancier_line (echeancier_id, state)');
$this->addSql('ALTER TABLE echeancier ADD CONSTRAINT FK_4694F00C9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE echeancier_line ADD CONSTRAINT FK_939EC7E48C858AF2 FOREIGN KEY (echeancier_id) REFERENCES echeancier (id) ON DELETE CASCADE NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE echeancier DROP CONSTRAINT FK_4694F00C9395C3F3');
$this->addSql('ALTER TABLE echeancier_line DROP CONSTRAINT FK_939EC7E48C858AF2');
$this->addSql('DROP TABLE echeancier');
$this->addSql('DROP TABLE echeancier_line');
}
}

View File

@@ -364,6 +364,7 @@ class ClientsController extends AbstractController
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
return $this->render('admin/clients/show.html.twig', [
'customer' => $customer,
@@ -374,6 +375,7 @@ class ClientsController extends AbstractController
'devisList' => $devisList,
'advertsList' => $advertsList,
'facturesList' => $facturesList,
'echeancierList' => $echeancierList,
'tab' => $tab,
]);
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Controller\Admin;
use App\Entity\Customer;
use App\Entity\Echeancier;
use App\Entity\EcheancierLine;
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\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
#[Route('/admin/echeancier', name: 'app_admin_echeancier_')]
#[IsGranted('ROLE_EMPLOYE')]
class EcheancierController extends AbstractController
{
private const MSG_NOT_FOUND = 'Echeancier introuvable';
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('/create/{customerId}', name: 'create', requirements: ['customerId' => '\d+'], methods: ['POST'])]
public function create(int $customerId, Request $request): Response
{
$customer = $this->em->getRepository(Customer::class)->find($customerId);
if (null === $customer) {
throw $this->createNotFoundException('Client introuvable');
}
$description = trim($request->request->getString('description'));
$totalHt = $request->request->getString('totalHt');
$nbEcheances = $request->request->getInt('nbEcheances');
$startDate = $request->request->getString('startDate');
if ('' === $description || $nbEcheances < 2 || $nbEcheances > 36 || '' === $startDate) {
$this->addFlash('error', 'Donnees invalides. Minimum 2 echeances, maximum 36.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'echeancier']);
}
$totalHtFloat = (float) str_replace(',', '.', $totalHt);
$monthlyAmount = round($totalHtFloat / $nbEcheances, 2);
$echeancier = new Echeancier($customer, $description, number_format($totalHtFloat, 2, '.', ''));
/** @var \App\Entity\User|null $currentUser */
$currentUser = $this->getUser();
$echeancier->setSubmitterCompanyId($currentUser?->getId());
$echeancier->setSubmitterCustomerId($customer->getId());
$start = new \DateTimeImmutable($startDate);
for ($i = 1; $i <= $nbEcheances; ++$i) {
$scheduledAt = $start->modify('+'.($i - 1).' months');
$amount = $i === $nbEcheances
? number_format($totalHtFloat - ($monthlyAmount * ($nbEcheances - 1)), 2, '.', '')
: number_format($monthlyAmount, 2, '.', '');
$line = new EcheancierLine($echeancier, $i, $amount, $scheduledAt);
$echeancier->addLine($line);
$this->em->persist($line);
}
$this->em->persist($echeancier);
$this->em->flush();
$this->addFlash('success', 'Echeancier cree avec '.$nbEcheances.' echeances de '.$monthlyAmount.' EUR/mois.');
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'echeancier']);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
return $this->render('admin/echeancier/show.html.twig', [
'echeancier' => $echeancier,
'customer' => $echeancier->getCustomer(),
]);
}
/**
* Envoie l'echeancier par email au client avec la proposition.
*/
#[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])]
public function send(int $id, MailerService $mailer, Environment $twig): Response
{
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
$customer = $echeancier->getCustomer();
if (null === $customer->getEmail()) {
$this->addFlash('error', 'Email client introuvable.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$html = $twig->render('emails/echeancier_proposition.html.twig', [
'customer' => $customer,
'echeancier' => $echeancier,
]);
$mailer->sendEmail(
$customer->getEmail(),
'Proposition d\'echeancier de paiement',
$html,
null,
null,
false,
);
$echeancier->setState(Echeancier::STATE_SEND);
$this->em->flush();
$this->addFlash('success', 'Proposition d\'echeancier envoyee a '.$customer->getEmail().'.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
/**
* Annule un echeancier (et la subscription Stripe si active).
*/
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(
int $id,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
// @codeCoverageIgnoreStart
if (null !== $echeancier->getStripeSubscriptionId() && '' !== $stripeSk) {
try {
\Stripe\Stripe::setApiKey($stripeSk);
\Stripe\Subscription::retrieve($echeancier->getStripeSubscriptionId())->cancel();
} catch (\Throwable) {
// Best effort
}
}
// @codeCoverageIgnoreEnd
$echeancier->setState(Echeancier::STATE_CANCELLED);
$this->em->flush();
$this->addFlash('success', 'Echeancier annule.');
return $this->redirectToRoute('app_admin_clients_show', [
'id' => $echeancier->getCustomer()->getId(),
'tab' => 'echeancier',
]);
}
/**
* Active la subscription Stripe apres signature du client.
*/
#[Route('/{id}/activate', name: 'activate', requirements: ['id' => '\d+'], methods: ['POST'])]
public function activate(
int $id,
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
): Response {
$echeancier = $this->em->getRepository(Echeancier::class)->find($id);
if (null === $echeancier) {
throw $this->createNotFoundException(self::MSG_NOT_FOUND);
}
if (Echeancier::STATE_SIGNED !== $echeancier->getState()) {
$this->addFlash('error', 'L\'echeancier doit etre signe avant activation.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
$customer = $echeancier->getCustomer();
if ('' === $stripeSk) {
$this->addFlash('error', 'Stripe non configure.');
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
// @codeCoverageIgnoreStart
try {
\Stripe\Stripe::setApiKey($stripeSk);
// Creer un prix Stripe pour le montant mensuel
$monthlyAmountCents = (int) round($echeancier->getMonthlyAmount() * 100);
$price = \Stripe\Price::create([
'unit_amount' => $monthlyAmountCents,
'currency' => 'eur',
'recurring' => ['interval' => 'month'],
'product_data' => [
'name' => 'Echeancier - '.$customer->getFullName(),
'metadata' => ['echeancier_id' => $echeancier->getId()],
],
]);
$echeancier->setStripePriceId($price->id);
// Utiliser le customer Stripe existant ou en creer un
$stripeCustomerId = $customer->getStripeCustomerId();
if (null === $stripeCustomerId) {
$stripeCustomer = \Stripe\Customer::create([
'email' => $customer->getEmail(),
'name' => $customer->getFullName(),
]);
$stripeCustomerId = $stripeCustomer->id;
$customer->setStripeCustomerId($stripeCustomerId);
}
$echeancier->setStripeCustomerId($stripeCustomerId);
// Creer la subscription avec nombre fixe d'echeances
$nbLines = $echeancier->getNbLines();
$firstLine = $echeancier->getLines()->first();
$billingAnchor = false !== $firstLine ? $firstLine->getScheduledAt()->getTimestamp() : time();
$subscription = \Stripe\Subscription::create([
'customer' => $stripeCustomerId,
'items' => [['price' => $price->id]],
'billing_cycle_anchor' => $billingAnchor,
'cancel_at' => (new \DateTimeImmutable())->modify('+'.$nbLines.' months')->getTimestamp(),
'metadata' => [
'echeancier_id' => (string) $echeancier->getId(),
'customer_email' => $customer->getEmail(),
'nb_echeances' => (string) $nbLines,
],
'payment_behavior' => 'default_incomplete',
'payment_settings' => [
'payment_method_types' => ['sepa_debit', 'card'],
],
]);
$echeancier->setStripeSubscriptionId($subscription->id);
$echeancier->setState(Echeancier::STATE_ACTIVE);
$this->em->flush();
$this->addFlash('success', 'Subscription Stripe activee. '.$nbLines.' echeances de '.number_format($echeancier->getMonthlyAmount(), 2, ',', ' ').' EUR/mois.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur Stripe : '.$e->getMessage());
}
// @codeCoverageIgnoreEnd
return $this->redirectToRoute('app_admin_echeancier_show', ['id' => $id]);
}
}

402
src/Entity/Echeancier.php Normal file
View File

@@ -0,0 +1,402 @@
<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity]
#[Vich\Uploadable]
class Echeancier
{
public const STATE_DRAFT = 'draft';
public const STATE_SEND = 'send';
public const STATE_SIGNED = 'signed';
public const STATE_ACTIVE = 'active';
public const STATE_COMPLETED = 'completed';
public const STATE_CANCELLED = 'cancelled';
public const STATE_DEFAULT = 'default';
#[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: 500)]
private string $description;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $totalAmountHt;
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
private string $state = self::STATE_DRAFT;
#[ORM\Column(nullable: true)]
private ?int $submitterCompanyId = null;
#[ORM\Column(nullable: true)]
private ?int $submitterCustomerId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeSubscriptionId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeCustomerId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripePriceId = null;
#[ORM\Column(nullable: true)]
private ?string $submissionId = null;
// ── PDF Unsigned ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfUnsigned = null;
#[Vich\UploadableField(mapping: 'echeancier_pdf', fileNameProperty: 'pdfUnsigned')]
private ?File $pdfUnsignedFile = null;
// ── PDF Signed ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfSigned = null;
#[Vich\UploadableField(mapping: 'echeancier_signed_pdf', fileNameProperty: 'pdfSigned')]
private ?File $pdfSignedFile = null;
// ── PDF Audit ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfAudit = null;
#[Vich\UploadableField(mapping: 'echeancier_audit_pdf', fileNameProperty: 'pdfAudit')]
private ?File $pdfAuditFile = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/** @var Collection<int, EcheancierLine> */
#[ORM\OneToMany(targetEntity: EcheancierLine::class, mappedBy: 'echeancier', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['scheduledAt' => 'ASC'])]
private Collection $lines;
public function __construct(Customer $customer, string $description, string $totalAmountHt)
{
$this->customer = $customer;
$this->description = $description;
$this->totalAmountHt = $totalAmountHt;
$this->createdAt = new \DateTimeImmutable();
$this->lines = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getTotalAmountHt(): string
{
return $this->totalAmountHt;
}
public function setTotalAmountHt(string $totalAmountHt): static
{
$this->totalAmountHt = $totalAmountHt;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getSubmitterCompanyId(): ?int
{
return $this->submitterCompanyId;
}
public function setSubmitterCompanyId(?int $submitterCompanyId): static
{
$this->submitterCompanyId = $submitterCompanyId;
return $this;
}
public function getSubmitterCustomerId(): ?int
{
return $this->submitterCustomerId;
}
public function setSubmitterCustomerId(?int $submitterCustomerId): static
{
$this->submitterCustomerId = $submitterCustomerId;
return $this;
}
public function getStripeSubscriptionId(): ?string
{
return $this->stripeSubscriptionId;
}
public function setStripeSubscriptionId(?string $stripeSubscriptionId): static
{
$this->stripeSubscriptionId = $stripeSubscriptionId;
return $this;
}
public function getStripeCustomerId(): ?string
{
return $this->stripeCustomerId;
}
public function setStripeCustomerId(?string $stripeCustomerId): static
{
$this->stripeCustomerId = $stripeCustomerId;
return $this;
}
public function getStripePriceId(): ?string
{
return $this->stripePriceId;
}
public function setStripePriceId(?string $stripePriceId): static
{
$this->stripePriceId = $stripePriceId;
return $this;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;
}
public function setSubmissionId(?string $submissionId): static
{
$this->submissionId = $submissionId;
return $this;
}
// ── PDF Unsigned ──
public function getPdfUnsigned(): ?string
{
return $this->pdfUnsigned;
}
public function setPdfUnsigned(?string $pdfUnsigned): static
{
$this->pdfUnsigned = $pdfUnsigned;
return $this;
}
public function getPdfUnsignedFile(): ?File
{
return $this->pdfUnsignedFile;
}
public function setPdfUnsignedFile(?File $file): void
{
$this->pdfUnsignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
}
// ── PDF Signed ──
public function getPdfSigned(): ?string
{
return $this->pdfSigned;
}
public function setPdfSigned(?string $pdfSigned): static
{
$this->pdfSigned = $pdfSigned;
return $this;
}
public function getPdfSignedFile(): ?File
{
return $this->pdfSignedFile;
}
public function setPdfSignedFile(?File $file): void
{
$this->pdfSignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
}
// ── PDF Audit ──
public function getPdfAudit(): ?string
{
return $this->pdfAudit;
}
public function setPdfAudit(?string $pdfAudit): static
{
$this->pdfAudit = $pdfAudit;
return $this;
}
public function getPdfAuditFile(): ?File
{
return $this->pdfAuditFile;
}
public function setPdfAuditFile(?File $file): void
{
$this->pdfAuditFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
}
// ── Dates ──
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
// ── Lines ──
/** @return Collection<int, EcheancierLine> */
public function getLines(): Collection
{
return $this->lines;
}
public function addLine(EcheancierLine $line): static
{
if (!$this->lines->contains($line)) {
$this->lines->add($line);
}
return $this;
}
public function removeLine(EcheancierLine $line): static
{
$this->lines->removeElement($line);
return $this;
}
// ── Computed ──
public function getNbLines(): int
{
return $this->lines->count();
}
public function getNbPaid(): int
{
$count = 0;
foreach ($this->lines as $line) {
if (EcheancierLine::STATE_OK === $line->getState()) {
++$count;
}
}
return $count;
}
public function getNbFailed(): int
{
$count = 0;
foreach ($this->lines as $line) {
if (EcheancierLine::STATE_KO === $line->getState()) {
++$count;
}
}
return $count;
}
public function getTotalPaid(): float
{
$total = 0.0;
foreach ($this->lines as $line) {
if (EcheancierLine::STATE_OK === $line->getState()) {
$total += (float) $line->getAmount();
}
}
return $total;
}
public function getProgress(): float
{
$nb = $this->getNbLines();
return $nb > 0 ? round($this->getNbPaid() / $nb * 100, 1) : 0.0;
}
/**
* Montant mensuel (total / nb echeances).
*/
public function getMonthlyAmount(): float
{
$nb = $this->getNbLines();
return $nb > 0 ? round((float) $this->totalAmountHt / $nb, 2) : 0.0;
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Index(columns: ['echeancier_id', 'state'], name: 'idx_echeancier_line_state')]
class EcheancierLine
{
public const STATE_PREPARED = 'prepared';
public const STATE_OK = 'ok';
public const STATE_KO = 'ko';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Echeancier::class, inversedBy: 'lines')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Echeancier $echeancier;
#[ORM\Column(type: 'smallint')]
private int $position;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $amount;
#[ORM\Column]
private \DateTimeImmutable $scheduledAt;
#[ORM\Column(length: 20, options: ['default' => 'prepared'])]
private string $state = self::STATE_PREPARED;
#[ORM\Column(length: 255, nullable: true)]
private ?string $stripeInvoiceId = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $paidAt = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $failureReason = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(Echeancier $echeancier, int $position, string $amount, \DateTimeImmutable $scheduledAt)
{
$this->echeancier = $echeancier;
$this->position = $position;
$this->amount = $amount;
$this->scheduledAt = $scheduledAt;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEcheancier(): Echeancier
{
return $this->echeancier;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
public function getAmount(): string
{
return $this->amount;
}
public function setAmount(string $amount): static
{
$this->amount = $amount;
return $this;
}
public function getScheduledAt(): \DateTimeImmutable
{
return $this->scheduledAt;
}
public function setScheduledAt(\DateTimeImmutable $scheduledAt): static
{
$this->scheduledAt = $scheduledAt;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getStripeInvoiceId(): ?string
{
return $this->stripeInvoiceId;
}
public function setStripeInvoiceId(?string $stripeInvoiceId): static
{
$this->stripeInvoiceId = $stripeInvoiceId;
return $this;
}
public function getPaidAt(): ?\DateTimeImmutable
{
return $this->paidAt;
}
public function setPaidAt(?\DateTimeImmutable $paidAt): static
{
$this->paidAt = $paidAt;
return $this;
}
public function getFailureReason(): ?string
{
return $this->failureReason;
}
public function setFailureReason(?string $failureReason): static
{
$this->failureReason = $failureReason;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* Label de l'echeance (ex: "Echeance 1/6 - Janvier 2026").
*/
public function getLabel(): string
{
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
$month = $months[(int) $this->scheduledAt->format('n')] ?? '';
$total = $this->echeancier->getNbLines();
return 'Echeance '.$this->position.'/'.$total.' - '.$month.' '.$this->scheduledAt->format('Y');
}
public function isPaid(): bool
{
return self::STATE_OK === $this->state;
}
public function isFailed(): bool
{
return self::STATE_KO === $this->state;
}
public function isPending(): bool
{
return self::STATE_PREPARED === $this->state;
}
}

View File

@@ -821,6 +821,94 @@
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun devis.</div>
{% endif %}
{# Tab: Echeancier #}
{% elseif tab == 'echeancier' %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold uppercase">Echeanciers</h2>
<button type="button" data-modal-open="modal-echeancier" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer un echeancier</button>
</div>
{# Liste des echeanciers existants #}
{% if echeancierList|length > 0 %}
<div class="glass overflow-x-auto overflow-hidden">
<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">Description</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Total HT</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Echeances</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Progression</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for e in echeancierList %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-bold text-xs">{{ e.description|length > 50 ? e.description[:50] ~ '...' : e.description }}</td>
<td class="px-4 py-3 text-right font-bold text-xs">{{ e.totalAmountHt|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-3 text-center text-xs">{{ e.nbPaid }}/{{ e.nbLines }}</td>
<td class="px-4 py-3 text-center">
<div class="w-full bg-gray-200 h-2">
<div class="bg-green-500 h-2" style="width: {{ e.progress }}%"></div>
</div>
</td>
<td class="px-4 py-3 text-center">
{% if e.state == 'active' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Actif</span>
{% elseif e.state == 'completed' %}
<span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-[10px]">Termine</span>
{% elseif e.state == 'cancelled' %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Annule</span>
{% elseif e.state == 'draft' %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Brouillon</span>
{% else %}
<span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">{{ e.state }}</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
<a href="{{ path('app_admin_echeancier_show', {id: e.id}) }}" class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun echeancier pour ce client.</div>
{% endif %}
{# Modal creation echeancier #}
<div id="modal-echeancier" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="glass-heavy p-6 w-full max-w-lg">
<h2 class="text-lg font-bold uppercase mb-4">Nouvel echeancier</h2>
<form method="post" action="{{ path('app_admin_echeancier_create', {customerId: customer.id}) }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="md:col-span-2">
<label for="ech-description" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Description / Motif *</label>
<textarea id="ech-description" name="description" required rows="2" class="input-glass w-full px-3 py-2 text-xs font-bold" placeholder="Ex: Solde impaye facture 04/2026-00001"></textarea>
</div>
<div>
<label for="ech-totalHt" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Montant total HT *</label>
<input type="number" id="ech-totalHt" name="totalHt" step="0.01" min="1" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div>
<label for="ech-nbEcheances" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Nombre d'echeances *</label>
<input type="number" id="ech-nbEcheances" name="nbEcheances" min="2" max="36" value="3" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
<div>
<label for="ech-startDate" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Date 1ere echeance *</label>
<input type="date" id="ech-startDate" name="startDate" required class="input-glass w-full px-3 py-2 text-xs font-bold">
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-echeancier" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button>
</div>
</form>
</div>
</div>
{# Tab: Securite #}
{% elseif tab == 'securite' %}
{% set user = customer.user %}

View File

@@ -0,0 +1,131 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Echeancier - {{ customer.fullName }} - Administration{% endblock %}
{% block admin_content %}
<div class="page-container">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold heading-page">Echeancier</h1>
<p class="text-xs text-gray-400 mt-1">{{ customer.fullName }} - {{ echeancier.description }}</p>
</div>
<div class="flex items-center gap-3">
{% if echeancier.state == 'draft' %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs">Brouillon</span>
{% elseif echeancier.state == 'send' %}
<span class="px-3 py-1 bg-blue-500/20 text-blue-700 font-bold uppercase text-xs">Envoye</span>
{% elseif echeancier.state == 'signed' %}
<span class="px-3 py-1 bg-purple-500/20 text-purple-700 font-bold uppercase text-xs">Signe</span>
{% elseif echeancier.state == 'active' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Actif</span>
{% elseif echeancier.state == 'completed' %}
<span class="px-3 py-1 bg-emerald-500/20 text-emerald-700 font-bold uppercase text-xs">Termine</span>
{% elseif echeancier.state == 'cancelled' %}
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs">Annule</span>
{% elseif echeancier.state == 'default' %}
<span class="px-3 py-1 bg-red-600 text-white font-bold uppercase text-xs">Defaut</span>
{% endif %}
<a href="{{ path('app_admin_clients_show', {id: customer.id, tab: 'echeancier'}) }}" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
</div>
</div>
{% for flash in app.flashes('success') %}
<div class="mb-4 p-3 bg-green-500/20 text-green-700 font-bold text-xs">{{ flash }}</div>
{% endfor %}
{% for flash in app.flashes('error') %}
<div class="mb-4 p-3 bg-red-500/20 text-red-700 font-bold text-xs">{{ flash }}</div>
{% endfor %}
{# Resume #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="glass p-4">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Montant total</p>
<p class="text-2xl font-bold mt-1">{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-4">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Mensualite</p>
<p class="text-2xl font-bold mt-1 text-[#fabf04]">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;</p>
</div>
<div class="glass p-4">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Progression</p>
<p class="text-2xl font-bold mt-1">{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}</p>
<div class="w-full bg-gray-200 h-2 mt-2">
<div class="bg-green-500 h-2" style="width: {{ echeancier.progress }}%"></div>
</div>
</div>
<div class="glass p-4">
<p class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Deja paye</p>
<p class="text-2xl font-bold mt-1 text-green-600">{{ echeancier.totalPaid|number_format(2, ',', ' ') }} &euro;</p>
</div>
</div>
{# Description #}
<div class="glass p-5 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-2">Motif</h2>
<p class="text-sm text-gray-600">{{ echeancier.description }}</p>
</div>
{# Actions #}
<div class="flex gap-2 mb-6">
{% if echeancier.state == 'draft' %}
<form method="post" action="{{ path('app_admin_echeancier_send', {id: echeancier.id}) }}" data-confirm="Envoyer la proposition d'echeancier au client ?">
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Envoyer au client</button>
</form>
{% endif %}
{% if echeancier.state == 'signed' %}
<form method="post" action="{{ path('app_admin_echeancier_activate', {id: echeancier.id}) }}" data-confirm="Activer la subscription Stripe ? Le client sera preleve automatiquement chaque mois.">
<button type="submit" class="px-4 py-2 bg-green-600 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-green-700 transition-all">Activer Stripe</button>
</form>
{% endif %}
{% if echeancier.state in ['draft', 'send', 'signed', 'active'] %}
<form method="post" action="{{ path('app_admin_echeancier_cancel', {id: echeancier.id}) }}" data-confirm="Annuler cet echeancier ? La subscription Stripe sera annulee.">
<button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Annuler</button>
</form>
{% endif %}
</div>
{# Echeances #}
<h2 class="text-lg font-bold uppercase mb-4">Echeances</h2>
<div class="glass overflow-x-auto overflow-hidden">
<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">N</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date prevue</th>
<th class="px-4 py-3 text-right font-bold uppercase text-xs tracking-widest">Montant</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Paye le</th>
</tr>
</thead>
<tbody>
{% for line in echeancier.lines %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-bold">{{ line.position }}</td>
<td class="px-4 py-3 text-xs">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td class="px-4 py-3 text-right font-bold text-xs">{{ line.amount|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-3 text-center">
{% if line.isPaid %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Paye</span>
{% elseif line.isFailed %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Echoue</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">En attente</span>
{% endif %}
</td>
<td class="px-4 py-3 text-xs text-gray-500">
{{ line.paidAt ? line.paidAt|date('d/m/Y H:i') : '—' }}
{% if line.failureReason %}
<span class="text-red-500 ml-1">{{ line.failureReason }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if echeancier.stripeSubscriptionId %}
<p class="mt-3 text-[10px] text-gray-400 font-mono">Stripe Subscription: {{ echeancier.stripeSubscriptionId }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% 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 proposons un echeancier de paiement pour faciliter le reglement de votre solde.
</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%;">Motif</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.description }}</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 total HT</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700;">{{ echeancier.totalAmountHt }} &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;">Nombre d'echeances</td>
<td style="padding: 10px 16px; font-size: 13px; font-weight: 700;">{{ echeancier.nbLines }} mois</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 mensuel</td>
<td style="padding: 10px 16px; font-family: monospace; font-size: 16px; font-weight: 700; color: #fabf04;">{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} &euro;/mois</td>
</tr>
</table>
<h2 style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 700; color: #111827; margin: 20px 0 10px;">Detail des echeances :</h2>
<table width="100%" cellpadding="0" cellspacing="0" style="border: 1px solid #e5e7eb;">
<tr>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; background: #111827; text-align: left;">N</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; background: #111827; text-align: left;">Date prevue</th>
<th style="padding: 8px 12px; font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #ffffff; background: #111827; text-align: right;">Montant</th>
</tr>
{% for line in echeancier.lines %}
<tr style="background: {{ loop.index is odd ? '#f9fafb' : '#ffffff' }};">
<td style="padding: 8px 12px; font-size: 12px;">{{ line.position }}</td>
<td style="padding: 8px 12px; font-size: 12px;">{{ line.scheduledAt|date('d/m/Y') }}</td>
<td style="padding: 8px 12px; font-size: 12px; font-weight: 700; text-align: right;">{{ line.amount }} &euro;</td>
</tr>
{% endfor %}
</table>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 14px; color: #374151; line-height: 22px; margin: 16px 0;">
En acceptant cet echeancier, vous autorisez le prelevement automatique mensuel du montant indique via Stripe.
Chaque echeance sera prelevee a la date prevue.
</p>
<p style="font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #9ca3af; margin: 16px 0 0;">
Pour toute question : <a href="mailto:contact@e-cosplay.fr" style="color: #fabf04;">contact@e-cosplay.fr</a>
</p>
</td>
</tr>
</table>
{% endblock %}