Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/routing/admin_accounting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ admin_accounting_invoices_send_email:
path: /invoices/send
defaults: {_controller: AppBundle\Controller\Admin\Accounting\Invoice\SendInvoiceEmailAction}

admin_accounting_invoices_mark_sent:
path: /invoices/mark-sent
methods: [POST]
defaults: {_controller: AppBundle\Controller\Admin\Accounting\Invoice\MarkFactureAsSentAction}

admin_accounting_search:
path: /search
defaults: {_controller: AppBundle\Controller\Admin\Accounting\SearchAction}
Expand Down
18 changes: 18 additions & 0 deletions db/migrations/20260914090000_facture_envoi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

class FactureEnvoi extends AbstractMigration
{
public function change(): void
{
$sql = <<<EOF
ALTER TABLE `afup_compta_facture`
ADD `date_envoi` datetime DEFAULT NULL AFTER `date_paiement`,
ADD `envoye_par` varchar(50) DEFAULT NULL AFTER `date_envoi`;
EOF;
$this->execute($sql);
}
}
2 changes: 2 additions & 0 deletions sources/AppBundle/Accounting/InvoicingMailer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use AppBundle\Email\Mailer\Attachment;
use AppBundle\Email\Mailer\Mailer;
use AppBundle\Email\Mailer\MailUser;
use AppBundle\Email\Mailer\MailUserFactory;
use AppBundle\Email\Mailer\Message;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

Expand Down Expand Up @@ -43,6 +44,7 @@ public function sendInvoice(Invoicing $invoicing): bool
new MailUser(MailUser::DEFAULT_SENDER_EMAIL, MailUser::DEFAULT_SENDER_NAME),
new MailUser($invoicing->getEmail(), $invoicing->getLastname()),
);
$message->addCc(MailUserFactory::sponsors());
$message->addAttachment(new Attachment(
$cheminFacture,
'facture-' . $invoiceNumber . '.pdf',
Expand Down
28 changes: 28 additions & 0 deletions sources/AppBundle/Accounting/Model/Invoicing.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class Invoicing implements NotifyPropertyInterface
private string $phone = '';
private InvoicingPaymentStatus $paymentStatus = InvoicingPaymentStatus::Waiting;
private ?DateTime $paymentDate = null;
private ?DateTime $dateEnvoi = null;
private ?string $envoyePar = null;
private ?InvoicingCurrency $currency = null;
/** @var InvoicingDetail[] */
#[Assert\Valid]
Expand Down Expand Up @@ -355,6 +357,32 @@ public function setPrice(?float $price): self
return $this;
}

public function getDateEnvoi(): ?DateTime
{
return $this->dateEnvoi;
}

public function setDateEnvoi(?DateTime $dateEnvoi): self
{
$this->propertyChanged('dateEnvoi', $this->dateEnvoi, $dateEnvoi);
$this->dateEnvoi = $dateEnvoi;

return $this;
}

public function getEnvoyePar(): ?string
{
return $this->envoyePar;
}

public function setEnvoyePar(?string $envoyePar): self
{
$this->propertyChanged('envoyePar', $this->envoyePar, $envoyePar);
$this->envoyePar = $envoyePar;

return $this;
}

public function getPaymentUrlRef(): string
{
if (empty($this->getInvoiceNumber())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function getQuotationsByPeriodId(?int $periodId = null, string $sort = 'd
->where('acf.date_devis <= (select date_fin from compta_periode where id = :periodId)')
->bindValues(['periodId' => $periodId]);
}
$builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'devise_facture'])
$builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'date_envoi', 'envoye_par', 'devise_facture'])
->orderBy(["$filter $direction"]);

$hydrator = new HydratorSingleObject();
Expand Down Expand Up @@ -82,7 +82,7 @@ public function getInvoicesByPeriodId(?int $periodId = null, string $sort = 'dat
->where('acf.date_facture <= (select date_fin from compta_periode where id = :periodId)')
->bindValues(['periodId' => $periodId]);
}
$builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'devise_facture'])
$builder->groupBy(['acf.id', 'date_devis', 'numero_devis', 'date_facture', 'numero_facture', 'societe', 'adresse', 'code_postal', 'ville', 'id_pays', 'email', 'observation', 'ref_clt1', 'ref_clt2', 'ref_clt3', 'nom', 'prenom', 'tel', 'etat_paiement', 'date_paiement', 'date_envoi', 'envoye_par', 'devise_facture'])
->orderBy(["$filter $direction"]);

$hydrator = new HydratorSingleObject();
Expand Down Expand Up @@ -315,6 +315,21 @@ public static function initMetadata(SerializerFactoryInterface $serializerFactor
'unserialize' => ['enum' => InvoicingCurrency::class],
],
])
->addField([
'columnName' => 'date_envoi',
'fieldName' => 'dateEnvoi',
'type' => 'datetime',
'serializer' => DateTime::class,
'serializer_options' => [
'serialize' => ['format' => 'Y-m-d H:i:s'],
'unserialize' => ['format' => 'Y-m-d H:i:s', 'unSerializeUseFormat' => true],
],
])
->addField([
'columnName' => 'envoye_par',
'fieldName' => 'envoyePar',
'type' => 'string',
])
;

