add subtotal and payment date to invoices (#2450)

This commit is contained in:
Philipp
2021-03-29 00:51:34 +02:00
committed by GitHub
parent 7029bde555
commit d84beb957c
11 changed files with 222 additions and 5 deletions

View File

@@ -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(),
]);
}
}

View File

@@ -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;
}
}

View File

@@ -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']);

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Invoice;
use App\Form\Type\DatePickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class InvoicePaymentDateForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$dateTimeOptions = [
'model_timezone' => $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'
],
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Update invoice with payment date
*
* @version 1.14
*/
final class Version20210320162820 extends AbstractMigration
{
public function getDescription(): string
{
return 'Update invoice with payment date';
}
public function up(Schema $schema): void
{
$invoices = $schema->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');
}
}

View File

@@ -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 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'invoice_number') }}">{{ widgets.label(entry.invoiceNumber, 'default') }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_date') }}">{{ macros.invoice_due_date(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'payment_date') }}">
{% if entry.paymentDate and entry.paid %}
{{ widgets.label(entry.paymentDate|date_short, 'primary') }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'status') }}">{{ macros.invoice_status(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'subtotal') }}">{{ entry.subtotal|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tax') }}">{{ entry.tax|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">{{ entry.total|money(entry.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.invoice(entry, 'index') }}</td>
@@ -60,4 +70,9 @@
});
</script>
{% endif %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.invoiceUpdate');
});
</script>
{% endblock %}

View File

@@ -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 %}

View File

@@ -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());

View File

@@ -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());

View File

@@ -953,6 +953,10 @@
<source>invoice.due_days</source>
<target>Zahlungsziel</target>
</trans-unit>
<trans-unit id="invoice.payment_date">
<source>invoice.payment_date</source>
<target>Zahlungsdatum</target>
</trans-unit>
<trans-unit id="invoice.from">
<source>invoice.from</source>
<target>Von</target>

View File

@@ -970,6 +970,10 @@
<source>invoice.due_days</source>
<target>Payment target</target>
</trans-unit>
<trans-unit id="invoice.payment_date">
<source>invoice.payment_date</source>
<target>Payment date</target>
</trans-unit>
<trans-unit id="invoice.from">
<source>invoice.from</source>
<target>From</target>