Meta-fields for InvoiceTemplate, structured Customer address (#5519)

This commit is contained in:
Kevin Papst
2025-11-02 11:24:17 +01:00
committed by GitHub
parent 76821c24ed
commit cc64acf0f8
72 changed files with 2111 additions and 801 deletions

View File

@@ -35,6 +35,7 @@
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"horstoeko/zugferd": "^1.0",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^1.4",
"league/csv": "^9.4",

1221
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.41
*/
final class Version20251031142000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add dedicated address fields for customer and customer-field for invoice template';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_customers ADD address_line1 VARCHAR(150) DEFAULT NULL, ADD address_line2 VARCHAR(150) DEFAULT NULL, ADD address_line3 VARCHAR(150) DEFAULT NULL, ADD postcode VARCHAR(20) DEFAULT NULL, ADD city VARCHAR(50) DEFAULT NULL, ADD buyer_reference VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD customer_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates MODIFY company VARCHAR(255) NULL');
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD CONSTRAINT FK_1626CFE99395C3F3 FOREIGN KEY (customer_id) REFERENCES kimai2_customers (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates (customer_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP FOREIGN KEY FK_1626CFE99395C3F3');
$this->addSql('DROP INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates');
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP customer_id');
$this->addSql('ALTER TABLE kimai2_customers DROP address_line1, DROP address_line2, DROP address_line3, DROP postcode, DROP city, DROP buyer_reference');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -0,0 +1,43 @@
<?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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.41
*/
final class Version20251031143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the invoice template meta table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE kimai2_invoice_templates_meta (id INT AUTO_INCREMENT NOT NULL, template_id INT NOT NULL, name VARCHAR(50) NOT NULL, value TEXT DEFAULT NULL, visible TINYINT(1) DEFAULT 0 NOT NULL, INDEX IDX_A165B0555DA0FB8 (template_id), UNIQUE INDEX UNIQ_A165B0555DA0FB85E237E06 (template_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE kimai2_invoice_templates_meta ADD CONSTRAINT FK_A165B0555DA0FB8 FOREIGN KEY (template_id) REFERENCES kimai2_invoice_templates (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$table = $schema->getTable('kimai2_invoice_templates_meta');
$table->removeForeignKey('FK_A165B0555DA0FB8');
$schema->dropTable('kimai2_invoice_templates_meta');
}
public function isTransactional(): bool
{
return false;
}
}

View File

@@ -41,6 +41,8 @@ parameters:
symfony:
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
ignoreErrors:
- identifier: throws.unusedType
- identifier: offsetAccess.notFound
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
-
@@ -753,11 +755,6 @@ parameters:
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Controller/InvoiceController.php
-
message: "#^Cannot call method getName\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
count: 1
@@ -804,7 +801,7 @@ parameters:
path: src/Controller/ProjectController.php
-
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\: DateTime, entry\\: App\\\\Entity\\\\Timesheet\\} will always evaluate to true\\.$#"
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\?\\: mixed, entry\\: mixed} will always evaluate to true\\.$#"
count: 1
path: src/Controller/QuickEntryController.php
@@ -983,11 +980,6 @@ parameters:
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Method App\\\\DataFixtures\\\\InvoiceFixtures\\:\\:getInvoiceConfigs\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/DataFixtures/InvoiceFixtures.php
-
message: "#^Method App\\\\DataFixtures\\\\TeamFixtures\\:\\:getGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2763,11 +2755,6 @@ parameters:
count: 1
path: src/Form/UserTwoFactorType.php
-
message: "#^Cannot call method getVat\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Calculator/AbstractCalculator.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
@@ -2818,31 +2805,6 @@ parameters:
count: 1
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
-
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTax\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTimeWorked\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 2
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getVat\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -2873,16 +2835,6 @@ parameters:
count: 1
path: src/Invoice/Hydrator/InvoiceModelUserHydrator.php
-
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Invoice/InvoiceFilename.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Invoice/InvoiceFilename.php
-
message: "#^Parameter \\#1 \\$filename of method App\\\\Invoice\\\\InvoiceFilename\\:\\:convert\\(\\) expects string, string\\|null given\\.$#"
count: 1
@@ -2928,11 +2880,6 @@ parameters:
count: 1
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
count: 5
@@ -2953,11 +2900,6 @@ parameters:
count: 1
path: src/Invoice/Renderer/AbstractTwigRenderer.php
-
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: src/Invoice/Renderer/AbstractTwigRenderer.php
-
message: "#^Method App\\\\Invoice\\\\Renderer\\\\AbstractTwigRenderer\\:\\:renderTwigTemplate\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
@@ -2983,11 +2925,6 @@ parameters:
count: 1
path: src/Invoice/ServiceInvoice.php
-
message: "#^Cannot call method getRenderer\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 6
path: src/Invoice/ServiceInvoice.php
-
message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
@@ -4207,3 +4144,8 @@ parameters:
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
count: 1
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
-
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
path: src/Entity/InvoiceTemplate.php

View File

@@ -17,6 +17,7 @@ use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
use App\Event\InvoiceTemplateMetaDefinitionEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
@@ -425,7 +426,7 @@ final class InvoiceController extends AbstractController
$table->addColumn('name', ['class' => 'alwaysVisible', 'orderBy' => false]);
$table->addColumn('title', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false]);
$table->addColumn('company', ['class' => 'd-none', 'orderBy' => false, 'title' => 'sending_company']);
$table->addColumn('vat_id', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('tax_rate', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
$table->addColumn('due_days', ['class' => 'd-none text-nowrap', 'orderBy' => false]);
@@ -728,6 +729,9 @@ final class InvoiceController extends AbstractController
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$event = new InvoiceTemplateMetaDefinitionEvent($template);
$this->dispatcher->dispatch($event);
$editForm = $this->createTemplateEditForm($template);
$editForm->handleRequest($request);

View File

@@ -127,7 +127,7 @@ final class QuickEntryController extends AbstractController
$startFrom = null;
if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) {
$startFrom = clone $startWeek;
$startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks));
$startFrom->modify(\sprintf('-%s weeks', (string) $takeOverWeeks));
}
$favorites = $this->favoriteRecordService->favoriteEntries($user, $amount);

View File

