delete invoices (#1652)

* include all meta fields as template variables
* support invoice preview via command
* support deletion of generated invoices
This commit is contained in:
Kevin Papst
2020-04-23 12:19:07 +02:00
committed by GitHub
parent cfe0724620
commit 711139ad4c
20 changed files with 206 additions and 36 deletions

View File

@@ -17,6 +17,7 @@ Perform EACH version specific task between your version and the new one, otherwi
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
- Invoice templates that use the templates variables `${activity.X}` or `${project.X}` should be checked and possibly adapted, as multi-select is now possible for filtering
- Invoice templates have access to all meta-fields as variables, not only the ones marked as visible
Permission changes:
- `history_invoice` - NEW: grants all features for the new invoice archive (by default for all admins)

View File

@@ -27,6 +27,9 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class InvoiceCreateCommand extends Command
@@ -55,6 +58,10 @@ class InvoiceCreateCommand extends Command
* @var EventDispatcherInterface
*/
private $eventDispatcher;
/**
* @var string|null
*/
private $previewDirectory;
public function __construct(
ServiceInvoice $serviceInvoice,
@@ -94,6 +101,7 @@ class InvoiceCreateCommand extends Command
->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null)
->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null)
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null)
->addOption('preview', null, InputOption::VALUE_OPTIONAL, 'Absolute path for a rendered preview of the invoice, which will neither be saved nor the items be marked as exported.', null)
;
}
@@ -208,7 +216,14 @@ class InvoiceCreateCommand extends Command
}
$markAsExported = false;
if ($input->getOption('set-exported')) {
if ($input->getOption('preview') !== null) {
$this->previewDirectory = rtrim($input->getOption('preview'), '/') . '/';
if (!is_dir($this->previewDirectory) || !is_writable($this->previewDirectory)) {
$io->error('Invalid preview directory given');
return 1;
}
} elseif ($input->getOption('set-exported')) {
$markAsExported = true;
}
@@ -283,7 +298,11 @@ class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
}
@@ -292,6 +311,34 @@ class InvoiceCreateCommand extends Command
return $invoices;
}
private function saveInvoicePreview(Response $response)
{
$filename = uniqid('invoice_');
if ($response->headers->has('Content-Disposition')) {
$disposition = $response->headers->get('Content-Disposition');
$parts = explode(';', $disposition);
foreach ($parts as $part) {
if (stripos($part, 'filename=') === false) {
continue;
}
$filename = explode('filename=', $part);
if (\count($filename) > 1) {
$filename = $filename[1];
}
}
}
if ($response instanceof BinaryFileResponse) {
$file = $response->getFile();
$file->move($this->previewDirectory, $filename);
} else {
(new Filesystem())->dumpFile($this->previewDirectory . $filename, $response->getContent());
}
return $this->previewDirectory . $filename;
}
/**
* @param Customer[] $customers
* @param InvoiceQuery $defaultQuery
@@ -318,7 +365,11 @@ class InvoiceCreateCommand extends Command
$query->setTemplate($tpl);
try {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
if (null !== $this->previewDirectory) {
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
} else {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
}
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
}
@@ -343,6 +394,22 @@ class InvoiceCreateCommand extends Command
return 0;
}
if (null !== $this->previewDirectory) {
$columns = ['Filename'];
$table = new Table($output);
$table->setHeaderTitle(sprintf('Created %s invoice(s)', \count($invoices)));
$table->setHeaders($columns);
foreach ($invoices as $invoiceFile) {
$table->addRow([$invoiceFile]);
}
$table->render();
return 0;
}
$columns = ['ID', 'Customer', 'Total', 'Filename'];
$table = new Table($output);

View File

@@ -189,11 +189,26 @@ final class InvoiceController extends AbstractController
{
try {
$this->service->changeInvoiceStatus($invoice, $status);
} catch (\InvalidArgumentException $ex) {
throw $this->createNotFoundException($ex->getMessage());
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('action.update.error');
}
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_list');
}
/**
* @Route(path="/delete/{id}", name="admin_invoice_delete", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function deleteInvoiceAction(Invoice $invoice): Response
{
try {
$this->service->deleteInvoice($invoice);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_invoice_list');
}

View File

@@ -145,7 +145,7 @@ class InvoiceTemplate
private $decimalDuration = false;
/**
* Used when rendering HTML templates.
* Used for translations and locale dependent number and date formats.
*
* @var string
*

View File

@@ -95,7 +95,7 @@ class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.activity_id' => $activity->getId(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
foreach ($activity->getMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
@@ -108,7 +108,7 @@ class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.project_id' => $project->getId(),
]);
foreach ($project->getVisibleMetaFields() as $metaField) {
foreach ($project->getMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
@@ -121,7 +121,7 @@ class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.customer_id' => $customer->getId(),
]);
foreach ($customer->getVisibleMetaFields() as $metaField) {
foreach ($customer->getMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);

View File

@@ -46,7 +46,7 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
// remaining time-budget?
];
foreach ($customer->getVisibleMetaFields() as $metaField) {
foreach ($customer->getMetaFields() as $metaField) {
$values = array_merge($values, [
'customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);

View File

@@ -27,6 +27,7 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
@@ -52,11 +53,19 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'),
'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()),
'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
'query.month_number' => $model->getQuery()->getBegin()->format('m'),
'query.year' => $model->getQuery()->getBegin()->format('Y'),
'query.day' => $model->getQuery()->getBegin()->format('d'), // @deprecated
'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()), // @deprecated
'query.month_number' => $model->getQuery()->getBegin()->format('m'), // @deprecated
'query.year' => $model->getQuery()->getBegin()->format('Y'), // @deprecated
'query.begin_day' => $model->getQuery()->getBegin()->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
'query.begin_month_number' => $model->getQuery()->getBegin()->format('m'),
'query.begin_year' => $model->getQuery()->getBegin()->format('Y'),
'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()), // since 1.9
'query.end_day' => $model->getQuery()->getEnd()->format('d'), // since 1.9
'query.end_month' => $formatter->getFormattedMonthName($model->getQuery()->getEnd()), // since 1.9
'query.end_month_number' => $model->getQuery()->getEnd()->format('m'), // since 1.9
'query.end_year' => $model->getQuery()->getEnd()->format('Y'), // since 1.9
];
return $values;

View File

@@ -396,6 +396,15 @@ final class ServiceInvoice
);
}
public function deleteInvoice(Invoice $invoice)
{
$invoiceDirectory = $this->getInvoicesDirectory();
if (is_file($invoiceDirectory . $invoice->getInvoiceFilename())) {
$this->fileHelper->removeFile($invoiceDirectory . $invoice->getInvoiceFilename());
}
$this->invoiceRepository->deleteInvoice($invoice);
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel

View File

@@ -28,6 +28,13 @@ class InvoiceRepository extends EntityRepository
$entityManager->flush();
}
public function deleteInvoice(Invoice $invoice)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($invoice);
$entityManager->flush();
}
private function getCounterFor(\DateTime $start, \DateTime $end): int
{
$qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -58,4 +58,9 @@ final class FileHelper
{
$this->filesystem->dumpFile($filename, $data);
}
public function removeFile(string $filename)
{
$this->filesystem->remove($filename);
}
}

View File

@@ -57,11 +57,8 @@
{% set actions = actions|merge({'invoice.paid': path('admin_invoice_status', {'id': invoice.id, 'status': 'paid'})}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% set actions = actions|merge({'download': {'url': path('admin_invoice_download', {'id': invoice.id}), 'target': '_blank'}}) %}
{% set actions = actions|merge({'trash': {'url': path('admin_invoice_delete', {'id' : invoice.id}), 'class': 'confirmation-link', 'attr': {'data-question': 'confirm.delete'}}}) %}
{% endif %}
{% set event = trigger('actions.invoice', {'actions': actions, 'invoice': invoice}) %}
@@ -107,7 +104,7 @@
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'edit': {'url': path('admin_invoice_template_edit', {'id' : template.id}), 'class': 'modal-ajax-form'}}) %}
{% set actions = actions|merge({'copy': path('admin_invoice_template_copy', {'id' : template.id})}) %}
{% set actions = actions|merge({'trash': path('admin_invoice_template_delete', {'id' : template.id})}) %}
{% set actions = actions|merge({'trash': {'url': path('admin_invoice_template_delete', {'id' : template.id}), 'class': 'confirmation-link', 'attr': {'data-question': 'confirm.delete'}}}) %}
{% endif %}
{% set event = trigger('actions.invoice_template', {'actions': actions, 'view': view, 'template': template}) %}

View File

@@ -1,19 +1,16 @@
{% for key, value in model.toArray %}
{{ key }}:
{{ key }}
{% if value is not empty %}
{{ value|multiline_indent(' ') }}
{{ value|multiline_indent(' ') }}
{% endif %}
{% endfor %}
{% for entry in model.calculator.entries %}
---
{% set items = model.itemToArray(entry) %}
{% for key, value in items %}
{{ key }}:
{{ key }}
{% if value is not empty %}
{{ value|multiline_indent(' ') }}
{{ value|multiline_indent(' ') }}
{% endif %}
{% endfor %}
{% endfor %}

View File

@@ -157,6 +157,11 @@ class InvoiceCreateCommandTest extends KernelTestCase
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'x', '--start' => '2020-01-01', '--end' => 'öäüß'], 'Invalid end date given');
}
public function testCreateWithInvalidPreviewDirectory()
{
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'x', '--start' => '2020-01-01', '--end' => '2020-01-02', '--preview' => '/kjhg/'], 'Invalid preview directory given');
}
public function testCreateWithInvalidCustomer()
{
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 3, '--template' => 'x'], 'Unknown customer ID: 3');
@@ -245,4 +250,17 @@ class InvoiceCreateCommandTest extends KernelTestCase
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
}
public function testCreateInvoiceByProjectWithPreview()
{
$start = new \DateTime('-2 months');
$end = new \DateTime();
$this->prepareFixtures($start);
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'all', '--preview' => sys_get_temp_dir(), '--by-project' => null, '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
}
}

View File

@@ -266,7 +266,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class'));
}
public function testCreateActionAsAdminWithDownloadAndStatusChange()
public function testCreateActionAsAdminWithDownloadAndStatusChangeAndDelete()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
@@ -350,6 +350,11 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->request($client, '/invoice/delete/1');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
}
public function testEditTemplateAction()

View File

@@ -76,6 +76,15 @@ class InvoiceTemplateTest extends TestCase
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
self::assertEquals('de', $sut->getLanguage());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setNumberGenerator('foo'));
self::assertEquals('foo', $sut->getNumberGenerator());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setRenderer('bar'));
self::assertEquals('bar', $sut->getRenderer());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setCalculator('fooBar'));
self::assertEquals('fooBar', $sut->getCalculator());
self::assertEquals($sut, clone $sut);
}

View File

@@ -184,13 +184,15 @@ class UserTest extends TestCase
{
$sut = new User();
$sut->setAlias('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
self::assertEquals(60, \strlen($sut->getAlias()));
self::assertEquals(60, mb_strlen($sut->getAlias()));
$sut->setAlias('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAAAAA');
self::assertEquals(60, \strlen($sut->getAlias()));
self::assertEquals(60, mb_strlen($sut->getAlias()));
$sut->setAlias('万政提質打録施熟活者韓症写気当。規談表有部確暑将回優隊見竜能南事。竹阪板府入違護究兵厚能提。済伸知題熱正写場京誉事週在複今徳際供。審利世連手阿量携泉指済像更映刊政病世。熱楽時予資方賀月改洋者職原桜提増脚職。気公誌荒原輝文治察専及唱戦白廃模書。着授健出山力集出止員捉害実載措明国無今。棋出陶供供知機使協物確講最新両。');
self::assertEquals(60, mb_strlen($sut->getAlias()));
$sut->setTitle('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
self::assertEquals(50, \strlen($sut->getTitle()));
self::assertEquals(50, mb_strlen($sut->getTitle()));
$sut->setTitle('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAAAAAA');
self::assertEquals(50, \strlen($sut->getTitle()));
self::assertEquals(50, mb_strlen($sut->getTitle()));
}
public function testPreferencesCollectionIsCreatedOnBrokenUser()

View File

@@ -39,6 +39,7 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'invoice.currency',
'invoice.currency_symbol',
'invoice.vat',
'invoice.language',
'invoice.tax',
'invoice.tax_nc',
'invoice.tax_plain',
@@ -60,11 +61,19 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'template.contact',
'template.payment_details',
'query.begin',
'query.day',
'query.end',
'query.day',
'query.month',
'query.month_number',
'query.year',
'query.begin_day',
'query.begin_month',
'query.begin_month_number',
'query.begin_year',
'query.end_day',
'query.end_month',
'query.end_month_number',
'query.end_year',
];
$givenKeys = array_keys($model);

View File

@@ -84,6 +84,7 @@ class DebugRendererTest extends TestCase
'invoice.currency_symbol',
'invoice.vat',
'invoice.tax',
'invoice.language',
'invoice.tax_nc',
'invoice.tax_plain',
'invoice.total_time',
@@ -109,6 +110,14 @@ class DebugRendererTest extends TestCase
'query.month',
'query.month_number',
'query.year',
'query.begin_day',
'query.begin_month',
'query.begin_month_number',
'query.begin_year',
'query.end_day',
'query.end_month',
'query.end_month_number',
'query.end_year',
'customer.id',
'customer.address',
'customer.name',

View File

@@ -115,6 +115,7 @@ trait RendererTestTrait
$template = new InvoiceTemplate();
$template->setTitle('a very *long* test invoice / template title with [special] character');
$template->setVat(19);
$template->setLanguage('en');
$project = new Project();
$project->setName('project name');

View File

@@ -69,9 +69,19 @@ class TextRendererTest extends KernelTestCase
foreach ($model->toArray() as $key => $value) {
if (null === $value || '' === $value) {
self::assertStringContainsString(sprintf("%s:\n\n", $key), $content);
self::assertStringContainsString(sprintf("%s\n", $key), $content);
} else {
self::assertStringContainsString(sprintf("%s:\n %s", $key, explode("\n", $value)[0]), $content);
self::assertStringContainsString(sprintf("%s\n %s", $key, explode("\n", $value)[0]), $content);
}
}
foreach ($model->getCalculator()->getEntries() as $entry) {
foreach ($model->itemToArray($entry) as $key => $value) {
if (null === $value || '' === $value) {
self::assertStringContainsString(sprintf("%s\n", $key), $content);
} else {
self::assertStringContainsString(sprintf("%s\n %s", $key, explode("\n", $value)[0]), $content);
}
}
}
self::assertEquals(\count($model->getCalculator()->getEntries()), substr_count($content, PHP_EOL . '---' . PHP_EOL));