diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index c604689..b757462 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -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 diff --git a/migrations/Version20260408172800.php b/migrations/Version20260408172800.php new file mode 100644 index 0000000..d4f245b --- /dev/null +++ b/migrations/Version20260408172800.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/src/Controller/Admin/ClientsController.php b/src/Controller/Admin/ClientsController.php index 2ca1bfb..b9e0ba6 100644 --- a/src/Controller/Admin/ClientsController.php +++ b/src/Controller/Admin/ClientsController.php @@ -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, ]); } diff --git a/src/Controller/Admin/EcheancierController.php b/src/Controller/Admin/EcheancierController.php new file mode 100644 index 0000000..8856f87 --- /dev/null +++ b/src/Controller/Admin/EcheancierController.php @@ -0,0 +1,261 @@ + '\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]); + } +} diff --git a/src/Entity/Echeancier.php b/src/Entity/Echeancier.php new file mode 100644 index 0000000..ba6d1e5 --- /dev/null +++ b/src/Entity/Echeancier.php @@ -0,0 +1,402 @@ + '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 */ + #[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 */ + 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; + } +} diff --git a/src/Entity/EcheancierLine.php b/src/Entity/EcheancierLine.php new file mode 100644 index 0000000..1b20501 --- /dev/null +++ b/src/Entity/EcheancierLine.php @@ -0,0 +1,182 @@ + '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; + } +} diff --git a/templates/admin/clients/show.html.twig b/templates/admin/clients/show.html.twig index 80ce569..1d30483 100644 --- a/templates/admin/clients/show.html.twig +++ b/templates/admin/clients/show.html.twig @@ -821,6 +821,94 @@
Aucun devis.
{% endif %} + {# Tab: Echeancier #} + {% elseif tab == 'echeancier' %} +
+

Echeanciers

+ +
+ + {# Liste des echeanciers existants #} + {% if echeancierList|length > 0 %} +
+ + + + + + + + + + + + + {% for e in echeancierList %} + + + + + + + + + {% endfor %} + +
DescriptionTotal HTEcheancesProgressionStatutActions
{{ e.description|length > 50 ? e.description[:50] ~ '...' : e.description }}{{ e.totalAmountHt|number_format(2, ',', ' ') }} €{{ e.nbPaid }}/{{ e.nbLines }} +
+
+
+
+ {% if e.state == 'active' %} + Actif + {% elseif e.state == 'completed' %} + Termine + {% elseif e.state == 'cancelled' %} + Annule + {% elseif e.state == 'draft' %} + Brouillon + {% else %} + {{ e.state }} + {% endif %} + + Voir +
+
+ {% else %} +
Aucun echeancier pour ce client.
+ {% endif %} + + {# Modal creation echeancier #} + + {# Tab: Securite #} {% elseif tab == 'securite' %} {% set user = customer.user %} diff --git a/templates/admin/echeancier/show.html.twig b/templates/admin/echeancier/show.html.twig new file mode 100644 index 0000000..3187dca --- /dev/null +++ b/templates/admin/echeancier/show.html.twig @@ -0,0 +1,131 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Echeancier - {{ customer.fullName }} - Administration{% endblock %} + +{% block admin_content %} +
+
+
+

Echeancier

+

{{ customer.fullName }} - {{ echeancier.description }}

+
+
+ {% if echeancier.state == 'draft' %} + Brouillon + {% elseif echeancier.state == 'send' %} + Envoye + {% elseif echeancier.state == 'signed' %} + Signe + {% elseif echeancier.state == 'active' %} + Actif + {% elseif echeancier.state == 'completed' %} + Termine + {% elseif echeancier.state == 'cancelled' %} + Annule + {% elseif echeancier.state == 'default' %} + Defaut + {% endif %} + Retour +
+
+ + {% for flash in app.flashes('success') %} +
{{ flash }}
+ {% endfor %} + {% for flash in app.flashes('error') %} +
{{ flash }}
+ {% endfor %} + + {# Resume #} +
+
+

Montant total

+

{{ echeancier.totalAmountHt|number_format(2, ',', ' ') }} €

+
+
+

Mensualite

+

{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €

+
+
+

Progression

+

{{ echeancier.nbPaid }}/{{ echeancier.nbLines }}

+
+
+
+
+
+

Deja paye

+

{{ echeancier.totalPaid|number_format(2, ',', ' ') }} €

+
+
+ + {# Description #} +
+

Motif

+

{{ echeancier.description }}

+
+ + {# Actions #} +
+ {% if echeancier.state == 'draft' %} +
+ +
+ {% endif %} + {% if echeancier.state == 'signed' %} +
+ +
+ {% endif %} + {% if echeancier.state in ['draft', 'send', 'signed', 'active'] %} +
+ +
+ {% endif %} +
+ + {# Echeances #} +

Echeances

+
+ + + + + + + + + + + + {% for line in echeancier.lines %} + + + + + + + + {% endfor %} + +
NDate prevueMontantStatutPaye le
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount|number_format(2, ',', ' ') }} € + {% if line.isPaid %} + Paye + {% elseif line.isFailed %} + Echoue + {% else %} + En attente + {% endif %} + + {{ line.paidAt ? line.paidAt|date('d/m/Y H:i') : '—' }} + {% if line.failureReason %} + {{ line.failureReason }} + {% endif %} +
+
+ + {% if echeancier.stripeSubscriptionId %} +

Stripe Subscription: {{ echeancier.stripeSubscriptionId }}

+ {% endif %} +
+{% endblock %} diff --git a/templates/emails/echeancier_proposition.html.twig b/templates/emails/echeancier_proposition.html.twig new file mode 100644 index 0000000..24868c1 --- /dev/null +++ b/templates/emails/echeancier_proposition.html.twig @@ -0,0 +1,61 @@ +{% extends 'email/base.html.twig' %} + +{% block content %} + + + + +
+ {% set greeting = customer.raisonSociale ? 'Chez ' ~ customer.raisonSociale : 'Bonjour ' ~ customer.firstName %} +

{{ greeting }},

+ +

+ Nous vous proposons un echeancier de paiement pour faciliter le reglement de votre solde. +

+ + + + + + + + + + + + + + + + + + +
Motif{{ echeancier.description }}
Montant total HT{{ echeancier.totalAmountHt }} €
Nombre d'echeances{{ echeancier.nbLines }} mois
Montant mensuel{{ echeancier.monthlyAmount|number_format(2, ',', ' ') }} €/mois
+ +

Detail des echeances :

+ + + + + + + + {% for line in echeancier.lines %} + + + + + + {% endfor %} +
NDate prevueMontant
{{ line.position }}{{ line.scheduledAt|date('d/m/Y') }}{{ line.amount }} €
+ +

+ En acceptant cet echeancier, vous autorisez le prelevement automatique mensuel du montant indique via Stripe. + Chaque echeance sera prelevee a la date prevue. +

+ +

+ Pour toute question : contact@e-cosplay.fr +

+
+{% endblock %}