@@ -92,6 +92,10 @@ final class CustomerFixtures extends Fixture
$entry->setTimezone($faker->timezone());
$entry->setVisible($visible);
$entry->setVatId($faker->creditCardNumber());
$entry->setPostCode($faker->postcode());
$entry->setCity($faker->city());
$entry->setAddressLine1($faker->streetAddress());
$entry->setAddressLine2($faker->streetAddress());
if (rand(0, 3) % 3) {
$entry->setBudget(rand(self::MIN_BUDGET, self::MAX_BUDGET));

View File

@@ -9,6 +9,7 @@
namespace App\DataFixtures;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@@ -29,6 +30,8 @@ final class InvoiceFixtures extends Fixture
public function load(ObjectManager $manager): void
{
$faker = Factory::create('en_US');
/** @var non-empty-array<Customer> $customers */
$customers = $manager->getRepository(Customer::class)->findAll();
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
@@ -38,15 +41,16 @@ final class InvoiceFixtures extends Fixture
$template->setRenderer($invoiceConfig[2]);
$template->setCalculator($invoiceConfig[3]);
$template->setNumberGenerator($invoiceConfig[4]);
$template->setCompany($invoiceConfig[5]);
$template->setCompany($invoiceConfig[5]); // @phpstan-ignore method.deprecated
$template->setCustomer($customers[array_rand($customers)]);
$template->setVat($invoiceConfig[6]);
$template->setDueDays($invoiceConfig[7]);
$template->setPaymentTerms($invoiceConfig[8]);
$template->setLanguage('en');
$template->setAddress($this->generateAddress($faker));
$template->setAddress($this->generateAddress($faker)); // @phpstan-ignore method.deprecated
$template->setContact($this->generateContact($faker));
$template->setPaymentDetails($this->generatePaymentDetails($faker));
$template->setVatId($faker->creditCardNumber());
$template->setVatId($faker->creditCardNumber()); // @phpstan-ignore method.deprecated
$manager->persist($template);
$manager->flush();
@@ -54,8 +58,7 @@ final class InvoiceFixtures extends Fixture
}
/**
* @param Generator $faker
* @return array
* @return array<array{0: string, 1: string, 2: string, 3: string, 4: string, 5: string, 6: int, 7: int, 8: string}>
*/
private function getInvoiceConfigs(Generator $faker): array
{

View File

@@ -37,8 +37,7 @@ final class TeamFixtures extends Fixture
}
/**
* @param ObjectManager $manager
* @return array<int|string, User>
* @return non-empty-array<int|string, User>
*/
private function getAllUsers(ObjectManager $manager): array
{
@@ -46,15 +45,18 @@ final class TeamFixtures extends Fixture
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need users to setup teams');
}
return $all;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Project>
* @return non-empty-array<int|string, Project>
*/
private function getAllProjects(ObjectManager $manager): array
{
@@ -63,7 +65,11 @@ final class TeamFixtures extends Fixture
/** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need projects to setup teams');
}
return $all;

View File

@@ -130,10 +130,9 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
/**
* @template T of object
* @param ObjectManager $manager
* @param class-string<T> $class
* @param int $amount
* @return array<int, T>
* @return non-empty-array<int, T>
*/
private function findRandom(ObjectManager $manager, string $class, int $amount): array
{
@@ -154,14 +153,17 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$qb = $manager->getRepository($class)->createQueryBuilder('entity');
/** @var array<int, T> $result */
$result = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
/** @var array<int, T> $all */
$all = $qb->where($qb->expr()->in('entity.id', $ids))->setMaxResults($amount)->getQuery()->getResult();
return $result;
if (\count($all) === 0) {
throw new \Exception('Need users to setup teams');
}
return $all;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Tag>
*/
private function getAllTags(ObjectManager $manager): array
@@ -170,8 +172,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
}
/**
* @param ObjectManager $manager
* @return array<int|string, User>
* @return non-empty-array<int|string, User>
*/
private function getAllUsers(ObjectManager $manager): array
{
@@ -179,15 +180,18 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
$all[(string) $temp->getId()] = $temp;
}
if (\count($all) === 0) {
throw new \Exception('Need users to setup timesheets');
}
return $all;
}
/**
* @param ObjectManager $manager
* @return array<int|string, Project>
* @return non-empty-array<int|string, Project>
*/
private function getAllProjects(ObjectManager $manager): array
{
@@ -195,8 +199,7 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
}
/**
* @param ObjectManager $manager
* @return array<int|string, Activity>
* @return non-empty-array<int|string, Activity>
*/
private function getAllActivities(ObjectManager $manager): array
{

View File

@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'addressLine1', 'addressLine2', 'addressLine3', 'postCode', 'city', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
{
@@ -86,13 +86,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'vat_id')]
private ?string $vatId = null;
#[ORM\Column(name: 'contact', type: 'string', length: 100, nullable: true)]
/**
* Contact person (name)
*/
#[ORM\Column(name: 'contact', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'contact')]
private ?string $contact = null;
#[ORM\Column(name: 'address', type: 'text', nullable: true)]
/**
* Unstructured address, better use the fields: address_line1-3, postcode, city, country
*/
#[ORM\Column(name: 'address', type: Types::TEXT, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address')]
@@ -132,7 +138,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'mobile')]
private ?string $mobile = null;
/**
* Customers contact email
* Contact email
*/
#[ORM\Column(name: 'email', type: Types::STRING, length: 75, nullable: true)]
#[Assert\Length(max: 75)]
@@ -190,6 +196,43 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
private ?InvoiceTemplate $invoiceTemplate = null;
#[ORM\Column(name: 'invoice_text', type: Types::TEXT, nullable: true)]
private ?string $invoiceText = null;
#[ORM\Column(name: 'address_line1', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line1')]
private ?string $addressLine1 = null;
#[ORM\Column(name: 'address_line2', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line2')]
private ?string $addressLine2 = null;
#[ORM\Column(name: 'address_line3', type: Types::STRING, length: 150, nullable: true)]
#[Assert\Length(max: 150)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'address_line3')]
private ?string $addressLine3 = null;
#[ORM\Column(name: 'postcode', type: Types::STRING, length: 20, nullable: true)]
#[Assert\Length(max: 20)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'postcode')]
private ?string $postCode = null;
// this should be more than enough to cover 99.99% - https://en.wikipedia.org/wiki/List_of_long_place_names
#[ORM\Column(name: 'city', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'city')]
private ?string $city = null;
#[ORM\Column(name: 'buyer_reference', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer_Entity'])]
#[Exporter\Expose(label: 'buyer_reference')]
private ?string $buyerReference = null;
public function __construct(string $name)
{
@@ -299,6 +342,35 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
return $this->address;
}
public function getFormattedAddress(): ?string
{
$address = $this->getAddressLine1();
if ($this->getAddressLine2() !== null) {
$address .= PHP_EOL;
$address .= $this->getAddressLine2();
}
if ($this->getAddressLine3() !== null) {
$address .= PHP_EOL;
$address .= $this->getAddressLine3();
}
if ($this->getPostCode() !== null || $this->getCity() !== null) {
$address .= PHP_EOL;
if ($this->getPostCode() !== null) {
$address .= $this->getPostCode();
if ($this->getCity() !== null) {
$address .= ' ';
}
}
$address .= $this->getCity() ?? '';
}
if ($address !== null && $address !== '') {
return $address;
}
return $this->getAddress();
}
public function setCountry(?string $country): void
{
$this->country = $country;
@@ -479,6 +551,66 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
return $this->teams;
}
public function getAddressLine1(): ?string
{
return $this->addressLine1;
}
public function setAddressLine1(?string $addressLine1): void
{
$this->addressLine1 = $addressLine1;
}
public function getAddressLine2(): ?string
{
return $this->addressLine2;
}
public function setAddressLine2(?string $addressLine2): void
{
$this->addressLine2 = $addressLine2;
}
public function getAddressLine3(): ?string
{
return $this->addressLine3;
}
public function setAddressLine3(?string $addressLine3): void
{
$this->addressLine3 = $addressLine3;
}
public function getPostCode(): ?string
{
return $this->postCode;
}
public function setPostCode(?string $postCode): void
{
$this->postCode = $postCode;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): void
{
$this->city = $city;
}
public function getBuyerReference(): ?string
{
return $this->buyerReference;
}
public function setBuyerReference(?string $buyerReference): void
{
$this->buyerReference = $buyerReference;
}
public function __toString(): string
{
return $this->getName();

View File

@@ -221,18 +221,12 @@ class Invoice implements EntityWithMetaFields
public function setModel(InvoiceModel $model): Invoice
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('Missing invoice template');
}
if ($template->getDueDays() === null || $template->getVat() === null) {
throw new \InvalidArgumentException('Missing due-days or vat setting');
}
$customer = $model->getCustomer();
if ($customer === null) {
throw new \InvalidArgumentException('Missing invoice customer');
}
$user = $model->getUser();
if ($user === null) {

View File

@@ -10,6 +10,8 @@
namespace App\Entity;
use App\Repository\InvoiceTemplateRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -20,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class InvoiceTemplate
class InvoiceTemplate implements EntityWithMetaFields
{
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Id]
@@ -34,9 +36,8 @@ class InvoiceTemplate
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(name: 'company', type: 'string', length: 255, nullable: false)]
#[ORM\Column(name: 'company', type: Types::STRING, length: 255, nullable: true)]
#[Assert\Length(max: 255)]
#[Assert\NotBlank]
private ?string $company = null;
#[ORM\Column(name: 'vat_id', type: Types::STRING, length: 50, nullable: true)]
#[Assert\Length(max: 50)]
@@ -75,17 +76,32 @@ class InvoiceTemplate
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: false)]
#[Assert\NotBlank]
private ?string $language = 'en';
/**
* Customer for this invoice template
*/
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Assert\NotNull]
private ?Customer $customer = null;
/**
* @var Collection<int, InvoiceTemplateMeta>
*/
#[ORM\OneToMany(mappedBy: 'template', targetEntity: InvoiceTemplateMeta::class, cascade: ['persist'])]
private Collection $meta;
public function __construct()
{
$this->meta = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setName(string $name): InvoiceTemplate
public function setName(string $name): void
{
$this->name = $name;
return $this;
}
public function getName(): ?string
@@ -93,6 +109,16 @@ class InvoiceTemplate
return $this->name;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): void
{
$this->customer = $customer;
}
// ---- trait methods below ---
public function getTitle(): ?string
@@ -100,23 +126,22 @@ class InvoiceTemplate
return $this->title;
}
public function setTitle(?string $title): InvoiceTemplate
public function setTitle(?string $title): void
{
$this->title = $title;
return $this;
}
public function getAddress(): ?string
{
return $this->address;
return $this->customer?->getFormattedAddress() ?? $this->address;
}
public function setAddress(?string $address): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setAddress(?string $address): void
{
$this->address = $address;
return $this;
}
public function getNumberGenerator(): string
@@ -124,11 +149,9 @@ class InvoiceTemplate
return $this->numberGenerator;
}
public function setNumberGenerator(string $numberGenerator): InvoiceTemplate
public function setNumberGenerator(string $numberGenerator): void
{
$this->numberGenerator = $numberGenerator;
return $this;
}
public function getDueDays(): ?int
@@ -136,11 +159,9 @@ class InvoiceTemplate
return $this->dueDays;
}
public function setDueDays(?int $dueDays): InvoiceTemplate
public function setDueDays(?int $dueDays): void
{
$this->dueDays = $dueDays;
return $this;
}
public function getVat(): ?float
@@ -148,23 +169,22 @@ class InvoiceTemplate
return $this->vat;
}
public function setVat(?float $vat): InvoiceTemplate
public function setVat(?float $vat): void
{
$this->vat = $vat;
return $this;
}
public function getCompany(): ?string
{
return $this->company;
return $this->customer?->getCompany() ?? $this->customer?->getName() ?? $this->company;
}
public function setCompany(?string $company): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setCompany(?string $company): void
{
$this->company = $company;
return $this;
}
public function getRenderer(): string
@@ -172,11 +192,9 @@ class InvoiceTemplate
return $this->renderer;
}
public function setRenderer(string $renderer): InvoiceTemplate
public function setRenderer(string $renderer): void
{
$this->renderer = $renderer;
return $this;
}
public function getCalculator(): string
@@ -184,11 +202,9 @@ class InvoiceTemplate
return $this->calculator;
}
public function setCalculator(string $calculator): InvoiceTemplate
public function setCalculator(string $calculator): void
{
$this->calculator = $calculator;
return $this;
}
public function getPaymentTerms(): ?string
@@ -196,23 +212,22 @@ class InvoiceTemplate
return $this->paymentTerms;
}
public function setPaymentTerms(?string $paymentTerms): InvoiceTemplate
public function setPaymentTerms(?string $paymentTerms): void
{
$this->paymentTerms = $paymentTerms;
return $this;
}
public function getVatId(): ?string
{
return $this->vatId;
return $this->customer?->getVatId() ?? $this->vatId;
}
public function setVatId(?string $vatId): InvoiceTemplate
/**
* @deprecated since 2.41
*/
public function setVatId(?string $vatId): void
{
$this->vatId = $vatId;
return $this;
}
public function getContact(): ?string
@@ -220,11 +235,9 @@ class InvoiceTemplate
return $this->contact;
}
public function setContact(?string $contact): InvoiceTemplate
public function setContact(?string $contact): void
{
$this->contact = $contact;
return $this;
}
public function getPaymentDetails(): ?string
@@ -232,11 +245,9 @@ class InvoiceTemplate
return $this->paymentDetails;
}
public function setPaymentDetails(?string $paymentDetails): InvoiceTemplate
public function setPaymentDetails(?string $paymentDetails): void
{
$this->paymentDetails = $paymentDetails;
return $this;
}
/**
@@ -252,9 +263,78 @@ class InvoiceTemplate
return $this->language;
}
public function setLanguage(?string $language): InvoiceTemplate
public function setLanguage(?string $language): void
{
$this->language = $language;
}
/**
* @return Tax[]
*/
public function getTaxRates(): array
{
// TODO make me configurable via UI
$tax = new Tax(
TaxType::STANDARD,
'VAT',
$this->vat ?? 0.00
);
return [$tax];
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if ($field->getName() !== null && strtolower($field->getName()) === strtolower($name)) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if ($meta->getName() === null) {
throw new \InvalidArgumentException('Meta-field needs to have a name');
}
if (!$meta instanceof InvoiceTemplateMeta) {
throw new \InvalidArgumentException('Meta-field needs to be an instanceof InvoiceTemplateMeta');
}
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}

