add subtotal and payment date to invoices (#2450)
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
50
src/Form/InvoicePaymentDateForm.php
Normal file
50
src/Form/InvoicePaymentDateForm.php
Normal 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'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
src/Migrations/Version20210320162820.php
Normal file
40
src/Migrations/Version20210320162820.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
–
|
||||
{% 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 %}
|
||||
|
||||
13
templates/invoice/payment_date_edit.html.twig
Normal file
13
templates/invoice/payment_date_edit.html.twig
Normal 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 %}
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user