added invoice archive & configurable invoice numbers (#1541)

This commit is contained in:
Kevin Papst
2020-03-14 01:16:58 +01:00
committed by GitHub
parent dd64eb98f9
commit e6e6a1eeea
106 changed files with 2587 additions and 339 deletions

View File

@@ -33,33 +33,6 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEmptyModel(new ActivityInvoiceCalculator());
}
public function testExceptionNoActivity()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot work with invoice items that do not have an activity');
$timesheet = new Timesheet();
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testExceptionNoId()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot handle un-persisted activities');
$timesheet = new Timesheet();
$timesheet->setActivity(new Activity());
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testWithMultipleEntries()
{
$customer = new Customer();
@@ -128,7 +101,35 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
->setActivity($activity3)
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$timesheet6 = new Timesheet();
$timesheet6
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$timesheet7 = new Timesheet();
$timesheet7
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setActivity(new Activity())
->setProject((new Project())->setName('bar'));
$timesheet8 = new Timesheet();
$timesheet8
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6, $timesheet7, $timesheet8];
$query = new InvoiceQuery();
$query->setActivity($activity1);
@@ -148,7 +149,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEquals('EUR', $model->getCurrency());
$this->assertEquals(2521.12, $sut->getSubtotal());
$this->assertEquals(6600, $sut->getTimeWorked());
$this->assertEquals(3, count($sut->getEntries()));
$this->assertEquals(5, count($sut->getEntries()));
$entries = $sut->getEntries();
$this->assertEquals(404.38, $entries[0]->getRate());

View File

@@ -0,0 +1,53 @@
<?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\Tests\Invoice;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\InvoiceFilename
*/
class InvoiceFilenameTest extends TestCase
{
public function testInvoiceFilename()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$model = new InvoiceModel(new DebugFormatter());
$model->setNumberGenerator(new DateNumberGenerator());
$model->setTemplate($template);
$model->setCustomer($customer);
$datePrefix = date('ymd');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix, $sut->getFilename());
self::assertEquals($datePrefix, (string) $sut);
$customer->setName('foo');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-foo', $sut->getFilename());
self::assertEquals($datePrefix . '-foo', (string) $sut);
$customer->setCompany('barß / laölala # ldksjf 123');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', $sut->getFilename());
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', (string) $sut);
}
}

View File

@@ -0,0 +1,100 @@
<?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\Tests\Invoice\NumberGenerator;
use App\Configuration\SystemConfiguration;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Repository\InvoiceRepository;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\NumberGenerator\ConfigurableNumberGenerator
*/
class ConfigurableNumberGeneratorTest extends TestCase
{
private function getSut(string $format)
{
$config = $this->createMock(SystemConfiguration::class);
$config->expects($this->any())
->method('find')
->willReturn($format);
$repository = $this->createMock(InvoiceRepository::class);
$repository
->expects($this->any())
->method('getCounterForAllTime')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForYear')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForMonth')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForDay')
->willReturn(1);
return new ConfigurableNumberGenerator($repository, $config);
}
public function getTestData()
{
$timestamp = time();
return [
// simple tests for single calls
['{date}', date('ymd'), $timestamp],
['{Y}', date('Y'), $timestamp],
['{y}', date('y'), $timestamp],
['{M}', date('m'), $timestamp],
['{m}', date('n'), $timestamp],
['{D}', date('d'), $timestamp],
['{d}', date('j'), $timestamp],
['{c}', '2', $timestamp],
['{cy}', '2', $timestamp],
['{cm}', '2', $timestamp],
['{cd}', '2', $timestamp],
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
['{date,10}', '0000' . date('ymd'), $timestamp],
['{Y,6}', '00' . date('Y'), $timestamp],
['{M,3}', '0' . date('m'), $timestamp],
['{D,3}', '0' . date('d'), $timestamp],
['{c,2}', '02', $timestamp],
['{cy,2}', '02', $timestamp],
['{cm,2}', '02', $timestamp],
['{cd,2}', '02', $timestamp],
// mixing identifiers
['{Y}{cy}', date('Y') . '2', $timestamp],
['{Y}{cy}{m}', date('Y') . '2' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y,5}/{cy,5}', '0' . date('Y') . '/00002', $timestamp],
];
}
/**
* @dataProvider getTestData
*/
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, int $timestamp)
{
$sut = $this->getSut($format);
$model = new InvoiceModel(new DebugFormatter());
$model->setInvoiceDate((new \DateTime())->setTimestamp($timestamp));
$sut->setModel($model);
$this->assertEquals($expectedInvoiceNumber, $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
}
}

View File

@@ -25,6 +25,6 @@ class DateNumberGeneratorTest extends TestCase
$sut->setModel(new InvoiceModel(new DebugFormatter()));
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
$this->assertEquals('date', $sut->getId());
}
}

View File

@@ -0,0 +1,65 @@
<?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\Tests\Invoice\Renderer;
use App\Invoice\Renderer\PdfRenderer;
use App\Utils\MPdfConverter;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Invoice\Renderer\PdfRenderer
* @group integration
*/
class PdfRendererTest extends KernelTestCase
{
use RendererTestTrait;
public function testSupports()
{
$loader = new FilesystemLoader();
$env = new Environment($loader);
$sut = new PdfRenderer($env, $this->createMock(MPdfConverter::class));
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.pdf.twig', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
$sut = new PdfRenderer($twig, new MPdfConverter($cacheDir));
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('default.pdf.twig', true);
$response = $sut->render($document, $model);
$this->assertEquals('application/pdf', $response->headers->get('Content-Type'));
}
}

View File

@@ -15,6 +15,7 @@ use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Utils\FileHelper;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
@@ -23,10 +24,16 @@ use Twig\Environment;
*/
class ServiceInvoiceTest extends TestCase
{
private function getSut(array $paths): ServiceInvoice
{
$repo = new InvoiceDocumentRepository($paths);
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
}
public function testEmptyObject()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$this->assertEmpty($sut->getCalculator());
$this->assertIsArray($sut->getCalculator());
@@ -44,8 +51,7 @@ class ServiceInvoiceTest extends TestCase
public function testWithDocumentDirectory()
{
$repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut(['templates/invoice/renderer/']);
$actual = $sut->getDocuments();
$this->assertNotEmpty($actual);
@@ -59,8 +65,7 @@ class ServiceInvoiceTest extends TestCase
public function testAdd()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator(new DateNumberGenerator());
@@ -74,7 +79,7 @@ class ServiceInvoiceTest extends TestCase
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
$this->assertEquals(1, count($sut->getNumberGenerator()));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default'));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('date'));
$this->assertEquals(1, count($sut->getRenderer()));
}

View File

@@ -0,0 +1,146 @@
{% extends 'invoice/layout.html.twig' %}
{% set language = model.template.language|default(app.request.locale) %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% block invoice %}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'label.date'|trans({}, 'messages', language) }}: {{ model.invoiceDate|date_short }}</small>
</h2>
</div>
</div>
<div class="row">
<div class="col-sm-5">
{{ 'invoice.from'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.to'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br>
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
{% endif %}
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
<th>{{ 'label.description'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.unit_price'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.amount'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.total_rate'|trans({}, 'messages', language) }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td>
<td nowrap class="text-nowrap text-right">{{ duration }}</td>
<td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans({}, 'messages', language) }}
</td>
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)
</td>
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|nl2br|md2html }}
</div>
{% endif %}
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>
{% endblock %}