View File

@@ -0,0 +1,49 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoice_templates_meta')]
#[ORM\UniqueConstraint(columns: ['template_id', 'name'])]
#[ORM\Entity]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
class InvoiceTemplateMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class, inversedBy: 'meta')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?InvoiceTemplate $template = null;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof InvoiceTemplate)) {
throw new \InvalidArgumentException(
\sprintf('Expected instanceof InvoiceTemplate, received "%s"', \get_class($entity))
);
}
$this->template = $entity;
return $this;
}
/**
* @return InvoiceTemplate|null
*/
public function getEntity(): ?EntityWithMetaFields
{
return $this->template;
}
}

36
src/Entity/Tax.php Normal file
View File

@@ -0,0 +1,36 @@
<?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\Entity;
final class Tax
{
public function __construct(
private readonly TaxType $type,
private readonly string $name = 'VAT',
private readonly float $rate = 0.0,
)
{
}
public function getType(): TaxType
{
return $this->type;
}
public function getName(): string
{
return $this->name;
}
public function getRate(): float
{
return $this->rate;
}
}

17
src/Entity/TaxType.php Normal file
View File

@@ -0,0 +1,17 @@
<?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\Entity;
enum TaxType: string
{
case STANDARD = 'standard';
case REVERSE = 'reverse';
case EXEMPT = 'exempt';
}

View File

@@ -0,0 +1,28 @@
<?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\Event;
use App\Entity\InvoiceTemplate;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to invoice templates
*/
final class InvoiceTemplateMetaDefinitionEvent extends Event
{
public function __construct(private readonly InvoiceTemplate $entity)
{
}
public function getEntity(): InvoiceTemplate
{
return $this->entity;
}
}

View File

@@ -34,12 +34,14 @@ class CustomerEditForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$isNew = false;
$hasAddress = false;
if (isset($options['data'])) {
/** @var Customer $customer */
$customer = $options['data'];
$isNew = $customer->getId() === null;
$options['currency'] = $customer->getCurrency();
$hasAddress = $customer->getAddress() !== null && $customer->getAddress() !== '';
}
$builder
@@ -69,10 +71,38 @@ class CustomerEditForm extends AbstractType
'label' => 'contact',
'required' => false,
])
->add('address', TextareaType::class, [
;
if ($hasAddress) {
$builder
->add('address', TextareaType::class, [
'label' => 'address',
'help' => 'address_deprecated',
'required' => false,
]);
}
$builder
->add('address_line1', TextType::class, [
'label' => 'address',
'required' => false,
])
->add('address_line2', TextType::class, [
'label' => false,
'required' => false,
])
->add('address_line3', TextType::class, [
'label' => false,
'required' => false,
])
->add('postcode', TextType::class, [
'label' => 'postcode',
'required' => false,
])
->add('city', TextType::class, [
'label' => 'city',
'required' => false,
])
->add('country', CountryType::class, [
'label' => 'country',
])
@@ -117,6 +147,10 @@ class CustomerEditForm extends AbstractType
'help' => 'help.invoiceTemplate_customer',
'required' => false,
])
->add('buyerReference', TextType::class, [
'label' => 'buyerReference',
'required' => false,
])
;
if ($isNew) {

View File

@@ -10,10 +10,12 @@
namespace App\Form;
use App\Entity\InvoiceTemplate;
use App\Form\Type\CustomerType;
use App\Form\Type\InvoiceCalculatorType;
use App\Form\Type\InvoiceNumberGeneratorType;
use App\Form\Type\InvoiceRendererType;
use App\Form\Type\LanguageType;
use App\Form\Type\MetaFieldsCollectionType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
@@ -36,17 +38,6 @@ final class InvoiceTemplateForm extends AbstractType
->add('title', TextType::class, [
'label' => 'title',
])
->add('company', TextType::class, [
'label' => 'company',
])
->add('vatId', TextType::class, [
'label' => 'vat_id',
'required' => false,
])
->add('address', TextareaType::class, [
'label' => 'address',
'required' => false,
])
->add('contact', TextareaType::class, [
'label' => 'contact',
'required' => false,
@@ -70,6 +61,13 @@ final class InvoiceTemplateForm extends AbstractType
->add('calculator', InvoiceCalculatorType::class)
->add('numberGenerator', InvoiceNumberGeneratorType::class)
->add('language', LanguageType::class)
->add('customer', CustomerType::class, [
'required' => true,
'label' => 'sending_company',
'placeholder' => '',
'help' => 'sending_company.help',
])
->add('metaFields', MetaFieldsCollectionType::class)
;
}

View File

@@ -9,7 +9,6 @@
namespace App\Form\MultiUpdate;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
@@ -42,7 +41,12 @@ final class MultiUpdateTable extends AbstractType
return [];
}
return $repository->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
$temp = explode(',', $ids);
if (\count($temp) === 0) {
return [];
}
return $repository->findBy(['id' => $temp]);
}
)
);

View File

@@ -21,7 +21,6 @@ use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -253,7 +252,12 @@ final class TimesheetMultiUpdate extends AbstractType
return [];
}
return $this->timesheet->matching((new Criteria())->where(Criteria::expr()->in('id', explode(',', $ids))));
$temp = explode(',', $ids);
if (\count($temp) === 0) {
return [];
}
return $this->timesheet->findBy(['id' => $temp]);
}
)
);

View File