return $metadata;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace AppBundle\Controller\Admin\Accounting\Invoice;

use AppBundle\Accounting\Model\Repository\InvoicingRepository;
use AppBundle\Association\Model\User;
use AppBundle\AuditLog\Audit;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

class MarkFactureAsSentAction extends AbstractController
{
public function __construct(
private readonly InvoicingRepository $invoicingRepository,
private readonly Audit $audit,
private readonly CsrfTokenManagerInterface $csrfTokenManager,
private readonly Security $security,
) {}

public function __invoke(Request $request): Response
{
$token = new CsrfToken('admin_accounting_invoices_mark_sent', (string) $request->request->get('_token'));
if (!$this->csrfTokenManager->isTokenValid($token)) {
throw $this->createAccessDeniedException('Token CSRF invalide');
}

$invoiceRef = $request->request->get('ref');
$invoice = $this->invoicingRepository->getOneByInvoiceNumber((string) $invoiceRef);
if ($invoice === null) {
throw new NotFoundHttpException("Cette facture n'existe pas");
}

$invoice->setDateEnvoi(new \DateTime());
$user = $this->security->getUser();
if ($user instanceof User) {
$invoice->setEnvoyePar($user->getUsername());
}
$this->invoicingRepository->save($invoice);
$this->audit->log('Facture n°' . $invoiceRef . ' marquée comme envoyée manuellement');
$this->addFlash('notice', 'La facture a été marquée comme envoyée');

return $this->redirectToRoute('admin_accounting_invoices_list');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@

use AppBundle\Accounting\InvoicingMailer;
use AppBundle\Accounting\Model\Repository\InvoicingRepository;
use AppBundle\Association\Model\User;
use AppBundle\AuditLog\Audit;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class SendInvoiceEmailAction extends AbstractController
Expand All @@ -18,6 +20,7 @@ public function __construct(
private readonly InvoicingMailer $invoicingMailer,
private readonly InvoicingRepository $invoicingRepository,
private readonly Audit $audit,
private readonly Security $security,
) {}

public function __invoke(Request $request): Response
Expand All @@ -29,6 +32,12 @@ public function __invoke(Request $request): Response
}

if ($this->invoicingMailer->sendInvoice($invoice)) {
$invoice->setDateEnvoi(new \DateTime());
$user = $this->security->getUser();
if ($user instanceof User) {
$invoice->setEnvoyePar($user->getUsername());
}
$this->invoicingRepository->save($invoice);
$this->audit->log('Envoi par email de la facture n°' . $invoiceRef);
$this->addFlash('notice', 'La facture a été envoyée');
} else {
Expand Down
24 changes: 24 additions & 0 deletions templates/admin/accounting/invoice/list.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
<th>Ville</th>
<th class="right aligned">Numéro facture</th>
<th>Référence client</th>
<th data-tf-filter-type="select" class="center aligned">État envoi</th>
<th data-tf-filter-type="select" class="center aligned">État paiement</th>
<th class="right aligned">
Montant{% if isSubjectedToVat %} HT{% endif %}
Expand All @@ -45,6 +46,18 @@
<td>{{ line.city }}</td>
<td class="right aligned">{{ line.invoiceNumber }}</td>
<td>{{ line.refClt1 }}</td>
<td class="center aligned">
{% if line.dateEnvoi %}
<span class="ui green label"
data-position="top center"
data-tooltip="Envoyé le {{ line.dateEnvoi|format_datetime('short', 'short', locale='fr') }} par {{ line.envoyePar }}"
>
Envoyé
</span>
{% else %}
<span class="ui label">Non envoyé</span>
{% endif %}
</td>
<td class="center aligned">
{% if line.paymentStatus == enum('AppBundle\\Accounting\\InvoicingPaymentStatus').Cancelled %}
<span class="ui label">Annulé</span>
Expand Down Expand Up @@ -77,6 +90,17 @@
>
<i class="paper plane icon"></i>
</a>
<form method="post" action="{{ path('admin_accounting_invoices_mark_sent') }}" style="display: inline">
<input type="hidden" name="_token" value="{{ csrf_token('admin_accounting_invoices_mark_sent') }}">
<input type="hidden" name="ref" value="{{ line.invoiceNumber }}">
<button type="submit"
data-position="left center"
data-tooltip="Marquer la facture {{ line.invoiceNumber }} comme envoyée manuellement"
class="compact ui icon button"
>
<i class="check icon"></i>
</button>
</form>
<a href="{{ url('payment_invoice', {ref: line.paymentUrlRef}) }}"
data-position="left center"
data-tooltip="Récupérer le lien de paiement en ligne"
Expand Down
31 changes: 25 additions & 6 deletions tests/behat/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ public function clickLinkOfTooltip(string $tooltip): void
$link->click();
}

#[When('I press the button of tooltip :arg1')]
public function pressButtonOfTooltip(string $tooltip): void
{
$button = $this->minkContext->getSession()->getPage()->find('css', sprintf('button[data-tooltip="%s"]', $tooltip));

if (null === $button) {
throw new ExpectationException(
sprintf('Button of tooltip "%s" not found', $tooltip),
$this->minkContext->getSession()->getDriver(),
);
}

$button->press();
}

#[Then('/^the rows of table "(?P<selector>[^"]+)" should be in the following order:$/')]
public function assertTableRowsInOrder(string $selector, PyStringNode $expectedRows): void
{
Expand Down Expand Up @@ -209,14 +224,18 @@ public function assertDownloadedFile(string $mode, string $filename): void
#[Then('/^(?:|I )should see a (?P<color>(?:[\w])* )?label "(?P<value>(?:[^"]|\\")*)"$/')]
public function shouldSeeLabel(string $color, string $text): void
{
$label = $this->minkContext->getSession()->getPage()->find('css', sprintf('.ui.label%s', $color != '' ? ('.' . $color) : ''));
$labels = $this->minkContext->getSession()->getPage()->findAll('css', sprintf('.ui.label%s', $color != '' ? ('.' . $color) : ''));

if (null === $label) {
throw new ExpectationException(
sprintf('label "%s" was not found', $text),
$this->minkContext->getSession()->getDriver(),
);
foreach ($labels as $label) {
if (trim((string) $label->getText()) === $text) {
return;
}
}

throw new ExpectationException(
sprintf('label "%s" was not found', $text),
$this->minkContext->getSession()->getDriver(),
);
}

/**
Expand Down
15 changes: 13 additions & 2 deletions tests/behat/features/Admin/Tresorerie/DevisFactures.feature
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,10 @@ Feature: Administration - Trésorerie - Devis/Facture
# Envoi de la facture par email
Then I follow the button of tooltip "Envoyer la facture 2026-3 par mail"
And I should only receive the following emails:
| from | to | subject |
| <bonjour@afup.org> | <martine@ens-en-folie.biz> | Facture AFUP |
| from | to | subject |
| <bonjour@afup.org> | <martine@ens-en-folie.biz>,<sponsors@afup.org> | Facture AFUP |
Then the ".content .message" element should contain "La facture a été envoyée"
And I should see a green label "Envoyé"
# Lien de paiement
Then I follow the button of tooltip "Récupérer le lien de paiement en ligne"
Then I should see "Paiement en ligne de la facture"
Expand Down Expand Up @@ -235,6 +236,16 @@ Feature: Administration - Trésorerie - Devis/Facture
Then The page "1" of the PDF should contain "TVA non applicable - art. 293B du CGI"
Then the checksum of the response content should be "c424b17649ae6bfc83bf6791dc1543ab"

@reloadDbWithTestData
Scenario: Marquer une facture comme envoyée manuellement
Given I am logged in as admin and on the Administration
When I go to "/admin/accounting/invoices/list?periodId=16"
Then I should see a label "Non envoyé"
When I press the button of tooltip "Marquer la facture 2025-02 comme envoyée manuellement"
Then I should see "La facture a été marquée comme envoyée"
And I go to "/admin/accounting/invoices/list?periodId=16"
And I should see a green label "Envoyé"


@reloadDbWithTestData
@vat
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/AppBundle/Accounting/InvoicingMailerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace AppBundle\Tests\Accounting;

use AppBundle\Accounting\InvoicingMailer;
use AppBundle\Accounting\InvoicingPdfGenerator;
use AppBundle\Accounting\Model\Invoicing;
use AppBundle\Email\Mailer\Mailer;
use AppBundle\Email\Mailer\Message;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class InvoicingMailerTest extends TestCase
{
public function testSendInvoiceAddsSponsorsCc(): void
{
$invoicing = new Invoicing();
$invoicing->setInvoiceNumber('2026-001');
$invoicing->setCompany('Société');
$invoicing->setEmail('contact@exemple.fr');
$invoicing->setLastname('Dupont');

/** @var Mailer&MockObject $mailer */
$mailer = $this->createMock(Mailer::class);
$pdfGenerator = $this->createStub(InvoicingPdfGenerator::class);

$mailer->expects(self::once())->method('send')
->with(self::callback(static function (Message $message): bool {
self::assertSame(1, count($message->getRecipients() ?? []));
$cc = $message->getCc();
self::assertCount(1, $cc);
self::assertSame('sponsors@afup.org', array_key_first($cc));

return true;
}));

$invoicingMailer = new InvoicingMailer($pdfGenerator, $mailer, sys_get_temp_dir() . '/');
$invoicingMailer->sendInvoice($invoicing);
}
}
Loading