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:
@@ -22,3 +22,15 @@ vich_uploader:
|
|||||||
uri_prefix: /uploads/factures_prestataires
|
uri_prefix: /uploads/factures_prestataires
|
||||||
upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires'
|
upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires'
|
||||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
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
|
||||||
|
|||||||
40
migrations/Version20260408172800.php
Normal file
40
migrations/Version20260408172800.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -364,6 +364,7 @@ class ClientsController extends AbstractController
|
|||||||
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||||
$advertsList = $em->getRepository(\App\Entity\Advert::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']);
|
$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', [
|
return $this->render('admin/clients/show.html.twig', [
|
||||||
'customer' => $customer,
|
'customer' => $customer,
|
||||||
@@ -374,6 +375,7 @@ class ClientsController extends AbstractController
|
|||||||
'devisList' => $devisList,
|
'devisList' => $devisList,
|
||||||
'advertsList' => $advertsList,
|
'advertsList' => $advertsList,
|
||||||
'facturesList' => $facturesList,
|
'facturesList' => $facturesList,
|
||||||
|
'echeancierList' => $echeancierList,
|
||||||
'tab' => $tab,
|
'tab' => $tab,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
261
src/Controller/Admin/EcheancierController.php
Normal file
261
src/Controller/Admin/EcheancierController.php
Normal 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
402
src/Entity/Echeancier.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/Entity/EcheancierLine.php
Normal file
182
src/Entity/EcheancierLine.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -821,6 +821,94 @@
|
|||||||
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun devis.</div>
|
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun devis.</div>
|
||||||
{% endif %}
|
{% 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, ',', ' ') }} €</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 #}
|
{# Tab: Securite #}
|
||||||
{% elseif tab == 'securite' %}
|
{% elseif tab == 'securite' %}
|
||||||
{% set user = customer.user %}
|
{% set user = customer.user %}
|
||||||
|
|||||||
131
templates/admin/echeancier/show.html.twig
Normal file
131
templates/admin/echeancier/show.html.twig
Normal 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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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, ',', ' ') }} €</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 %}
|
||||||
61
templates/emails/echeancier_proposition.html.twig
Normal file
61
templates/emails/echeancier_proposition.html.twig
Normal 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 }} €</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, ',', ' ') }} €/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 }} €</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 %}
|
||||||
Reference in New Issue
Block a user