@@ -147,7 +147,7 @@ final class DateRangeType extends AbstractType
}
/**
* @param array{'format': non-empty-string, 'separator': non-empty-string, 'allow_empty': true, 'timezone': non-empty-string} $options
* @param array{'format': non-empty-string, 'separator': non-empty-string, 'allow_empty': true, 'timezone': non-empty-string, 'user': User} $options
*/
public function buildForm(FormBuilderInterface $builder, array $options): void // @phpstan-ignore-line
{

View File

@@ -11,6 +11,7 @@ namespace App\Invoice\Calculator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
abstract class AbstractCalculator
{
@@ -51,21 +52,35 @@ abstract class AbstractCalculator
return round($amount, 2);
}
/**
* @deprecated use getTaxRows() instead
*/
public function getVat(): float
{
return $this->model->getTemplate()->getVat() ?? 0.00;
}
public function getTax(): float
/**
* @return array<TaxRow>
*/
public function getTaxRows(): array
{
$vat = $this->getVat();
if (0.00 === $vat) {
return 0.00;
$rows = [];
foreach ($this->model->getTemplate()->getTaxRates() as $taxRate) {
$rows[] = new TaxRow($taxRate, $this->getSubtotal());
}
$percent = $vat / 100.00;
return $rows;
}
return round($this->getSubtotal() * $percent, 2);
public function getTax(): float
{
$tax = 0.00;
foreach ($this->getTaxRows() as $row) {
$tax += $row->getAmount();
}
return round($tax, 2);
}
public function getTotal(): float
@@ -75,8 +90,6 @@ abstract class AbstractCalculator
/**
* Returns the total amount of worked time in seconds.
*
* @return int
*/
public function getTimeWorked(): int
{

View File

@@ -26,39 +26,35 @@ interface CalculatorInterface
/**
* Set the invoice model and can be used to fetch the customer.
*
* @param InvoiceModel $model
*/
public function setModel(InvoiceModel $model): void;
/**
* Returns the subtotal before taxes.
*
* @return float
*/
public function getSubtotal(): float;
/**
* Returns the tax amount for this invoice.
*
* @return float
*/
public function getTax(): float;
/**
* Returns the total amount for this invoice including taxes.
*
* @return float
*/
public function getTotal(): float;
/**
* Returns the percentage for the value-added tax (VAT) calculation.
*
* @return float
* @deprecated use getTaxRows() instead
*/
public function getVat(): float;
/**
* @return array<TaxRow>
*/
public function getTaxRows(): array;
/**
* Returns the total amount of worked time in seconds.
*
@@ -71,8 +67,6 @@ interface CalculatorInterface
*
* Prefix it with your company name followed by a hyphen (e.g. "acme-"),
* if this is a third-party calculator.
*
* @return string
*/
public function getId(): string;
}

View File

@@ -12,6 +12,7 @@ namespace App\Invoice\Hydrator;
use App\Customer\CustomerStatisticService;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
@@ -25,22 +26,26 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
$customer = $model->getCustomer();
if (null === $customer) {
return [];
}
$prefix = 'customer.';
$language = $model->getTemplate()->getLanguage();
$country = $customer->getCountry();
$values = [
$prefix . 'id' => $customer->getId(),
$prefix . 'address' => $customer->getAddress() ?? '',
$prefix . 'address' => $customer->getFormattedAddress() ?? '',
$prefix . 'address_line1' => $customer->getAddressLine1() ?? '',
$prefix . 'address_line2' => $customer->getAddressLine2() ?? '',
$prefix . 'address_line3' => $customer->getAddressLine3() ?? '',
$prefix . 'postcode' => $customer->getPostCode() ?? '',
$prefix . 'city' => $customer->getCity() ?? '',
$prefix . 'name' => $customer->getName() ?? '',
$prefix . 'contact' => $customer->getContact() ?? '',
$prefix . 'company' => $customer->getCompany() ?? '',
$prefix . 'vat' => $customer->getVatId() ?? '', // deprecated since 2.0.15
$prefix . 'vat_id' => $customer->getVatId() ?? '',
$prefix . 'number' => $customer->getNumber() ?? '',
$prefix . 'country' => $customer->getCountry(),
$prefix . 'country' => $country,
$prefix . 'country_name' => $country !== null ? Countries::getName($country, $language) : null,
$prefix . 'homepage' => $customer->getHomepage() ?? '',
$prefix . 'comment' => $customer->getComment() ?? '',
$prefix . 'email' => $customer->getEmail() ?? '',
@@ -48,6 +53,7 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
$prefix . 'phone' => $customer->getPhone() ?? '',
$prefix . 'mobile' => $customer->getMobile() ?? '',
$prefix . 'invoice_text' => $customer->getInvoiceText() ?? '',
$prefix . 'buyer_reference' => $customer->getBuyerReference() ?? '',
];
$end = $model->getQuery()?->getEnd();

View File

@@ -11,6 +11,7 @@ namespace App\Invoice\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use Symfony\Component\Intl\Countries;
final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
{
@@ -19,16 +20,23 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
public function hydrate(InvoiceModel $model): array
{
$template = $model->getTemplate();
if ($template === null) {
throw new \InvalidArgumentException('InvoiceModel needs a template');
$calculator = $model->getCalculator();
if ($calculator === null) {
throw new \InvalidArgumentException('InvoiceModel needs a calculator');
}
$currency = $model->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$tax = $calculator->getTax();
$total = $calculator->getTotal();
$subtotal = $calculator->getSubtotal();
$formatter = $model->getFormatter();
$language = $template->getLanguage();
$taxRows = $calculator->getTaxRows();
$vat = 0.00;
foreach ($taxRows as $taxRow) {
$vat += $taxRow->getTax()->getRate();
}
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
@@ -39,13 +47,13 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.currency' => $currency,
'invoice.language' => $language, // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.vat' => $vat, // @deprecated, use invoice.tax_rows instead
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false),
'invoice.tax_plain' => $tax,
'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), // @deprecated, use invoice.tax_rows instead
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), // @deprecated, use invoice.tax_rows instead
'invoice.tax_plain' => $tax, // @deprecated, use invoice.tax_rows instead
'invoice.total_time' => $formatter->getFormattedDuration($calculator->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($calculator->getTimeWorked()),
'invoice.total' => $formatter->getFormattedMoney($total, $currency),
'invoice.total_nc' => $formatter->getFormattedMoney($total, $currency, false),
'invoice.total_plain' => $total,
@@ -82,6 +90,26 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'user.see_others' => ($model->getQuery()?->getUser() === null),
];
$values['invoice.tax_rows'] = [];
foreach ($taxRows as $taxRow) {
$values['invoice.tax_rows'][] = [
'type' => $taxRow->getTax()->getType()->value,
'name' => $taxRow->getTax()->getName(),
'rate' => $taxRow->getTax()->getRate(),
'amount' => $taxRow->getAmount(), // do not format, only available in twig anyway
'base' => $taxRow->getBasePrice(), // do not format, only available in twig anyway
];
}
$seller = $template->getCustomer();
if ($seller !== null) {
$country = $seller->getCountry();
if ($country !== null) {
$values['template.country'] = $country;
$values['template.country_name'] = Countries::getName($country, $language);
}
}
$query = $model->getQuery();
if ($query !== null) {
$begin = $query->getBegin();

View File

@@ -23,20 +23,20 @@ use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Project\ProjectStatisticService;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
/**
* InvoiceModel is the ONLY value that a RendererInterface receives for generating the invoice,
* besides the InvoiceDocument which is used as a "template".
*/
#[Exclude]
final class InvoiceModel
{
private ?Customer $customer = null;
private ?InvoiceQuery $query = null;
/**
* @var ExportableItem[]
*/
private array $entries = [];
private ?InvoiceTemplate $template = null;
private ?CalculatorInterface $calculator = null;
private ?NumberGeneratorInterface $generator = null;
private \DateTimeInterface $invoiceDate;
@@ -61,7 +61,14 @@ final class InvoiceModel
/**
* @internal use InvoiceModelFactory
*/
public function __construct(InvoiceFormatter $formatter, CustomerStatisticService $customerStatistic, ProjectStatisticService $projectStatistic, ActivityStatisticService $activityStatistic)
public function __construct(
InvoiceFormatter $formatter,
CustomerStatisticService $customerStatistic,
ProjectStatisticService $projectStatistic,
ActivityStatisticService $activityStatistic,
private readonly Customer $customer,
private readonly InvoiceTemplate $template,
)
{
$this->invoiceDate = new \DateTimeImmutable();
$this->formatter = $formatter;
@@ -138,37 +145,20 @@ final class InvoiceModel
return $this;
}
public function getTemplate(): ?InvoiceTemplate
public function getTemplate(): InvoiceTemplate
{
return $this->template;
}
public function setTemplate(InvoiceTemplate $template): void
{
$this->template = $template;
}
public function getCustomer(): ?Customer
public function getCustomer(): Customer
{
return $this->customer;
}
public function setCustomer(Customer $customer): void
{
$this->customer = $customer;
}
/**
* Requires the template and invoice date to be set
*/
public function getDueDate(): \DateTimeInterface
{
$date = \DateTimeImmutable::createFromInterface($this->getInvoiceDate());
$dueDays = 14;
if ($this->getTemplate() !== null) {
$dueDays = $this->getTemplate()->getDueDays();
}
$dueDays = $this->template->getDueDays();
return $date->add(new \DateInterval('P' . $dueDays . 'D'));
}
@@ -246,8 +236,8 @@ final class InvoiceModel
public function getCurrency(): string
{
if (null !== $this->getCustomer() && $this->getCustomer()->getCurrency() !== null) {
return $this->getCustomer()->getCurrency();
if ($this->customer->getCurrency() !== null) {
return $this->customer->getCurrency();
}
return Customer::DEFAULT_CURRENCY;

View File

@@ -27,10 +27,8 @@ final class InvoiceModelFactory
public function createModel(InvoiceFormatter $formatter, Customer $customer, InvoiceTemplate $template, InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService);
$model = new InvoiceModel($formatter, $this->customerStatisticService, $this->projectStatisticService, $this->activityStatisticService, $customer, $template);
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
return $model;

View File

@@ -53,11 +53,11 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
throw new \InvalidArgumentException('Missing invoice model, cannot calculate invoice number');
}
if ($format === 'cname' && $this->model->getCustomer()?->getName() === null) {
if ($format === 'cname' && $this->model->getCustomer()->getName() === null) {
throw new \InvalidArgumentException('Customer has no name, replacer {cname} failed evaluation');
}
if ($format === 'cnumber' && $this->model->getCustomer()?->getNumber() === null) {
if ($format === 'cnumber' && $this->model->getCustomer()->getNumber() === null) {
throw new \InvalidArgumentException('Customer has no number, replacer {cnumber} failed evaluation');
}
@@ -87,8 +87,8 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
'cy' => $this->repository->getCounterForYear($invoiceDate) + $increaseBy,
'cm' => $this->repository->getCounterForMonth($invoiceDate) + $increaseBy,
'cd' => $this->repository->getCounterForDay($invoiceDate) + $increaseBy,
'cname' => (string) $this->model->getCustomer()?->getName(),
'cnumber' => (string) $this->model->getCustomer()?->getNumber(),
'cname' => (string) $this->model->getCustomer()->getName(),
'cnumber' => (string) $this->model->getCustomer()->getNumber(),
default => $originalFormat,
};
});

View File

@@ -29,6 +29,11 @@ final class DocxRenderer extends AbstractRenderer implements RendererInterface
$template = new TemplateProcessor($document->getFilename());
foreach ($model->toArray() as $search => $replace) {
if (\is_array($replace)) {
// TODO tax rows
continue;
}
$replace = $xmlEscaper->escape($replace);
$replace = preg_replace('/\n|\r\n?/', '</w:t><w:br /><w:t xml:space="preserve">', $replace);

39
src/Invoice/TaxRow.php Normal file
View File

@@ -0,0 +1,39 @@
<?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\Invoice;
use App\Entity\Tax;
final class TaxRow
{
public function __construct(
private readonly Tax $tax,
private readonly float $basePrice = 0.0
)
{
}
public function getTax(): Tax
{
return $this->tax;
}
public function getBasePrice(): float
{
return $this->basePrice;
}
public function getAmount(): float
{
$percent = $this->tax->getRate() / 100.00;
return round($this->basePrice * $percent, 4);
}
}

View File

@@ -34,7 +34,7 @@ final class MPdfConverter implements HtmlToPdfConverter
$filtered = array_filter($options, function ($key): bool {
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files'
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files', 'additional_xmp_rdf'
];
if (!\in_array($key, $allowed)) {
$configs = new ConfigVariables();
@@ -110,6 +110,12 @@ final class MPdfConverter implements HtmlToPdfConverter
unset($options['associated_files']);
}
$additionalXmpRdf = null;
if (\array_key_exists('additional_xmp_rdf', $options) && \is_string($options['additional_xmp_rdf'])) {
$additionalXmpRdf = $options['additional_xmp_rdf'];
unset($options['additional_xmp_rdf']);
}
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
@@ -117,6 +123,10 @@ final class MPdfConverter implements HtmlToPdfConverter
$mpdf->SetAssociatedFiles($associatedFiles);
}
if ($additionalXmpRdf !== null) {
$mpdf->SetAdditionalXmpRdf($additionalXmpRdf);
}
return $mpdf;
}

View File

@@ -10,10 +10,12 @@
namespace App\Utils;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
#[Exclude]
final class Context
{
public function __construct(private User $user)
public function __construct(private readonly User $user)
{
}

View File

@@ -38,10 +38,31 @@
</td>
</tr>
{% endif %}
{% if customer.addressLine1 is not empty %}
<tr>
<th>{{ 'address'|trans }}</th>
<td>
{{ customer.addressLine1 }}<br>
{% if customer.addressLine2 is not empty %}
{{ customer.addressLine2 }}<br>
{% endif %}
{% if customer.addressLine3 is not empty %}
{{ customer.addressLine3 }}<br>
{% endif %}
{{ customer.postCode }} {{ customer.city }}
</td>
</tr>
{% endif %}
{% if customer.address is not empty %}
<tr>
<th>{{ 'address'|trans }}</th>
<td>{{ customer.address|nl2br }}</td>
<td>
{{ widgets.alert('info', 'address_deprecated'|trans) }}
<br>
<i>
{{ customer.address|nl2br }}
</i>
</td>
</tr>
{% endif %}
{% if customer.country is not empty %}

View File

@@ -18,28 +18,16 @@
</div>
</div>
{{ form_row(form.comment) }}
{{ form_row(form.address) }}
<div class="row">
<div class="col-md-4">
{{ form_row(form.company) }}
</div>
<div class="col-md-4">
{{ form_row(form.number) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.country) }}
</div>
<div class="col-md-4">
{{ form_row(form.currency) }}
</div>
<div class="col-md-4">
{{ form_row(form.timezone) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
@@ -87,21 +75,66 @@
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
</div>
<div class="col-md-6">
{{ form_row(form.billable) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'address'|trans }}{% endblock %}
{% block body %}
<div class="row">
<div class="col-12">
{{ form_row(form.company) }}
</div>
</div>
{% if form.address is defined %}
<div class="row">
<div class="col-12">
{{ form_row(form.address) }}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-12">
{{ form_row(form.address_line1, {attr: {placeholder: 'address_row'|trans({'%row%': 1})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line2, {attr: {placeholder: 'address_row'|trans({'%row%': 2})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line3, {attr: {placeholder: 'address_row'|trans({'%row%': 3})}}) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.postcode) }}
</div>
<div class="col-md-9">
{{ form_row(form.city) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.country) }}
</div>
<div class="col-md-6">
{{ form_row(form.timezone) }}
</div>
</div>
{% endblock %}
{% endembed %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.buyerReference is defined %}
{{ form_row(form.buyerReference) }}
{% endif %}
{% if form.invoiceTemplate is defined %}
{{ form_row(form.invoiceTemplate) }}
{% endif %}
@@ -110,6 +143,14 @@
{% endif %}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.visible) }}
</div>
<div class="col-md-6">
{{ form_row(form.billable) }}
</div>
</div>
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -48,6 +48,10 @@ mpdf-->
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong>
<br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
{% if invoice['customer.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
@@ -59,6 +63,12 @@ mpdf-->
<strong>{{ invoice['template.company'] }}</strong>
<br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}:

View File

@@ -25,6 +25,10 @@
<address contenteditable="true">
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
{% if invoice['customer.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
@@ -45,6 +49,12 @@
<address contenteditable="true">
<strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}:

View File

@@ -14,6 +14,12 @@
<tr>
<td class="text-small">
{{ invoice['template.company'] }} &ndash; {{ invoice['template.address']|nl2str(' ') }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
{{ country }}
{% endif %}
{% endif %}
{% if invoice['template.vat_id'] is not empty %}
&ndash; {{ 'vat_id'|trans }}: {{ invoice['template.vat_id'] }}
{% endif %}
@@ -33,6 +39,12 @@
<br>
{{ invoice['template.company'] }}<br>
{{ invoice['template.address']|nl2br }}
{% if invoice['template.country'] is not null %}
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['template.address'] %}
<br> {{ country }}
{% endif %}
{% endif %}
</td>
<td class="text-center">
<strong>{{ 'invoice_bank_account'|trans }}</strong>
@@ -54,6 +66,10 @@ mpdf-->
<td>
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ invoice['customer.address']|nl2br }}
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
{% if country not in invoice['customer.address'] %}
<br> {{ country }}
{% endif %}
</td>
<td class="text-right">
{% set classLeft = 'text-left' %}

View File

@@ -18,25 +18,19 @@
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.company) }}
</div>
<div class="col-md-6">
{{ form_row(form.vatId) }}
<div class="col-md-12">
{{ form_row(form.customer) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.address) }}
<div class="col-md-12">
{{ form_row(form.paymentTerms) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.contact) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.paymentTerms) }}
</div>
<div class="col-md-6">
{{ form_row(form.paymentDetails) }}
</div>
@@ -70,6 +64,15 @@
</div>
</div>
</fieldset>
{% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset class="form-fieldset pb-0">
<div class="row">
<div class="col">
{{ form_row(form.metaFields) }}
</div>
</div>
</fieldset>
{% endif %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -26,14 +26,6 @@
{{ form_row(form.number) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.orderNumber) }}
</div>
<div class="col-md-6">
{{ form_row(form.orderDate) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.start) }}
@@ -66,6 +58,26 @@
</div>
</div>
{% endif %}
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.orderNumber) }}
</div>
<div class="col-md-6">
{{ form_row(form.orderDate) }}
</div>
</div>
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
<div class="row">
<div class="col-md-4">
{{ form_row(form.visible) }}
@@ -77,18 +89,6 @@
{{ form_row(form.globalActivities) }}
</div>
</div>
{% if form.metaFields is defined and form.metaFields is not empty %}
{{ form_row(form.metaFields) }}
{% endif %}
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'invoices'|trans }}{% endblock %}
{% block body %}
{% if form.invoiceText is defined %}
{{ form_row(form.invoiceText) }}
{% endif %}
{% endblock %}
{% endembed %}
{{ form_rest(form) }}
{% endblock %}
{% endembed %}

