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

@@ -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)
{
}