Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
414 lines
9.8 KiB
PHP
414 lines
9.8 KiB
PHP
<?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 EFlex
|
|
{
|
|
public const STATE_DRAFT = 'draft';
|
|
public const STATE_ACTIVE = 'active';
|
|
public const STATE_COMPLETED = 'completed';
|
|
public const STATE_CANCELLED = 'cancelled';
|
|
public const STATE_PENDING_SETUP = 'pending_setup';
|
|
|
|
public const METHOD_SEPA = 'sepa';
|
|
public const METHOD_CB = 'cb';
|
|
public const METHOD_VIREMENT = 'virement';
|
|
|
|
#[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 $totalAmount;
|
|
|
|
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
|
|
private string $state = self::STATE_DRAFT;
|
|
|
|
#[ORM\Column(length: 20, options: ['default' => 'sepa'])]
|
|
private string $paymentMethod = self::METHOD_SEPA;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $stripeCustomerId = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $stripePaymentMethodId = null;
|
|
|
|
#[ORM\Column(length: 4, nullable: true)]
|
|
private ?string $stripeSepaLast4 = null;
|
|
|
|
#[ORM\Column(length: 100, nullable: true)]
|
|
private ?string $stripeSepaBankName = null;
|
|
|
|
#[ORM\Column(length: 2, nullable: true)]
|
|
private ?string $stripeSepaCountry = null;
|
|
|
|
#[ORM\Column(nullable: true)]
|
|
private ?string $submissionId = null;
|
|
|
|
// ── PDF Unsigned ──
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $pdfUnsigned = null;
|
|
|
|
#[Vich\UploadableField(mapping: 'eflex_pdf', fileNameProperty: 'pdfUnsigned')]
|
|
private ?File $pdfUnsignedFile = null;
|
|
|
|
// ── PDF Signed ──
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $pdfSigned = null;
|
|
|
|
#[Vich\UploadableField(mapping: 'eflex_signed_pdf', fileNameProperty: 'pdfSigned')]
|
|
private ?File $pdfSignedFile = null;
|
|
|
|
// ── PDF Audit ──
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
private ?string $pdfAudit = null;
|
|
|
|
#[Vich\UploadableField(mapping: 'eflex_audit_pdf', fileNameProperty: 'pdfAudit')]
|
|
private ?File $pdfAuditFile = null;
|
|
|
|
/** @var Collection<int, EFlexLine> */
|
|
#[ORM\OneToMany(targetEntity: EFlexLine::class, mappedBy: 'eflex', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
|
#[ORM\OrderBy(['position' => 'ASC'])]
|
|
private Collection $lines;
|
|
|
|
#[ORM\Column]
|
|
private \DateTimeImmutable $createdAt;
|
|
|
|
#[ORM\Column(nullable: true)]
|
|
private ?\DateTimeImmutable $updatedAt = null;
|
|
|
|
public function __construct(Customer $customer, string $description, string $totalAmount)
|
|
{
|
|
$this->customer = $customer;
|
|
$this->description = $description;
|
|
$this->totalAmount = $totalAmount;
|
|
$this->lines = new ArrayCollection();
|
|
$this->createdAt = new \DateTimeImmutable();
|
|
}
|
|
|
|
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 getTotalAmount(): string
|
|
{
|
|
return $this->totalAmount;
|
|
}
|
|
|
|
public function setTotalAmount(string $totalAmount): static
|
|
{
|
|
$this->totalAmount = $totalAmount;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getState(): string
|
|
{
|
|
return $this->state;
|
|
}
|
|
|
|
public function setState(string $state): static
|
|
{
|
|
$this->state = $state;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getPaymentMethod(): string
|
|
{
|
|
return $this->paymentMethod;
|
|
}
|
|
|
|
public function setPaymentMethod(string $paymentMethod): static
|
|
{
|
|
$this->paymentMethod = $paymentMethod;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getStripeCustomerId(): ?string
|
|
{
|
|
return $this->stripeCustomerId;
|
|
}
|
|
|
|
public function setStripeCustomerId(?string $stripeCustomerId): static
|
|
{
|
|
$this->stripeCustomerId = $stripeCustomerId;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getStripePaymentMethodId(): ?string
|
|
{
|
|
return $this->stripePaymentMethodId;
|
|
}
|
|
|
|
public function setStripePaymentMethodId(?string $stripePaymentMethodId): static
|
|
{
|
|
$this->stripePaymentMethodId = $stripePaymentMethodId;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getStripeSepaLast4(): ?string
|
|
{
|
|
return $this->stripeSepaLast4;
|
|
}
|
|
|
|
public function setStripeSepaLast4(?string $stripeSepaLast4): static
|
|
{
|
|
$this->stripeSepaLast4 = $stripeSepaLast4;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getStripeSepaBankName(): ?string
|
|
{
|
|
return $this->stripeSepaBankName;
|
|
}
|
|
|
|
public function setStripeSepaBankName(?string $stripeSepaBankName): static
|
|
{
|
|
$this->stripeSepaBankName = $stripeSepaBankName;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getStripeSepaCountry(): ?string
|
|
{
|
|
return $this->stripeSepaCountry;
|
|
}
|
|
|
|
public function setStripeSepaCountry(?string $stripeSepaCountry): static
|
|
{
|
|
$this->stripeSepaCountry = $stripeSepaCountry;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/** @return Collection<int, EFlexLine> */
|
|
public function getLines(): Collection
|
|
{
|
|
return $this->lines;
|
|
}
|
|
|
|
public function addLine(EFlexLine $line): static
|
|
{
|
|
if (!$this->lines->contains($line)) {
|
|
$this->lines->add($line);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
public function getSubmissionId(): ?string
|
|
{
|
|
return $this->submissionId;
|
|
}
|
|
|
|
public function setSubmissionId(?string $submissionId): static
|
|
{
|
|
$this->submissionId = $submissionId;
|
|
|
|
return $this;
|
|
}
|
|
|
|
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 $pdfUnsignedFile): static
|
|
{
|
|
$this->pdfUnsignedFile = $pdfUnsignedFile;
|
|
if (null !== $pdfUnsignedFile) {
|
|
$this->updatedAt = new \DateTimeImmutable();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getPdfSigned(): ?string
|
|
{
|
|
return $this->pdfSigned;
|
|
}
|
|
|
|
public function setPdfSigned(?string $pdfSigned): static
|
|
{
|
|
$this->pdfSigned = $pdfSigned;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setPdfSignedFile(?File $pdfSignedFile): static
|
|
{
|
|
$this->pdfSignedFile = $pdfSignedFile;
|
|
if (null !== $pdfSignedFile) {
|
|
$this->updatedAt = new \DateTimeImmutable();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getPdfAudit(): ?string
|
|
{
|
|
return $this->pdfAudit;
|
|
}
|
|
|
|
public function setPdfAudit(?string $pdfAudit): static
|
|
{
|
|
$this->pdfAudit = $pdfAudit;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setPdfAuditFile(?File $pdfAuditFile): static
|
|
{
|
|
$this->pdfAuditFile = $pdfAuditFile;
|
|
if (null !== $pdfAuditFile) {
|
|
$this->updatedAt = new \DateTimeImmutable();
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Reference unique (E_FLEX_00001).
|
|
*/
|
|
public function getReference(): string
|
|
{
|
|
return 'E_FLEX_'.str_pad((string) ($this->id ?? 0), 5, '0', \STR_PAD_LEFT);
|
|
}
|
|
|
|
public function getNbLines(): int
|
|
{
|
|
return $this->lines->count();
|
|
}
|
|
|
|
/**
|
|
* Montant mensuel (total / nb echeances).
|
|
*/
|
|
public function getMonthlyAmount(): float
|
|
{
|
|
$nb = $this->getNbLines();
|
|
|
|
return $nb > 0 ? round((float) $this->totalAmount / $nb, 2) : 0.0;
|
|
}
|
|
|
|
public function getNbPaid(): int
|
|
{
|
|
$count = 0;
|
|
foreach ($this->lines as $line) {
|
|
if (EFlexLine::STATE_OK === $line->getState()) {
|
|
++$count;
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
public function getNbFailed(): int
|
|
{
|
|
$count = 0;
|
|
foreach ($this->lines as $line) {
|
|
if (EFlexLine::STATE_KO === $line->getState()) {
|
|
++$count;
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
public function getTotalPaid(): float
|
|
{
|
|
$total = 0.0;
|
|
foreach ($this->lines as $line) {
|
|
if (EFlexLine::STATE_OK === $line->getState()) {
|
|
$total += (float) $line->getAmount();
|
|
}
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
public function getProgress(): int
|
|
{
|
|
$nb = $this->getNbLines();
|
|
|
|
return $nb > 0 ? (int) round($this->getNbPaid() / $nb * 100) : 0;
|
|
}
|
|
|
|
public function getPaymentMethodLabel(): string
|
|
{
|
|
return match ($this->paymentMethod) {
|
|
self::METHOD_SEPA => 'Prelevement SEPA',
|
|
self::METHOD_CB => 'Carte bancaire',
|
|
self::METHOD_VIREMENT => 'Virement bancaire',
|
|
default => $this->paymentMethod,
|
|
};
|
|
}
|
|
}
|