View File

@@ -487,6 +487,12 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'timeBudget' => 'int',
'vatId' => '@string', // since 1.10
'budgetType' => '@string', // since 1.15
'addressLine1' => '@string', // since 2.32
'addressLine2' => '@string', // since 2.32
'addressLine3' => '@string', // since 2.32
'city' => '@string', // since 2.32
'postCode' => '@string', // since 2.32
'buyerReference' => '@string', // since 2.41
];
// if a project is embedded

View File

@@ -102,9 +102,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
$form = $client->getCrawler()->filter('form[name=invoice_template_form]')->form();
$client->submit($form, [
'invoice_template_form' => [
'name' => 'Test',
'name' => 'FooBar Template',
'title' => 'Test invoice template',
'company' => 'Company name',
'customer' => 1,
'renderer' => 'default',
'calculator' => 'default',
'vat' => '27,937',
@@ -117,9 +117,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
$this->assertHasFlashSuccess($client);
$template = $this->getEntityManager()->getRepository(InvoiceTemplate::class)->findAll()[0];
self::assertEquals('Test', $template->getName());
self::assertEquals('FooBar Template', $template->getName());
self::assertEquals('Test invoice template', $template->getTitle());
self::assertEquals('Company name', $template->getCompany());
self::assertEquals('Test', $template->getCompany());
self::assertEquals('default', $template->getRenderer());
self::assertEquals('default', $template->getCalculator());
self::assertEquals('27.937', $template->getVat());
@@ -145,8 +145,6 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
self::assertEquals($template->getCalculator(), $values['calculator']);
self::assertEquals($template->getVat(), $values['vat']);
self::assertEquals($template->getRenderer(), $values['renderer']);
self::assertEquals($template->getCompany(), $values['company']);
self::assertEquals($template->getAddress(), $values['address']);
self::assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
}
@@ -408,7 +406,7 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
'invoice_template_form' => [
'name' => 'Test 2!',
'title' => 'Test invoice template',
'company' => 'Company name',
'customer' => 1,
'renderer' => 'default',
'calculator' => 'default',
]

View File

@@ -9,6 +9,7 @@
namespace App\Tests\DataFixtures;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
@@ -27,10 +28,21 @@ class InvoiceTemplateFixtures implements TestFixture
$faker = Factory::create();
$customer = new Customer($faker->company());
$customer->setCountry($faker->countryCode());
$customer->setTimezone($faker->timezone());
$customer->setCompany('Company name');
$customer->setAddressLine1($faker->streetAddress());
$customer->setAddressLine2($faker->streetAddress());
$customer->setPostCode($faker->postcode());
$customer->setCity($faker->city());
$manager->persist($customer);
$template = new InvoiceTemplate();
$template->setName('Invoice');
$template->setTitle('Your company name');
$template->setCompany($faker->company());
$template->setCustomer($customer);
$template->setVat(19);
$template->setDueDays(14);
$template->setPaymentTerms(
@@ -39,9 +51,7 @@ class InvoiceTemplateFixtures implements TestFixture
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
);
$template->setAddress(
$faker->streetAddress() . PHP_EOL .
$faker->city() . ' ' . $faker->postcode() . ', ' . $faker->country() . PHP_EOL .
$template->setContact(
'Phone: ' . $faker->phoneNumber() . PHP_EOL .
'Email: ' . $faker->safeEmail()
);

View File

@@ -35,6 +35,13 @@ class CustomerTest extends AbstractEntityTestCase
self::assertNull($sut->getVatId());
self::assertNull($sut->getContact());
self::assertNull($sut->getAddress());
self::assertNull($sut->getAddressLine1());
self::assertNull($sut->getAddressLine2());
self::assertNull($sut->getAddressLine3());
self::assertNull($sut->getFormattedAddress());
self::assertNull($sut->getCity());
self::assertNull($sut->getPostCode());
self::assertNull($sut->getBuyerReference());
self::assertNull($sut->getCountry());
self::assertEquals('EUR', $sut->getCurrency());
self::assertEquals('EUR', Customer::DEFAULT_CURRENCY);
@@ -137,6 +144,51 @@ class CustomerTest extends AbstractEntityTestCase
$sut->setCurrency(null);
self::assertNull($sut->getCurrency());
$sut->setBuyerReference('BR-876876876876');
self::assertEquals('BR-876876876876', $sut->getBuyerReference());
$sut->setBuyerReference(null);
self::assertNull($sut->getBuyerReference());
$sut->setAddressLine1('address line 1');
$sut->setAddressLine2('address line 2');
$sut->setAddressLine3('address line 3');
$sut->setCity('looney toon');
$sut->setPostcode('zip 12345');
$sut->setAddress('foo bar
sdfsadf
sdfarwt34
786876 uitiutziuz');
self::assertEquals('address line 1', $sut->getAddressLine1());
self::assertEquals('address line 2', $sut->getAddressLine2());
self::assertEquals('address line 3', $sut->getAddressLine3());
self::assertEquals('looney toon', $sut->getCity());
self::assertEquals('zip 12345', $sut->getPostCode());
self::assertEquals('foo bar
sdfsadf
sdfarwt34
786876 uitiutziuz', $sut->getAddress());
self::assertEquals('address line 1
address line 2
address line 3
zip 12345 looney toon', $sut->getFormattedAddress());
$sut->setAddressLine1(null);
$sut->setAddressLine2(null);
$sut->setAddressLine3(null);
$sut->setCity(null);
$sut->setPostcode(null);
$sut->setAddress(null);
self::assertNull($sut->getAddress());
self::assertNull($sut->getAddressLine1());
self::assertNull($sut->getAddressLine2());
self::assertNull($sut->getAddressLine3());
self::assertNull($sut->getFormattedAddress());
self::assertNull($sut->getCity());
self::assertNull($sut->getPostCode());
}
public function testMetaFields(): void
@@ -212,6 +264,11 @@ class CustomerTest extends AbstractEntityTestCase
['mobile', 'string'],
['fax', 'string'],
['homepage', 'string'],
['address_line1', 'string'],
['address_line2', 'string'],
['address_line3', 'string'],
['postcode', 'string'],
['city', 'string'],
['country', 'string'],
['currency', 'string'],
['timezone', 'string'],
@@ -222,6 +279,7 @@ class CustomerTest extends AbstractEntityTestCase
['visible', 'boolean'],
['comment', 'string'],
['billable', 'boolean'],
['buyer_reference', 'string'],
];
self::assertCount(\count($expected), $columns);

View File

@@ -0,0 +1,40 @@
<?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\Entity;
use App\Entity\EntityWithMetaFields;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(InvoiceTemplateMeta::class)]
class InvoiceTemplateMetaTest extends AbstractMetaEntityTestCase
{
protected function getEntity(): EntityWithMetaFields
{
return new InvoiceTemplate();
}
protected function getMetaEntity(): MetaTableTypeInterface
{
return new InvoiceTemplateMeta();
}
public function testSetEntityThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected instanceof InvoiceTemplate, received "App\Entity\Timesheet"');
$sut = new InvoiceTemplateMeta();
$sut->setEntity(new Timesheet());
}
}

View File

@@ -9,7 +9,11 @@
namespace App\Tests\Entity;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\InvoiceTemplate;
use App\Entity\InvoiceTemplateMeta;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -35,59 +39,118 @@ class InvoiceTemplateTest extends TestCase
self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays());
self::assertTrue($sut->isDecimalDuration()); // @phpstan-ignore method.deprecated
}
public function testSetNullForOptionalValues(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
self::assertEquals(0, $sut->getMetaFields()->count());
self::assertNull($sut->getMetaField('foo'));
}
public function testSetterAndGetter(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms('foo bar'));
$sut->setPaymentTerms('foo bar');
self::assertEquals('foo bar', $sut->getPaymentTerms());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf'));
$sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf');
self::assertEquals('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf', $sut->getPaymentDetails());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact('hello world'));
$sut->setContact('hello world');
self::assertEquals('hello world', $sut->getContact());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVat(7.31));
$sut->setVat(7.31);
self::assertEquals(7.31, $sut->getVat());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
$sut->setVatId('1234567890'); // @phpstan-ignore method.deprecated
self::assertEquals('1234567890', $sut->getVatId());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
$sut->setLanguage('de');
self::assertEquals('de', $sut->getLanguage());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setNumberGenerator('foo'));
$sut->setNumberGenerator('foo');
self::assertEquals('foo', $sut->getNumberGenerator());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setRenderer('bar'));
$sut->setRenderer('bar');
self::assertEquals('bar', $sut->getRenderer());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setCalculator('fooBar'));
$sut->setCalculator('fooBar');
self::assertEquals('fooBar', $sut->getCalculator());
$sut->setCompany('looney toon'); // @phpstan-ignore method.deprecated
self::assertEquals('looney toon', $sut->getCompany());
$sut->setAddress('acme street, 1234 looney town, rainbow'); // @phpstan-ignore method.deprecated
self::assertEquals('acme street, 1234 looney town, rainbow', $sut->getAddress());
self::assertEquals($sut, clone $sut);
$customer = new Customer('foo');
$customer->setVatId('0987654321');
$customer->setCompany('bar');
$customer->setAddressLine1('elmstreet');
$customer->setAddressLine2('2nd floor');
$customer->setPostCode('4711');
$customer->setCity('Over the rainbow');
$sut->setCustomer($customer);
self::assertEquals('elmstreet
2nd floor
4711 Over the rainbow', $sut->getAddress());
self::assertEquals('bar', $sut->getCompany());
self::assertEquals('0987654321', $sut->getVatId());
}
public function testToString(): void
{
$sut = new InvoiceTemplate();
self::assertInstanceOf(InvoiceTemplate::class, $sut->setName('a template name'));
$sut->setName('a template name');
self::assertEquals('a template name', $sut->__toString());
self::assertEquals('a template name', (string) $sut);
}
public function testMetaFields(): void
{
$sut = new InvoiceTemplate();
$meta = new InvoiceTemplateMeta();
$meta->setName('foo')->setValue('bar2')->setType('test');
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test', $result->getType());
self::assertEquals('bar2', $result->getValue());
$meta2 = new InvoiceTemplateMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2');
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields());
$result = $sut->getMetaField('foo');
self::assertSame($result, $meta);
self::assertEquals('test2', $result->getType());
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blub')->setIsVisible(true));
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blab')->setIsVisible(true));
self::assertEquals(3, $sut->getMetaFields()->count());
self::assertCount(2, $sut->getVisibleMetaFields());
}
public function testThrowsOnMetaFieldWithoutName(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Meta-field needs to have a name');
$sut = new InvoiceTemplate();
$sut->setMetaField(new CustomerMeta());
}
public function testThrowsOnMetaFieldsWrongType(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Meta-field needs to be an instanceof InvoiceTemplateMeta');
$sut = new InvoiceTemplate();
$meta = new CustomerMeta();
$meta->setName('foo');
$sut->setMetaField($meta);
}
}

