Meta-fields for InvoiceTemplate, structured Customer address (#5519)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
49
src/Entity/InvoiceTemplateMeta.php
Normal file
49
src/Entity/InvoiceTemplateMeta.php
Normal 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
36
src/Entity/Tax.php
Normal 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
17
src/Entity/TaxType.php
Normal 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';
|
||||
}
|
||||
28
src/Event/InvoiceTemplateMetaDefinitionEvent.php
Normal file
28
src/Event/InvoiceTemplateMetaDefinitionEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
39
src/Invoice/TaxRow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user