From d84beb957c5477fbf38c6e290b4234db5335076f Mon Sep 17 00:00:00 2001 From: Philipp Date: Mon, 29 Mar 2021 00:51:34 +0200 Subject: [PATCH] add subtotal and payment date to invoices (#2450) --- src/Controller/InvoiceController.php | 30 ++++++++++- src/Entity/Invoice.php | 33 +++++++++++- .../Actions/InvoiceSubscriber.php | 4 +- src/Form/InvoicePaymentDateForm.php | 50 +++++++++++++++++++ src/Migrations/Version20210320162820.php | 40 +++++++++++++++ templates/invoice/listing.html.twig | 15 ++++++ templates/invoice/payment_date_edit.html.twig | 13 +++++ tests/Controller/InvoiceControllerTest.php | 23 +++++++++ tests/Entity/InvoiceTest.php | 11 ++++ translations/messages.de.xlf | 4 ++ translations/messages.en.xlf | 4 ++ 11 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/Form/InvoicePaymentDateForm.php create mode 100644 src/Migrations/Version20210320162820.php create mode 100644 templates/invoice/payment_date_edit.html.twig diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 8d939b6a..1b28a36b 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -17,6 +17,7 @@ use App\Export\Spreadsheet\AnnotatedObjectExporter; use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter; use App\Export\Spreadsheet\Writer\XlsxWriter; use App\Form\InvoiceDocumentUploadForm; +use App\Form\InvoicePaymentDateForm; use App\Form\InvoiceTemplateForm; use App\Form\Toolbar\InvoiceArchiveForm; use App\Form\Toolbar\InvoiceToolbarForm; @@ -228,10 +229,22 @@ final class InvoiceController extends AbstractController } /** - * @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"}) + * @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET", "POST"}) */ - public function changeStatusAction(Invoice $invoice, string $status): Response + public function changeStatusAction(Invoice $invoice, string $status, Request $request): Response { + if ($status === Invoice::STATUS_PAID) { + $form = $this->createPaymentDateForm($invoice, $request); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + return $this->render('invoice/payment_date_edit.html.twig', [ + 'invoice' => $invoice, + 'form' => $form->createView() + ]); + } + } + try { $this->service->changeInvoiceStatus($invoice, $status); $this->flashSuccess('action.update.success'); @@ -512,4 +525,17 @@ final class InvoiceController extends AbstractController 'method' => 'POST' ]); } + + private function createPaymentDateForm(Invoice $invoice, Request $request): FormInterface + { + if (null === $invoice->getPaymentDate()) { + $invoice->setPaymentDate($this->getDateTimeFactory()->createDateTime()); + } + + return $this->createForm(InvoicePaymentDateForm::class, $invoice, [ + 'action' => $request->getUri(), + 'method' => 'POST', + 'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(), + ]); + } } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 21115ee4..8e9fdd37 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -26,10 +26,11 @@ use Symfony\Component\Validator\Constraints as Assert; * @UniqueEntity("invoiceNumber") * @UniqueEntity("invoiceFilename") * - * @Exporter\Order({"id", "createdAt", "invoiceNumber", "status", "customer", "total", "tax", "currency", "vat", "dueDays", "dueDate", "user", "invoiceFilename"}) + * @Exporter\Order({"id", "createdAt", "invoiceNumber", "status", "customer", "subtotal", "total", "tax", "currency", "vat", "dueDays", "dueDate", "paymentDate", "user", "invoiceFilename"}) * @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()") * @Exporter\Expose("dueDate", label="invoice.due_days", type="datetime", exp="object.getDueDate() === null ? null : object.getDueDate()") * @Exporter\Expose("user", label="label.username", type="string", exp="object.getUser() === null ? null : object.getUser().getDisplayName()") + * @Exporter\Expose("paymentDate", label="invoice.payment_date", type="date", exp="object.getPaymentDate() === null ? null : object.getPaymentDate()") */ class Invoice { @@ -172,6 +173,13 @@ class Invoice */ private $localized = false; + /** + * @var \DateTime + * + * @ORM\Column(name="payment_date", type="date", nullable=true) + */ + private $paymentDate; + public function getId(): ?int { return $this->id; @@ -265,6 +273,7 @@ class Invoice public function setIsNew(): Invoice { + $this->setPaymentDate(null); $this->status = self::STATUS_NEW; return $this; @@ -277,6 +286,7 @@ class Invoice public function setIsPending(): Invoice { + $this->setPaymentDate(null); $this->status = self::STATUS_PENDING; return $this; @@ -318,4 +328,25 @@ class Invoice { return $this->invoiceFilename; } + + /** + * @Exporter\Expose(label="invoice.subtotal", type="float", name="subtotal") + * @return float|null + */ + public function getSubtotal(): ?float + { + return $this->total - $this->tax; + } + + public function getPaymentDate(): ?\DateTime + { + return $this->paymentDate; + } + + public function setPaymentDate(?\DateTime $paymentDate): Invoice + { + $this->paymentDate = $paymentDate; + + return $this; + } } diff --git a/src/EventSubscriber/Actions/InvoiceSubscriber.php b/src/EventSubscriber/Actions/InvoiceSubscriber.php index b48d9909..8374b3b9 100644 --- a/src/EventSubscriber/Actions/InvoiceSubscriber.php +++ b/src/EventSubscriber/Actions/InvoiceSubscriber.php @@ -30,10 +30,10 @@ class InvoiceSubscriber extends AbstractActionsSubscriber return; } - if ($invoice->isNew()) { + if ($invoice->isNew() || $invoice->isPaid()) { $event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]); } elseif ($invoice->isPending()) { - $event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid'])]); + $event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid']), 'class' => 'modal-ajax-form']); } $event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']); diff --git a/src/Form/InvoicePaymentDateForm.php b/src/Form/InvoicePaymentDateForm.php new file mode 100644 index 00000000..baac96d4 --- /dev/null +++ b/src/Form/InvoicePaymentDateForm.php @@ -0,0 +1,50 @@ + $options['timezone'], + 'view_timezone' => $options['timezone'], + ]; + + $builder + ->add('paymentDate', DatePickerType::class, array_merge($dateTimeOptions, [ + 'label' => 'invoice.payment_date', + 'required' => true, + ])); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Invoice::class, + 'timezone' => date_default_timezone_get(), + 'attr' => [ + 'data-form-event' => 'kimai.invoiceUpdate' + ], + ]); + } +} diff --git a/src/Migrations/Version20210320162820.php b/src/Migrations/Version20210320162820.php new file mode 100644 index 00000000..67cf124c --- /dev/null +++ b/src/Migrations/Version20210320162820.php @@ -0,0 +1,40 @@ +getTable('kimai2_invoices'); + $invoices->addColumn('payment_date', 'date', ['default' => null, 'notnull' => false]); + } + + public function down(Schema $schema): void + { + $invoices = $schema->getTable('kimai2_invoices'); + $invoices->dropColumn('payment_date'); + } +} diff --git a/templates/invoice/listing.html.twig b/templates/invoice/listing.html.twig index 13ba6a76..4504b8ba 100644 --- a/templates/invoice/listing.html.twig +++ b/templates/invoice/listing.html.twig @@ -11,7 +11,9 @@ 'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false}, 'invoice_number': {'class': 'hidden-xs hidden-sm text-center w-min', 'title': 'invoice.number'|trans, 'orderBy': false}, 'due_date': {'class': 'hidden-xs text-center w-min', 'title': 'invoice.due_days'|trans, 'orderBy': false}, + 'payment_date': {'class': 'hidden-xs hidden text-center w-min', 'title': 'invoice.payment_date'|trans, 'orderBy': false}, 'status': {'class': 'text-center alwaysVisible w-min', 'orderBy': false}, + 'subtotal': {'class': 'hidden-xs text-center w-min hidden', 'title': 'invoice.subtotal'|trans, 'orderBy': false}, 'tax': {'class': 'hidden-xs text-center w-min hidden', 'title': 'invoice.tax'|trans, 'orderBy': false}, 'total_rate': {'class': 'hidden-xs text-right w-min', 'orderBy': false}, 'actions': {'class': 'actions alwaysVisible', 'orderBy': false}, @@ -40,7 +42,15 @@ {{ widgets.label_customer(entry.customer) }} {{ widgets.label(entry.invoiceNumber, 'default') }} {{ macros.invoice_due_date(entry) }} + + {% if entry.paymentDate and entry.paid %} + {{ widgets.label(entry.paymentDate|date_short, 'primary') }} + {% else %} + – + {% endif %} + {{ macros.invoice_status(entry) }} + {{ entry.subtotal|money(entry.currency) }} {{ entry.tax|money(entry.currency) }} {{ entry.total|money(entry.currency) }} {{ actions.invoice(entry, 'index') }} @@ -60,4 +70,9 @@ }); {% endif %} + {% endblock %} diff --git a/templates/invoice/payment_date_edit.html.twig b/templates/invoice/payment_date_edit.html.twig new file mode 100644 index 00000000..1408dac3 --- /dev/null +++ b/templates/invoice/payment_date_edit.html.twig @@ -0,0 +1,13 @@ +{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} + +{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %} + +{% block main %} + {% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %} + {% set formOptions = { + 'title': invoice.customer.name ~ ' – ' ~ invoice.createdAt|date_short~ ' – ' ~ invoice.total|money(invoice.customer.currency), + 'form': form, + 'back': path('admin_invoice_list') + } %} + {% embed formEditTemplate with formOptions %}{% endembed %} +{% endblock %} diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index 4d661736..c9575a07 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -320,6 +320,29 @@ class InvoiceControllerTest extends ControllerBaseTest $this->assertTrue($client->getResponse()->isSuccessful()); $this->request($client, '/invoice/change-status/' . $id . '/paid'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $this->assertHasValidationError( + $client, + '/invoice/change-status/' . $id . '/paid', + 'form[name=invoice_payment_date_form]', + [ + 'invoice_payment_date_form' => [ + 'paymentDate' => 'invalid' + ] + ], + ['#invoice_payment_date_form_paymentDate'] + ); + + $this->assertTrue($client->getResponse()->isSuccessful()); + + $form = $client->getCrawler()->filter('form[name=invoice_payment_date_form]')->form(); + $client->submit($form, [ + 'invoice_payment_date_form' => [ + 'paymentDate' => (new \DateTime())->format('Y-m-d') + ] + ]); + $this->assertIsRedirect($client, '/invoice/show'); $client->followRedirect(); $this->assertTrue($client->getResponse()->isSuccessful()); diff --git a/tests/Entity/InvoiceTest.php b/tests/Entity/InvoiceTest.php index 20cc87f0..c6654ffb 100644 --- a/tests/Entity/InvoiceTest.php +++ b/tests/Entity/InvoiceTest.php @@ -52,6 +52,7 @@ class InvoiceTest extends TestCase self::assertFalse($sut->isPending()); self::assertFalse($sut->isPaid()); self::assertFalse($sut->isOverdue()); + self::assertNull($sut->getPaymentDate()); } public function testSetterAndGetter() @@ -75,6 +76,15 @@ class InvoiceTest extends TestCase self::assertFalse($sut->isPaid()); self::assertFalse($sut->isOverdue()); + $paymentDate = new \DateTime(); + $sut->setPaymentDate($paymentDate); + self::assertEquals($paymentDate, $sut->getPaymentDate()); + $sut->setIsPending(); + self::assertNull($sut->getPaymentDate()); + $sut->setPaymentDate($paymentDate); + $sut->setIsNew(); + self::assertNull($sut->getPaymentDate()); + $sut->setModel($this->getInvoiceModel($date)); self::assertTrue($sut->isOverdue()); @@ -86,6 +96,7 @@ class InvoiceTest extends TestCase self::assertNull($sut->getId()); self::assertNull($sut->getInvoiceFilename()); self::assertEquals(date('ymd', $date->getTimestamp()), $sut->getInvoiceNumber()); + self::assertEquals(293.27, $sut->getSubtotal()); self::assertEquals(55.72, $sut->getTax()); self::assertEquals(348.99, $sut->getTotal()); self::assertNotNull($sut->getUser()); diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index a377582a..e39a3d93 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -953,6 +953,10 @@ invoice.due_days Zahlungsziel + + invoice.payment_date + Zahlungsdatum + invoice.from Von diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 19676734..b1382a6e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -970,6 +970,10 @@ invoice.due_days Payment target + + invoice.payment_date + Payment date + invoice.from From