View File

@@ -0,0 +1,26 @@
<?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\Event;
use App\Entity\InvoiceTemplate;
use App\Event\InvoiceTemplateMetaDefinitionEvent;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(InvoiceTemplateMetaDefinitionEvent::class)]
class InvoiceTemplateMetaDefinitionEventTest extends TestCase
{
public function testGetterAndSetter(): void
{
$invoiceTemplate = new InvoiceTemplate();
$sut = new InvoiceTemplateMetaDefinitionEvent($invoiceTemplate);
self::assertSame($invoiceTemplate, $sut->getEntity());
}
}

View File

@@ -52,6 +52,13 @@ class CustomerExporterTest extends TestCase
$customer->setMetaField((new CustomerMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
$customer->setMetaField((new CustomerMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
$customer->setMetaField((new CustomerMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
$customer->setCountry('AT');
$customer->setAddressLine1('1234 Main Road');
$customer->setAddressLine2('Third Floor');
$customer->setAddressLine3('Golden Office Tower');
$customer->setCity('Acme City');
$customer->setPostCode('MT 543.6');
$customer->setBuyerReference('BR-0987654321');
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
$spreadsheet = $sut->export(Customer::class, [$customer], new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT));
@@ -71,7 +78,12 @@ class CustomerExporterTest extends TestCase
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // mobile
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // fax
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // homepage
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // country
self::assertEquals('1234 Main Road', $worksheet->getCell([++$i, 2])->getValue()); // address_line1
self::assertEquals('Third Floor', $worksheet->getCell([++$i, 2])->getValue()); // address_line2
self::assertEquals('Golden Office Tower', $worksheet->getCell([++$i, 2])->getValue()); // address_line3
self::assertEquals('MT 543.6', $worksheet->getCell([++$i, 2])->getValue()); // postcode
self::assertEquals('Acme City', $worksheet->getCell([++$i, 2])->getValue()); // city
self::assertEquals('AT', $worksheet->getCell([++$i, 2])->getValue()); // country
self::assertEquals('EUR', $worksheet->getCell([++$i, 2])->getValue()); // currency
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // timezone
self::assertEquals('123456.789', $worksheet->getCell([++$i, 2])->getValue()); // budget
@@ -81,6 +93,7 @@ class CustomerExporterTest extends TestCase
self::assertFalse($worksheet->getCell([++$i, 2])->getValue()); // visible
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue()); // comment
self::assertTrue($worksheet->getCell([++$i, 2])->getValue()); // billable
self::assertEquals('BR-0987654321', $worksheet->getCell([++$i, 2])->getValue()); // buyer reference
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
}

View File

@@ -17,6 +17,7 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceModel;
use App\Invoice\TaxRow;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use App\Tests\Mocks\InvoiceModelFactoryFactory;
@@ -43,11 +44,21 @@ abstract class AbstractCalculatorTestCase extends TestCase
$sut->setModel($model);
self::assertEquals(0, $sut->getTotal());
self::assertEquals(0, $sut->getVat());
self::assertEquals(0, $sut->getSubtotal());
self::assertEquals(0, $sut->getTimeWorked());
self::assertEquals(0, \count($sut->getEntries()));
self::assertEquals(0, $sut->getTax());
self::assertTax($sut, 0);
}
protected function assertTax(CalculatorInterface $sut, int $rate): void
{
self::assertEquals($rate, $sut->getVat()); // @phpstan-ignore method.deprecated
$rows = $sut->getTaxRows();
self::assertCount(1, $rows);
self::assertInstanceOf(TaxRow::class, $rows[0]);
self::assertEquals($rate, $rows[0]->getTax()->getRate());
}
private function getEmptyModel(): InvoiceModel

View File

@@ -138,7 +138,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('activity', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -150,7 +150,7 @@ class ActivityUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('activity_user', $sut->getId());
self::assertEquals(3100.09, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2605.12, $sut->getSubtotal());
self::assertEquals(7000, $sut->getTimeWorked());

View File

@@ -114,7 +114,7 @@ class DateInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('date', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class DateUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('date_user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -72,7 +72,7 @@ class DefaultCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('default', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());

View File

@@ -117,7 +117,7 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('price', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -113,7 +113,7 @@ class ProjectInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('project', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class ProjectUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('project_user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -95,7 +95,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('short', $sut->getId());
self::assertEquals(562.28, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(472.5, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());
@@ -171,7 +171,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('short', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());
@@ -241,7 +241,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('short', $sut->getId());
self::assertEquals(581.17, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(488.38, $sut->getSubtotal());
self::assertEquals(5800, $sut->getTimeWorked());

View File

@@ -110,7 +110,7 @@ class UserInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('user', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -116,7 +116,7 @@ class WeeklyInvoiceCalculatorTest extends AbstractCalculatorTestCase
self::assertEquals('weekly', $sut->getId());
self::assertEquals(3000.13, $sut->getTotal());
self::assertEquals(19, $sut->getVat());
$this->assertTax($sut, 19);
self::assertEquals('EUR', $model->getCurrency());
self::assertEquals(2521.12, $sut->getSubtotal());
self::assertEquals(6600, $sut->getTimeWorked());

View File

@@ -36,6 +36,12 @@ class InvoiceModelCustomerHydratorTest extends TestCase
self::assertEquals([
'customer.id' => null,
'customer.address' => "Foo\nStreet\n1111 City",
'customer.address_line1' => '',
'customer.address_line2' => '',
'customer.address_line3' => '',
'customer.buyer_reference' => '',
'customer.city' => '',
'customer.postcode' => '',
'customer.name' => 'customer,with/special#name',
'customer.contact' => '',
'customer.company' => '',
@@ -43,6 +49,7 @@ class InvoiceModelCustomerHydratorTest extends TestCase
'customer.vat_id' => '',
'customer.number' => '',
'customer.country' => 'AT',
'customer.country_name' => 'Austria',
'customer.homepage' => '',
'customer.comment' => '',
'customer.email' => '',
@@ -63,12 +70,19 @@ class InvoiceModelCustomerHydratorTest extends TestCase
$keys = [
'customer.id',
'customer.address',
'customer.address_line1',
'customer.address_line2',
'customer.address_line3',
'customer.buyer_reference',
'customer.city',
'customer.postcode',
'customer.name',
'customer.contact',
'customer.company',
'customer.vat',
'customer.vat_id',
'customer.country',
'customer.country_name',
'customer.number',
'customer.homepage',
'customer.comment',

View File

@@ -33,12 +33,12 @@ class InvoiceModelDefaultHydratorTest extends TestCase
public function testHydrateThrowsOnMissing(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('InvoiceModel needs a template');
$this->expectExceptionMessage('InvoiceModel needs a calculator');
$model = $this->getInvoiceModel();
$obj = new ReflectionObject($model);
$prop = $obj->getProperty('template');
$prop = $obj->getProperty('calculator');
$prop->setAccessible(true);
$prop->setValue($model, null);
@@ -63,6 +63,7 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'invoice.tax_hide',
'invoice.tax_nc',
'invoice.tax_plain',
'invoice.tax_rows',
'invoice.total_time',
'invoice.duration_decimal',
'invoice.first',

View File

@@ -28,8 +28,6 @@ class InvoiceModelTest extends TestCase
$sut = (new InvoiceModelFactoryFactory($this))->create()->createModel($formatter, new Customer('foo'), new InvoiceTemplate(), new InvoiceQuery());
self::assertNotNull($sut->getQuery());
self::assertNotNull($sut->getCustomer());
self::assertNotNull($sut->getTemplate());
self::assertFalse($sut->isPreview());
self::assertFalse($sut->isHideZeroTax());

View File

@@ -96,6 +96,7 @@ class DebugRendererTest extends TestCase
'invoice.language',
'invoice.tax_nc',
'invoice.tax_plain',
'invoice.tax_rows',
'invoice.total_time',
'invoice.duration_decimal',
'invoice.first',
@@ -137,12 +138,19 @@ class DebugRendererTest extends TestCase
'query.end_year',
'customer.id',
'customer.address',
'customer.address_line1',
'customer.address_line2',
'customer.address_line3',
'customer.buyer_reference',
'customer.city',
'customer.postcode',
'customer.name',
'customer.contact',
'customer.company',
'customer.vat',
'customer.vat_id',
'customer.country',
'customer.country_name',
'customer.number',
'customer.homepage',
'customer.comment',

View File

@@ -95,6 +95,7 @@ trait RendererTestTrait
$template->setTitle('a very *long* test invoice / template title with [ßpecial] chäracter');
$template->setVat(19);
$template->setLanguage('en');
$template->setCustomer($customer);
$pMeta = new ProjectMeta();
$pMeta->setName('foo-project')->setValue('bar-project')->setIsVisible(true);
@@ -279,6 +280,7 @@ trait RendererTestTrait
$template->setTitle('a test invoice template title');
$template->setVat(19);
$template->setLanguage('it');
$template->setCustomer($customer);
$project = new Project();
$project->setName('project name');

View File

@@ -59,7 +59,7 @@ class TwigRendererTest extends KernelTestCase
$sut = new TwigRenderer($twig);
$model = $this->getInvoiceModel();
$model->getTemplate()?->setLanguage('de');
$model->getTemplate()->setLanguage('de');
$document = $this->getInvoiceDocument('timesheet.html.twig');
$response = $sut->render($document, $model);
@@ -118,7 +118,7 @@ class TwigRendererTest extends KernelTestCase
$sut = new TwigRenderer($twig);
$model = $this->getInvoiceModel();
$model->getTemplate()?->setLanguage('de');
$model->getTemplate()->setLanguage('de');
foreach ($files as $filename) {
$document = new InvoiceDocument(new \SplFileInfo($filename));

View File

@@ -19,14 +19,20 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
#[Group('integration')]
class MPdfConverterTest extends KernelTestCase
{
public function test()
public function testConvertToPdf(): void
{
$kernel = self::bootKernel();
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
$options = [
'foo' => 'bar',
'additional_xmp_rdf' => '<rdf:Description rdf:about="" xmlns:zf="urn:ferd:pdfa:CrossIndustryDocument:invoice:1p0#"></rdf:Description>',
'margin_top' => 2,
];
$sut = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir);
$result = $sut->convertToPdf('<h1>Test</h1>');
// Yeah, thats not a real test, I know ;-)
$result = $sut->convertToPdf('<h1>Test</h1>', $options);
// Yeah, that's not a real test, I know ;-)
self::assertNotEmpty($result);
preg_match('/\/Creator \((.*)\)/', $result, $matches);
self::assertCount(2, $matches);

View File

@@ -16,7 +16,7 @@ use PHPUnit\Framework\TestCase;
#[CoversClass(JavascriptFormatConverter::class)]
class JavascriptFormatConverterTest extends TestCase
{
public function test()
public function testConvert(): void
{
$sut = new JavascriptFormatConverter();
self::assertEquals('DD.MM.YYYY HH:mm', $sut->convert('dd.MM.y HH:mm'));

View File

@@ -1721,11 +1721,6 @@ parameters:
count: 1
path: Invoice/Renderer/XlsxRendererTest.php
-
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1
path: Invoice/ServiceInvoiceTest.php
-
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
count: 4
@@ -1855,11 +1850,6 @@ parameters:
count: 1
path: Model/Statistic/MonthTest.php
-
message: "#^Method App\\\\Tests\\\\Pdf\\\\MPdfConverterTest\\:\\:test\\(\\) has no return type specified\\.$#"
count: 1
path: Pdf/MPdfConverterTest.php
-
message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#"
count: 1
@@ -2685,11 +2675,6 @@ parameters:
count: 1
path: Utils/FormFormatConverterTest.php
-
message: "#^Method App\\\\Tests\\\\Utils\\\\JavascriptFormatConverterTest\\:\\:test\\(\\) has no return type specified\\.$#"
count: 1
path: Utils/JavascriptFormatConverterTest.php
-
message: "#^Method App\\\\Tests\\\\Utils\\\\ProfileManagerTest\\:\\:getCookieProfiles\\(\\) has no return type specified\\.$#"
count: 1

View File

@@ -1952,7 +1952,11 @@
</trans-unit>
<trans-unit id="nsbR30p" resname="sending_company">
<source>sending_company</source>
<target>Rechnungsabsender</target>
<target>Absender der Rechnung</target>
</trans-unit>
<trans-unit id="BmDRYT." resname="sending_company.help">
<source>sending_company.help</source>
<target>Dieses Unternehmen wird auf der Rechnung als Aussteller angegeben. Der Name, die Adresse und die Steuerinformationen werden verwendet.</target>
</trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
@@ -1986,6 +1990,58 @@
<source>font</source>
<target>Schriftart</target>
</trans-unit>
<trans-unit id="PciSBv9" resname="address_row">
<source>address_row</source>
<target>Zeile %row%</target>
</trans-unit>
<trans-unit id="_Ep8Ogx" resname="buyerReference">
<source>buyerReference</source>
<target>Bestellreferenz</target>
</trans-unit>
<trans-unit id="nPJWKwW" resname="payment_type">
<source>payment_type</source>
<target>Zahlart</target>
</trans-unit>
<trans-unit id="uXB48Vj" resname="SEPA Credit Transfer">
<source>SEPA Credit Transfer</source>
<target>SEPA-Überweisung</target>
</trans-unit>
<trans-unit id="s42A2gm" resname="SEPA Direct Debit">
<source>SEPA Direct Debit</source>
<target>SEPA-Lastschrift</target>
</trans-unit>
<trans-unit id="4toQj2M" resname="Cash">
<source>Cash</source>
<target>Bar</target>
</trans-unit>
<trans-unit id="cWZZc6D" resname="Cheque">
<source>Cheque</source>
<target>Check</target>
</trans-unit>
<trans-unit id="jMvh7bn" resname="Credit Transfer">
<source>Credit Transfer</source>
<target>Überweisung</target>
</trans-unit>
<trans-unit id="TJQbc0a" resname="Payment To Bank Account">
<source>Payment To Bank Account</source>
<target>Zahlung auf Bankkonto</target>
</trans-unit>
<trans-unit id=".UIt099" resname="Card Payment">
<source>Card Payment</source>
<target>Kartenzahlung</target>
</trans-unit>
<trans-unit id="Wnb_qF0" resname="Direct Debit">
<source>Direct Debit</source>
<target>Lastschrift</target>
</trans-unit>
<trans-unit id="rIdfGxM" resname="payment_account_name">
<source>payment_account_name</source>
<target>Konto- oder Karteninhaber</target>
</trans-unit>
<trans-unit id="jcT9Og6" resname="payment_account_number">
<source>payment_account_name</source>
<target>IBAN oder Kartennummer</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1952,7 +1952,11 @@
</trans-unit>
<trans-unit id="nsbR30p" resname="sending_company">
<source>sending_company</source>
<target>Invoice Sender</target>
<target>Invoice issuer</target>
</trans-unit>
<trans-unit id="BmDRYT." resname="sending_company.help">
<source>sending_company.help</source>
<target>This company is shown as the issuer on the invoice. The name, address and tax information will be used.</target>
</trans-unit>
<trans-unit id="Uzll0ps" resname="Customer portal">
<source>Customer portal</source>
@@ -1986,6 +1990,58 @@
<source>font</source>
<target>Font</target>
</trans-unit>
<trans-unit id="PciSBv9" resname="address_row">
<source>address_row</source>
<target>Line %row%</target>
</trans-unit>
<trans-unit id="_Ep8Ogx" resname="buyerReference">
<source>buyerReference</source>
<target>Buyer reference</target>
</trans-unit>
<trans-unit id="nPJWKwW" resname="payment_type">
<source>payment_type</source>
<target>Payment type</target>
</trans-unit>
<trans-unit id="uXB48Vj" resname="SEPA Credit Transfer">
<source>SEPA Credit Transfer</source>
<target>SEPA Credit Transfer</target>
</trans-unit>
<trans-unit id="s42A2gm" resname="SEPA Direct Debit">
<source>SEPA Direct Debit</source>
<target>SEPA Direct Debit</target>
</trans-unit>
<trans-unit id="4toQj2M" resname="Cash">
<source>Cash</source>
<target>Cash</target>
</trans-unit>
<trans-unit id="cWZZc6D" resname="Cheque">
<source>Cheque</source>
<target>Cheque</target>
</trans-unit>
<trans-unit id="jMvh7bn" resname="Credit Transfer">
<source>Credit Transfer</source>
<target>Credit Transfer</target>
</trans-unit>
<trans-unit id="TJQbc0a" resname="Payment To Bank Account">
<source>Payment To Bank Account</source>
<target>Payment To Bank Account</target>
</trans-unit>
<trans-unit id=".UIt099" resname="Card Payment">
<source>Card Payment</source>
<target>Card Payment</target>
</trans-unit>
<trans-unit id="Wnb_qF0" resname="Direct Debit">
<source>Direct Debit</source>
<target>Direct Debit</target>
</trans-unit>
<trans-unit id="rIdfGxM" resname="payment_account_name">
<source>payment_account_name</source>
<target>Account or card holder</target>
</trans-unit>
<trans-unit id="jcT9Og6" resname="payment_account_number">
<source>payment_account_name</source>
<target>IBAN or card number</target>
</trans-unit>
</body>
</file>
</xliff>