Meta-fields for InvoiceTemplate, structured Customer address (#5519)
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"horstoeko/zugferd": "^1.0",
|
||||
"jms/serializer-bundle": "^5.0",
|
||||
"kevinpapst/tabler-bundle": "^1.4",
|
||||
"league/csv": "^9.4",
|
||||
|
||||
1221
composer.lock
generated
1221
composer.lock
generated
File diff suppressed because it is too large
Load Diff
46
migrations/Version20251031142000.php
Normal file
46
migrations/Version20251031142000.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.41
|
||||
*/
|
||||
final class Version20251031142000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add dedicated address fields for customer and customer-field for invoice template';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_customers ADD address_line1 VARCHAR(150) DEFAULT NULL, ADD address_line2 VARCHAR(150) DEFAULT NULL, ADD address_line3 VARCHAR(150) DEFAULT NULL, ADD postcode VARCHAR(20) DEFAULT NULL, ADD city VARCHAR(50) DEFAULT NULL, ADD buyer_reference VARCHAR(50) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD customer_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates MODIFY company VARCHAR(255) NULL');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates ADD CONSTRAINT FK_1626CFE99395C3F3 FOREIGN KEY (customer_id) REFERENCES kimai2_customers (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates (customer_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP FOREIGN KEY FK_1626CFE99395C3F3');
|
||||
$this->addSql('DROP INDEX IDX_1626CFE99395C3F3 ON kimai2_invoice_templates');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates DROP customer_id');
|
||||
$this->addSql('ALTER TABLE kimai2_customers DROP address_line1, DROP address_line2, DROP address_line3, DROP postcode, DROP city, DROP buyer_reference');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
43
migrations/Version20251031143000.php
Normal file
43
migrations/Version20251031143000.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 2.41
|
||||
*/
|
||||
final class Version20251031143000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create the invoice template meta table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE kimai2_invoice_templates_meta (id INT AUTO_INCREMENT NOT NULL, template_id INT NOT NULL, name VARCHAR(50) NOT NULL, value TEXT DEFAULT NULL, visible TINYINT(1) DEFAULT 0 NOT NULL, INDEX IDX_A165B0555DA0FB8 (template_id), UNIQUE INDEX UNIQ_A165B0555DA0FB85E237E06 (template_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE kimai2_invoice_templates_meta ADD CONSTRAINT FK_A165B0555DA0FB8 FOREIGN KEY (template_id) REFERENCES kimai2_invoice_templates (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$table = $schema->getTable('kimai2_invoice_templates_meta');
|
||||
$table->removeForeignKey('FK_A165B0555DA0FB8');
|
||||
|
||||
$schema->dropTable('kimai2_invoice_templates_meta');
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
74
phpstan.neon
74
phpstan.neon
@@ -41,6 +41,8 @@ parameters:
|
||||
symfony:
|
||||
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
|
||||
ignoreErrors:
|
||||
- identifier: throws.unusedType
|
||||
- identifier: offsetAccess.notFound
|
||||
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
|
||||
|
||||
-
|
||||
@@ -753,11 +755,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/InvoiceController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/InvoiceController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on Symfony\\\\Component\\\\Form\\\\FormInterface\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -804,7 +801,7 @@ parameters:
|
||||
path: src/Controller/ProjectController.php
|
||||
|
||||
-
|
||||
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\: DateTime, entry\\: App\\\\Entity\\\\Timesheet\\} will always evaluate to true\\.$#"
|
||||
message: "#^Call to function array_key_exists\\(\\) with 'entry' and array\\{day\\?\\: mixed, entry\\: mixed} will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/QuickEntryController.php
|
||||
|
||||
@@ -983,11 +980,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\InvoiceFixtures\\:\\:getInvoiceConfigs\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/InvoiceFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\TeamFixtures\\:\\:getGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -2763,11 +2755,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/UserTwoFactorType.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVat\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Calculator/AbstractCalculator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -2818,31 +2805,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelCustomerHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getSubtotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTax\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimeWorked\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVat\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -2873,16 +2835,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Hydrator/InvoiceModelUserHydrator.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/InvoiceFilename.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/InvoiceFilename.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$filename of method App\\\\Invoice\\\\InvoiceFilename\\:\\:convert\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -2928,11 +2880,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, mixed given\\.$#"
|
||||
count: 5
|
||||
@@ -2953,11 +2900,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractTwigRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractTwigRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Invoice\\\\Renderer\\\\AbstractTwigRenderer\\:\\:renderTwigTemplate\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -2983,11 +2925,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Invoice/ServiceInvoice.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getRenderer\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 6
|
||||
path: src/Invoice/ServiceInvoice.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method hasInvoiceTemplate\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -4207,3 +4144,8 @@ parameters:
|
||||
message: "#^Method App\\\\Form\\\\MultiUpdate\\\\MultiUpdateTableDTO\\:\\:setEntities\\(\\) has parameter \\$entities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection but does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Form/MultiUpdate/MultiUpdateTableDTO.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\InvoiceTemplate\\:\\:getMetaFields\\(\\) return type with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
path: src/Entity/InvoiceTemplate.php
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,31 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if customer.addressLine1 is not empty %}
|
||||
<tr>
|
||||
<th>{{ 'address'|trans }}</th>
|
||||
<td>
|
||||
{{ customer.addressLine1 }}<br>
|
||||
{% if customer.addressLine2 is not empty %}
|
||||
{{ customer.addressLine2 }}<br>
|
||||
{% endif %}
|
||||
{% if customer.addressLine3 is not empty %}
|
||||
{{ customer.addressLine3 }}<br>
|
||||
{% endif %}
|
||||
{{ customer.postCode }} {{ customer.city }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if customer.address is not empty %}
|
||||
<tr>
|
||||
<th>{{ 'address'|trans }}</th>
|
||||
<td>{{ customer.address|nl2br }}</td>
|
||||
<td>
|
||||
{{ widgets.alert('info', 'address_deprecated'|trans) }}
|
||||
<br>
|
||||
<i>
|
||||
{{ customer.address|nl2br }}
|
||||
</i>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if customer.country is not empty %}
|
||||
|
||||
@@ -18,28 +18,16 @@
|
||||
</div>
|
||||
</div>
|
||||
{{ form_row(form.comment) }}
|
||||
{{ form_row(form.address) }}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.company) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.number) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.vatId) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.country) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.currency) }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.timezone) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
@@ -87,21 +75,66 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_address_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'address'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ form_row(form.company) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.address is defined %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ form_row(form.address) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ form_row(form.address_line1, {attr: {placeholder: 'address_row'|trans({'%row%': 1})}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ form_row(form.address_line2, {attr: {placeholder: 'address_row'|trans({'%row%': 2})}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{{ form_row(form.address_line3, {attr: {placeholder: 'address_row'|trans({'%row%': 3})}}) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
{{ form_row(form.postcode) }}
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
{{ form_row(form.city) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.country) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.timezone) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'customer_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if form.buyerReference is defined %}
|
||||
{{ form_row(form.buyerReference) }}
|
||||
{% endif %}
|
||||
{% if form.invoiceTemplate is defined %}
|
||||
{{ form_row(form.invoiceTemplate) }}
|
||||
{% endif %}
|
||||
@@ -110,6 +143,14 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.visible) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -48,6 +48,10 @@ mpdf-->
|
||||
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong>
|
||||
<br>
|
||||
{{ invoice['customer.address']|nl2br }}
|
||||
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['customer.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
{% if invoice['customer.vat_id'] is not empty %}
|
||||
<br>
|
||||
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
|
||||
@@ -59,6 +63,12 @@ mpdf-->
|
||||
<strong>{{ invoice['template.company'] }}</strong>
|
||||
<br>
|
||||
{{ invoice['template.address']|trim|nl2br }}
|
||||
{% if invoice['template.country'] is not null %}
|
||||
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['template.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if invoice['template.vat_id'] is not empty %}
|
||||
<br>
|
||||
{{ 'vat_id'|trans }}:
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<address contenteditable="true">
|
||||
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
|
||||
{{ invoice['customer.address']|nl2br }}
|
||||
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['customer.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
{% if invoice['customer.vat_id'] is not empty %}
|
||||
<br>
|
||||
{{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
|
||||
@@ -45,6 +49,12 @@
|
||||
<address contenteditable="true">
|
||||
<strong>{{ invoice['template.company'] }}</strong><br>
|
||||
{{ invoice['template.address']|trim|nl2br }}
|
||||
{% if invoice['template.country'] is not null %}
|
||||
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['template.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if invoice['template.vat_id'] is not empty %}
|
||||
<br>
|
||||
{{ 'vat_id'|trans }}:
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
<tr>
|
||||
<td class="text-small">
|
||||
{{ invoice['template.company'] }} – {{ invoice['template.address']|nl2str(' – ') }}
|
||||
{% if invoice['template.country'] is not null %}
|
||||
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['template.address'] %}
|
||||
– {{ country }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if invoice['template.vat_id'] is not empty %}
|
||||
– {{ 'vat_id'|trans }}: {{ invoice['template.vat_id'] }}
|
||||
{% endif %}
|
||||
@@ -33,6 +39,12 @@
|
||||
<br>
|
||||
{{ invoice['template.company'] }}<br>
|
||||
{{ invoice['template.address']|nl2br }}
|
||||
{% if invoice['template.country'] is not null %}
|
||||
{% set country = invoice['template.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['template.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<strong>{{ 'invoice_bank_account'|trans }}</strong>
|
||||
@@ -54,6 +66,10 @@ mpdf-->
|
||||
<td>
|
||||
<strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
|
||||
{{ invoice['customer.address']|nl2br }}
|
||||
{% set country = invoice['customer.country']|country_name(invoice['invoice.language']) %}
|
||||
{% if country not in invoice['customer.address'] %}
|
||||
<br> {{ country }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% set classLeft = 'text-left' %}
|
||||
|
||||
@@ -18,25 +18,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.company) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.vatId) }}
|
||||
<div class="col-md-12">
|
||||
{{ form_row(form.customer) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.address) }}
|
||||
<div class="col-md-12">
|
||||
{{ form_row(form.paymentTerms) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.contact) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.paymentTerms) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.paymentDetails) }}
|
||||
</div>
|
||||
@@ -70,6 +64,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
<fieldset class="form-fieldset pb-0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{{ form_row(form.metaFields) }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -26,14 +26,6 @@
|
||||
{{ form_row(form.number) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.orderNumber) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.orderDate) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.start) }}
|
||||
@@ -66,6 +58,26 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.orderNumber) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.orderDate) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
{{ form_row(form.visible) }}
|
||||
@@ -77,18 +89,6 @@
|
||||
{{ form_row(form.globalActivities) }}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{{ form_row(form.metaFields) }}
|
||||
{% endif %}
|
||||
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'project_invoice_settings'} %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block title %}{{ 'invoices'|trans }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if form.invoiceText is defined %}
|
||||
{{ form_row(form.invoiceText) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -487,6 +487,12 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
|
||||
'timeBudget' => 'int',
|
||||
'vatId' => '@string', // since 1.10
|
||||
'budgetType' => '@string', // since 1.15
|
||||
'addressLine1' => '@string', // since 2.32
|
||||
'addressLine2' => '@string', // since 2.32
|
||||
'addressLine3' => '@string', // since 2.32
|
||||
'city' => '@string', // since 2.32
|
||||
'postCode' => '@string', // since 2.32
|
||||
'buyerReference' => '@string', // since 2.41
|
||||
];
|
||||
|
||||
// if a project is embedded
|
||||
|
||||
@@ -102,9 +102,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
|
||||
$form = $client->getCrawler()->filter('form[name=invoice_template_form]')->form();
|
||||
$client->submit($form, [
|
||||
'invoice_template_form' => [
|
||||
'name' => 'Test',
|
||||
'name' => 'FooBar Template',
|
||||
'title' => 'Test invoice template',
|
||||
'company' => 'Company name',
|
||||
'customer' => 1,
|
||||
'renderer' => 'default',
|
||||
'calculator' => 'default',
|
||||
'vat' => '27,937',
|
||||
@@ -117,9 +117,9 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$template = $this->getEntityManager()->getRepository(InvoiceTemplate::class)->findAll()[0];
|
||||
self::assertEquals('Test', $template->getName());
|
||||
self::assertEquals('FooBar Template', $template->getName());
|
||||
self::assertEquals('Test invoice template', $template->getTitle());
|
||||
self::assertEquals('Company name', $template->getCompany());
|
||||
self::assertEquals('Test', $template->getCompany());
|
||||
self::assertEquals('default', $template->getRenderer());
|
||||
self::assertEquals('default', $template->getCalculator());
|
||||
self::assertEquals('27.937', $template->getVat());
|
||||
@@ -145,8 +145,6 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
|
||||
self::assertEquals($template->getCalculator(), $values['calculator']);
|
||||
self::assertEquals($template->getVat(), $values['vat']);
|
||||
self::assertEquals($template->getRenderer(), $values['renderer']);
|
||||
self::assertEquals($template->getCompany(), $values['company']);
|
||||
self::assertEquals($template->getAddress(), $values['address']);
|
||||
self::assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
|
||||
}
|
||||
|
||||
@@ -408,7 +406,7 @@ class InvoiceControllerTest extends AbstractControllerBaseTestCase
|
||||
'invoice_template_form' => [
|
||||
'name' => 'Test 2!',
|
||||
'title' => 'Test invoice template',
|
||||
'company' => 'Company name',
|
||||
'customer' => 1,
|
||||
'renderer' => 'default',
|
||||
'calculator' => 'default',
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\DataFixtures;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Faker\Factory;
|
||||
@@ -27,10 +28,21 @@ class InvoiceTemplateFixtures implements TestFixture
|
||||
|
||||
$faker = Factory::create();
|
||||
|
||||
$customer = new Customer($faker->company());
|
||||
$customer->setCountry($faker->countryCode());
|
||||
$customer->setTimezone($faker->timezone());
|
||||
$customer->setCompany('Company name');
|
||||
$customer->setAddressLine1($faker->streetAddress());
|
||||
$customer->setAddressLine2($faker->streetAddress());
|
||||
$customer->setPostCode($faker->postcode());
|
||||
$customer->setCity($faker->city());
|
||||
|
||||
$manager->persist($customer);
|
||||
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setName('Invoice');
|
||||
$template->setTitle('Your company name');
|
||||
$template->setCompany($faker->company());
|
||||
$template->setCustomer($customer);
|
||||
$template->setVat(19);
|
||||
$template->setDueDays(14);
|
||||
$template->setPaymentTerms(
|
||||
@@ -39,9 +51,7 @@ class InvoiceTemplateFixtures implements TestFixture
|
||||
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
|
||||
'as reference.'
|
||||
);
|
||||
$template->setAddress(
|
||||
$faker->streetAddress() . PHP_EOL .
|
||||
$faker->city() . ' ' . $faker->postcode() . ', ' . $faker->country() . PHP_EOL .
|
||||
$template->setContact(
|
||||
'Phone: ' . $faker->phoneNumber() . PHP_EOL .
|
||||
'Email: ' . $faker->safeEmail()
|
||||
);
|
||||
|
||||
@@ -35,6 +35,13 @@ class CustomerTest extends AbstractEntityTestCase
|
||||
self::assertNull($sut->getVatId());
|
||||
self::assertNull($sut->getContact());
|
||||
self::assertNull($sut->getAddress());
|
||||
self::assertNull($sut->getAddressLine1());
|
||||
self::assertNull($sut->getAddressLine2());
|
||||
self::assertNull($sut->getAddressLine3());
|
||||
self::assertNull($sut->getFormattedAddress());
|
||||
self::assertNull($sut->getCity());
|
||||
self::assertNull($sut->getPostCode());
|
||||
self::assertNull($sut->getBuyerReference());
|
||||
self::assertNull($sut->getCountry());
|
||||
self::assertEquals('EUR', $sut->getCurrency());
|
||||
self::assertEquals('EUR', Customer::DEFAULT_CURRENCY);
|
||||
@@ -137,6 +144,51 @@ class CustomerTest extends AbstractEntityTestCase
|
||||
|
||||
$sut->setCurrency(null);
|
||||
self::assertNull($sut->getCurrency());
|
||||
|
||||
$sut->setBuyerReference('BR-876876876876');
|
||||
self::assertEquals('BR-876876876876', $sut->getBuyerReference());
|
||||
|
||||
$sut->setBuyerReference(null);
|
||||
self::assertNull($sut->getBuyerReference());
|
||||
|
||||
$sut->setAddressLine1('address line 1');
|
||||
$sut->setAddressLine2('address line 2');
|
||||
$sut->setAddressLine3('address line 3');
|
||||
$sut->setCity('looney toon');
|
||||
$sut->setPostcode('zip 12345');
|
||||
$sut->setAddress('foo bar
|
||||
sdfsadf
|
||||
sdfarwt34
|
||||
786876 uitiutziuz');
|
||||
|
||||
self::assertEquals('address line 1', $sut->getAddressLine1());
|
||||
self::assertEquals('address line 2', $sut->getAddressLine2());
|
||||
self::assertEquals('address line 3', $sut->getAddressLine3());
|
||||
self::assertEquals('looney toon', $sut->getCity());
|
||||
self::assertEquals('zip 12345', $sut->getPostCode());
|
||||
self::assertEquals('foo bar
|
||||
sdfsadf
|
||||
sdfarwt34
|
||||
786876 uitiutziuz', $sut->getAddress());
|
||||
self::assertEquals('address line 1
|
||||
address line 2
|
||||
address line 3
|
||||
zip 12345 looney toon', $sut->getFormattedAddress());
|
||||
|
||||
$sut->setAddressLine1(null);
|
||||
$sut->setAddressLine2(null);
|
||||
$sut->setAddressLine3(null);
|
||||
$sut->setCity(null);
|
||||
$sut->setPostcode(null);
|
||||
$sut->setAddress(null);
|
||||
|
||||
self::assertNull($sut->getAddress());
|
||||
self::assertNull($sut->getAddressLine1());
|
||||
self::assertNull($sut->getAddressLine2());
|
||||
self::assertNull($sut->getAddressLine3());
|
||||
self::assertNull($sut->getFormattedAddress());
|
||||
self::assertNull($sut->getCity());
|
||||
self::assertNull($sut->getPostCode());
|
||||
}
|
||||
|
||||
public function testMetaFields(): void
|
||||
@@ -212,6 +264,11 @@ class CustomerTest extends AbstractEntityTestCase
|
||||
['mobile', 'string'],
|
||||
['fax', 'string'],
|
||||
['homepage', 'string'],
|
||||
['address_line1', 'string'],
|
||||
['address_line2', 'string'],
|
||||
['address_line3', 'string'],
|
||||
['postcode', 'string'],
|
||||
['city', 'string'],
|
||||
['country', 'string'],
|
||||
['currency', 'string'],
|
||||
['timezone', 'string'],
|
||||
@@ -222,6 +279,7 @@ class CustomerTest extends AbstractEntityTestCase
|
||||
['visible', 'boolean'],
|
||||
['comment', 'string'],
|
||||
['billable', 'boolean'],
|
||||
['buyer_reference', 'string'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
||||
40
tests/Entity/InvoiceTemplateMetaTest.php
Normal file
40
tests/Entity/InvoiceTemplateMetaTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\EntityWithMetaFields;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\InvoiceTemplateMeta;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Timesheet;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
|
||||
#[CoversClass(InvoiceTemplateMeta::class)]
|
||||
class InvoiceTemplateMetaTest extends AbstractMetaEntityTestCase
|
||||
{
|
||||
protected function getEntity(): EntityWithMetaFields
|
||||
{
|
||||
return new InvoiceTemplate();
|
||||
}
|
||||
|
||||
protected function getMetaEntity(): MetaTableTypeInterface
|
||||
{
|
||||
return new InvoiceTemplateMeta();
|
||||
}
|
||||
|
||||
public function testSetEntityThrowsException(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Expected instanceof InvoiceTemplate, received "App\Entity\Timesheet"');
|
||||
|
||||
$sut = new InvoiceTemplateMeta();
|
||||
$sut->setEntity(new Timesheet());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\InvoiceTemplateMeta;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -35,59 +39,118 @@ class InvoiceTemplateTest extends TestCase
|
||||
self::assertEquals('default', $sut->getRenderer());
|
||||
self::assertEquals(30, $sut->getDueDays());
|
||||
self::assertTrue($sut->isDecimalDuration()); // @phpstan-ignore method.deprecated
|
||||
}
|
||||
|
||||
public function testSetNullForOptionalValues(): void
|
||||
{
|
||||
$sut = new InvoiceTemplate();
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails(null));
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId(null));
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
|
||||
self::assertInstanceOf(Collection::class, $sut->getMetaFields());
|
||||
self::assertEquals(0, $sut->getMetaFields()->count());
|
||||
self::assertNull($sut->getMetaField('foo'));
|
||||
}
|
||||
|
||||
public function testSetterAndGetter(): void
|
||||
{
|
||||
$sut = new InvoiceTemplate();
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms('foo bar'));
|
||||
$sut->setPaymentTerms('foo bar');
|
||||
self::assertEquals('foo bar', $sut->getPaymentTerms());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf'));
|
||||
$sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf');
|
||||
self::assertEquals('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf', $sut->getPaymentDetails());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact('hello world'));
|
||||
$sut->setContact('hello world');
|
||||
self::assertEquals('hello world', $sut->getContact());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVat(7.31));
|
||||
$sut->setVat(7.31);
|
||||
self::assertEquals(7.31, $sut->getVat());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
|
||||
$sut->setVatId('1234567890'); // @phpstan-ignore method.deprecated
|
||||
self::assertEquals('1234567890', $sut->getVatId());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
|
||||
$sut->setLanguage('de');
|
||||
self::assertEquals('de', $sut->getLanguage());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setNumberGenerator('foo'));
|
||||
$sut->setNumberGenerator('foo');
|
||||
self::assertEquals('foo', $sut->getNumberGenerator());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setRenderer('bar'));
|
||||
$sut->setRenderer('bar');
|
||||
self::assertEquals('bar', $sut->getRenderer());
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setCalculator('fooBar'));
|
||||
$sut->setCalculator('fooBar');
|
||||
self::assertEquals('fooBar', $sut->getCalculator());
|
||||
|
||||
$sut->setCompany('looney toon'); // @phpstan-ignore method.deprecated
|
||||
self::assertEquals('looney toon', $sut->getCompany());
|
||||
|
||||
$sut->setAddress('acme street, 1234 looney town, rainbow'); // @phpstan-ignore method.deprecated
|
||||
self::assertEquals('acme street, 1234 looney town, rainbow', $sut->getAddress());
|
||||
|
||||
self::assertEquals($sut, clone $sut);
|
||||
|
||||
$customer = new Customer('foo');
|
||||
$customer->setVatId('0987654321');
|
||||
$customer->setCompany('bar');
|
||||
$customer->setAddressLine1('elmstreet');
|
||||
$customer->setAddressLine2('2nd floor');
|
||||
$customer->setPostCode('4711');
|
||||
$customer->setCity('Over the rainbow');
|
||||
$sut->setCustomer($customer);
|
||||
|
||||
self::assertEquals('elmstreet
|
||||
2nd floor
|
||||
4711 Over the rainbow', $sut->getAddress());
|
||||
self::assertEquals('bar', $sut->getCompany());
|
||||
self::assertEquals('0987654321', $sut->getVatId());
|
||||
}
|
||||
|
||||
public function testToString(): void
|
||||
{
|
||||
$sut = new InvoiceTemplate();
|
||||
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setName('a template name'));
|
||||
$sut->setName('a template name');
|
||||
self::assertEquals('a template name', $sut->__toString());
|
||||
self::assertEquals('a template name', (string) $sut);
|
||||
}
|
||||
|
||||
public function testMetaFields(): void
|
||||
{
|
||||
$sut = new InvoiceTemplate();
|
||||
$meta = new InvoiceTemplateMeta();
|
||||
$meta->setName('foo')->setValue('bar2')->setType('test');
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta));
|
||||
self::assertEquals(1, $sut->getMetaFields()->count());
|
||||
$result = $sut->getMetaField('foo');
|
||||
self::assertSame($result, $meta);
|
||||
self::assertEquals('test', $result->getType());
|
||||
self::assertEquals('bar2', $result->getValue());
|
||||
|
||||
$meta2 = new InvoiceTemplateMeta();
|
||||
$meta2->setName('foo')->setValue('bar')->setType('test2');
|
||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setMetaField($meta2));
|
||||
self::assertEquals(1, $sut->getMetaFields()->count());
|
||||
self::assertCount(0, $sut->getVisibleMetaFields());
|
||||
|
||||
$result = $sut->getMetaField('foo');
|
||||
self::assertSame($result, $meta);
|
||||
self::assertEquals('test2', $result->getType());
|
||||
|
||||
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blub')->setIsVisible(true));
|
||||
$sut->setMetaField((new InvoiceTemplateMeta())->setName('blab')->setIsVisible(true));
|
||||
self::assertEquals(3, $sut->getMetaFields()->count());
|
||||
self::assertCount(2, $sut->getVisibleMetaFields());
|
||||
}
|
||||
|
||||
public function testThrowsOnMetaFieldWithoutName(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Meta-field needs to have a name');
|
||||
$sut = new InvoiceTemplate();
|
||||
$sut->setMetaField(new CustomerMeta());
|
||||
}
|
||||
|
||||
public function testThrowsOnMetaFieldsWrongType(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Meta-field needs to be an instanceof InvoiceTemplateMeta');
|
||||
$sut = new InvoiceTemplate();
|
||||
$meta = new CustomerMeta();
|
||||
$meta->setName('foo');
|
||||
$sut->setMetaField($meta);
|
||||
}
|
||||
}
|
||||
|
||||
26
tests/Event/InvoiceTemplateMetaDefinitionEventTest.php
Normal file
26
tests/Event/InvoiceTemplateMetaDefinitionEventTest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Event;
|
||||
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Event\InvoiceTemplateMetaDefinitionEvent;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(InvoiceTemplateMetaDefinitionEvent::class)]
|
||||
class InvoiceTemplateMetaDefinitionEventTest extends TestCase
|
||||
{
|
||||
public function testGetterAndSetter(): void
|
||||
{
|
||||
$invoiceTemplate = new InvoiceTemplate();
|
||||
$sut = new InvoiceTemplateMetaDefinitionEvent($invoiceTemplate);
|
||||
self::assertSame($invoiceTemplate, $sut->getEntity());
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,13 @@ class CustomerExporterTest extends TestCase
|
||||
$customer->setMetaField((new CustomerMeta())->setName('foo meta')->setValue('some magic')->setIsVisible(true));
|
||||
$customer->setMetaField((new CustomerMeta())->setName('hidden meta')->setValue('will not be seen')->setIsVisible(false));
|
||||
$customer->setMetaField((new CustomerMeta())->setName('bar meta')->setValue('is happening')->setIsVisible(true));
|
||||
$customer->setCountry('AT');
|
||||
$customer->setAddressLine1('1234 Main Road');
|
||||
$customer->setAddressLine2('Third Floor');
|
||||
$customer->setAddressLine3('Golden Office Tower');
|
||||
$customer->setCity('Acme City');
|
||||
$customer->setPostCode('MT 543.6');
|
||||
$customer->setBuyerReference('BR-0987654321');
|
||||
|
||||
$sut = new EntityWithMetaFieldsExporter($spreadsheetExporter, $annotationExtractor, $metaFieldExtractor);
|
||||
$spreadsheet = $sut->export(Customer::class, [$customer], new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT));
|
||||
@@ -71,7 +78,12 @@ class CustomerExporterTest extends TestCase
|
||||
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // mobile
|
||||
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // fax
|
||||
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // homepage
|
||||
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // country
|
||||
self::assertEquals('1234 Main Road', $worksheet->getCell([++$i, 2])->getValue()); // address_line1
|
||||
self::assertEquals('Third Floor', $worksheet->getCell([++$i, 2])->getValue()); // address_line2
|
||||
self::assertEquals('Golden Office Tower', $worksheet->getCell([++$i, 2])->getValue()); // address_line3
|
||||
self::assertEquals('MT 543.6', $worksheet->getCell([++$i, 2])->getValue()); // postcode
|
||||
self::assertEquals('Acme City', $worksheet->getCell([++$i, 2])->getValue()); // city
|
||||
self::assertEquals('AT', $worksheet->getCell([++$i, 2])->getValue()); // country
|
||||
self::assertEquals('EUR', $worksheet->getCell([++$i, 2])->getValue()); // currency
|
||||
self::assertEquals(null, $worksheet->getCell([++$i, 2])->getValue()); // timezone
|
||||
self::assertEquals('123456.789', $worksheet->getCell([++$i, 2])->getValue()); // budget
|
||||
@@ -81,6 +93,7 @@ class CustomerExporterTest extends TestCase
|
||||
self::assertFalse($worksheet->getCell([++$i, 2])->getValue()); // visible
|
||||
self::assertEquals('Lorem Ipsum', $worksheet->getCell([++$i, 2])->getValue()); // comment
|
||||
self::assertTrue($worksheet->getCell([++$i, 2])->getValue()); // billable
|
||||
self::assertEquals('BR-0987654321', $worksheet->getCell([++$i, 2])->getValue()); // buyer reference
|
||||
self::assertEquals('some magic', $worksheet->getCell([++$i, 2])->getValue());
|
||||
self::assertEquals('is happening', $worksheet->getCell([++$i, 2])->getValue());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Invoice\CalculatorInterface;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\TaxRow;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Tests\Invoice\DebugFormatter;
|
||||
use App\Tests\Mocks\InvoiceModelFactoryFactory;
|
||||
@@ -43,11 +44,21 @@ abstract class AbstractCalculatorTestCase extends TestCase
|
||||
$sut->setModel($model);
|
||||
|
||||
self::assertEquals(0, $sut->getTotal());
|
||||
self::assertEquals(0, $sut->getVat());
|
||||
self::assertEquals(0, $sut->getSubtotal());
|
||||
self::assertEquals(0, $sut->getTimeWorked());
|
||||
self::assertEquals(0, \count($sut->getEntries()));
|
||||
self::assertEquals(0, $sut->getTax());
|
||||
self::assertTax($sut, 0);
|
||||
}
|
||||
|
||||
protected function assertTax(CalculatorInterface $sut, int $rate): void
|
||||
{
|
||||
self::assertEquals($rate, $sut->getVat()); // @phpstan-ignore method.deprecated
|
||||
$rows = $sut->getTaxRows();
|
||||
self::assertCount(1, $rows);
|
||||
|
||||
self::assertInstanceOf(TaxRow::class, $rows[0]);
|
||||
self::assertEquals($rate, $rows[0]->getTax()->getRate());
|
||||
}
|
||||
|
||||
private function getEmptyModel(): InvoiceModel
|
||||
|
||||
@@ -138,7 +138,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('activity', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -150,7 +150,7 @@ class ActivityUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('activity_user', $sut->getId());
|
||||
self::assertEquals(3100.09, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2605.12, $sut->getSubtotal());
|
||||
self::assertEquals(7000, $sut->getTimeWorked());
|
||||
|
||||
@@ -114,7 +114,7 @@ class DateInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('date', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -116,7 +116,7 @@ class DateUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('date_user', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -72,7 +72,7 @@ class DefaultCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('default', $sut->getId());
|
||||
self::assertEquals(581.17, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(488.38, $sut->getSubtotal());
|
||||
self::assertEquals(5800, $sut->getTimeWorked());
|
||||
|
||||
@@ -117,7 +117,7 @@ class PriceInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('price', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -113,7 +113,7 @@ class ProjectInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('project', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -116,7 +116,7 @@ class ProjectUserInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('project_user', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -95,7 +95,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('short', $sut->getId());
|
||||
self::assertEquals(562.28, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(472.5, $sut->getSubtotal());
|
||||
self::assertEquals(5800, $sut->getTimeWorked());
|
||||
@@ -171,7 +171,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('short', $sut->getId());
|
||||
self::assertEquals(581.17, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(488.38, $sut->getSubtotal());
|
||||
self::assertEquals(5800, $sut->getTimeWorked());
|
||||
@@ -241,7 +241,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('short', $sut->getId());
|
||||
self::assertEquals(581.17, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(488.38, $sut->getSubtotal());
|
||||
self::assertEquals(5800, $sut->getTimeWorked());
|
||||
|
||||
@@ -110,7 +110,7 @@ class UserInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('user', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -116,7 +116,7 @@ class WeeklyInvoiceCalculatorTest extends AbstractCalculatorTestCase
|
||||
|
||||
self::assertEquals('weekly', $sut->getId());
|
||||
self::assertEquals(3000.13, $sut->getTotal());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
$this->assertTax($sut, 19);
|
||||
self::assertEquals('EUR', $model->getCurrency());
|
||||
self::assertEquals(2521.12, $sut->getSubtotal());
|
||||
self::assertEquals(6600, $sut->getTimeWorked());
|
||||
|
||||
@@ -36,6 +36,12 @@ class InvoiceModelCustomerHydratorTest extends TestCase
|
||||
self::assertEquals([
|
||||
'customer.id' => null,
|
||||
'customer.address' => "Foo\nStreet\n1111 City",
|
||||
'customer.address_line1' => '',
|
||||
'customer.address_line2' => '',
|
||||
'customer.address_line3' => '',
|
||||
'customer.buyer_reference' => '',
|
||||
'customer.city' => '',
|
||||
'customer.postcode' => '',
|
||||
'customer.name' => 'customer,with/special#name',
|
||||
'customer.contact' => '',
|
||||
'customer.company' => '',
|
||||
@@ -43,6 +49,7 @@ class InvoiceModelCustomerHydratorTest extends TestCase
|
||||
'customer.vat_id' => '',
|
||||
'customer.number' => '',
|
||||
'customer.country' => 'AT',
|
||||
'customer.country_name' => 'Austria',
|
||||
'customer.homepage' => '',
|
||||
'customer.comment' => '',
|
||||
'customer.email' => '',
|
||||
@@ -63,12 +70,19 @@ class InvoiceModelCustomerHydratorTest extends TestCase
|
||||
$keys = [
|
||||
'customer.id',
|
||||
'customer.address',
|
||||
'customer.address_line1',
|
||||
'customer.address_line2',
|
||||
'customer.address_line3',
|
||||
'customer.buyer_reference',
|
||||
'customer.city',
|
||||
'customer.postcode',
|
||||
'customer.name',
|
||||
'customer.contact',
|
||||
'customer.company',
|
||||
'customer.vat',
|
||||
'customer.vat_id',
|
||||
'customer.country',
|
||||
'customer.country_name',
|
||||
'customer.number',
|
||||
'customer.homepage',
|
||||
'customer.comment',
|
||||
|
||||
@@ -33,12 +33,12 @@ class InvoiceModelDefaultHydratorTest extends TestCase
|
||||
public function testHydrateThrowsOnMissing(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('InvoiceModel needs a template');
|
||||
$this->expectExceptionMessage('InvoiceModel needs a calculator');
|
||||
|
||||
$model = $this->getInvoiceModel();
|
||||
|
||||
$obj = new ReflectionObject($model);
|
||||
$prop = $obj->getProperty('template');
|
||||
$prop = $obj->getProperty('calculator');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($model, null);
|
||||
|
||||
@@ -63,6 +63,7 @@ class InvoiceModelDefaultHydratorTest extends TestCase
|
||||
'invoice.tax_hide',
|
||||
'invoice.tax_nc',
|
||||
'invoice.tax_plain',
|
||||
'invoice.tax_rows',
|
||||
'invoice.total_time',
|
||||
'invoice.duration_decimal',
|
||||
'invoice.first',
|
||||
|
||||
@@ -28,8 +28,6 @@ class InvoiceModelTest extends TestCase
|
||||
$sut = (new InvoiceModelFactoryFactory($this))->create()->createModel($formatter, new Customer('foo'), new InvoiceTemplate(), new InvoiceQuery());
|
||||
|
||||
self::assertNotNull($sut->getQuery());
|
||||
self::assertNotNull($sut->getCustomer());
|
||||
self::assertNotNull($sut->getTemplate());
|
||||
|
||||
self::assertFalse($sut->isPreview());
|
||||
self::assertFalse($sut->isHideZeroTax());
|
||||
|
||||
@@ -96,6 +96,7 @@ class DebugRendererTest extends TestCase
|
||||
'invoice.language',
|
||||
'invoice.tax_nc',
|
||||
'invoice.tax_plain',
|
||||
'invoice.tax_rows',
|
||||
'invoice.total_time',
|
||||
'invoice.duration_decimal',
|
||||
'invoice.first',
|
||||
@@ -137,12 +138,19 @@ class DebugRendererTest extends TestCase
|
||||
'query.end_year',
|
||||
'customer.id',
|
||||
'customer.address',
|
||||
'customer.address_line1',
|
||||
'customer.address_line2',
|
||||
'customer.address_line3',
|
||||
'customer.buyer_reference',
|
||||
'customer.city',
|
||||
'customer.postcode',
|
||||
'customer.name',
|
||||
'customer.contact',
|
||||
'customer.company',
|
||||
'customer.vat',
|
||||
'customer.vat_id',
|
||||
'customer.country',
|
||||
'customer.country_name',
|
||||
'customer.number',
|
||||
'customer.homepage',
|
||||
'customer.comment',
|
||||
|
||||
@@ -95,6 +95,7 @@ trait RendererTestTrait
|
||||
$template->setTitle('a very *long* test invoice / template title with [ßpecial] chäracter');
|
||||
$template->setVat(19);
|
||||
$template->setLanguage('en');
|
||||
$template->setCustomer($customer);
|
||||
|
||||
$pMeta = new ProjectMeta();
|
||||
$pMeta->setName('foo-project')->setValue('bar-project')->setIsVisible(true);
|
||||
@@ -279,6 +280,7 @@ trait RendererTestTrait
|
||||
$template->setTitle('a test invoice template title');
|
||||
$template->setVat(19);
|
||||
$template->setLanguage('it');
|
||||
$template->setCustomer($customer);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('project name');
|
||||
|
||||
@@ -59,7 +59,7 @@ class TwigRendererTest extends KernelTestCase
|
||||
$sut = new TwigRenderer($twig);
|
||||
|
||||
$model = $this->getInvoiceModel();
|
||||
$model->getTemplate()?->setLanguage('de');
|
||||
$model->getTemplate()->setLanguage('de');
|
||||
|
||||
$document = $this->getInvoiceDocument('timesheet.html.twig');
|
||||
$response = $sut->render($document, $model);
|
||||
@@ -118,7 +118,7 @@ class TwigRendererTest extends KernelTestCase
|
||||
$sut = new TwigRenderer($twig);
|
||||
|
||||
$model = $this->getInvoiceModel();
|
||||
$model->getTemplate()?->setLanguage('de');
|
||||
$model->getTemplate()->setLanguage('de');
|
||||
|
||||
foreach ($files as $filename) {
|
||||
$document = new InvoiceDocument(new \SplFileInfo($filename));
|
||||
|
||||
@@ -19,14 +19,20 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
#[Group('integration')]
|
||||
class MPdfConverterTest extends KernelTestCase
|
||||
{
|
||||
public function test()
|
||||
public function testConvertToPdf(): void
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
|
||||
|
||||
$options = [
|
||||
'foo' => 'bar',
|
||||
'additional_xmp_rdf' => '<rdf:Description rdf:about="" xmlns:zf="urn:ferd:pdfa:CrossIndustryDocument:invoice:1p0#"></rdf:Description>',
|
||||
'margin_top' => 2,
|
||||
];
|
||||
|
||||
$sut = new MPdfConverter((new FileHelperFactory($this))->create(), $cacheDir);
|
||||
$result = $sut->convertToPdf('<h1>Test</h1>');
|
||||
// Yeah, thats not a real test, I know ;-)
|
||||
$result = $sut->convertToPdf('<h1>Test</h1>', $options);
|
||||
// Yeah, that's not a real test, I know ;-)
|
||||
self::assertNotEmpty($result);
|
||||
preg_match('/\/Creator \((.*)\)/', $result, $matches);
|
||||
self::assertCount(2, $matches);
|
||||
|
||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\TestCase;
|
||||
#[CoversClass(JavascriptFormatConverter::class)]
|
||||
class JavascriptFormatConverterTest extends TestCase
|
||||
{
|
||||
public function test()
|
||||
public function testConvert(): void
|
||||
{
|
||||
$sut = new JavascriptFormatConverter();
|
||||
self::assertEquals('DD.MM.YYYY HH:mm', $sut->convert('dd.MM.y HH:mm'));
|
||||
|
||||
@@ -1721,11 +1721,6 @@ parameters:
|
||||
count: 1
|
||||
path: Invoice/Renderer/XlsxRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
|
||||
count: 1
|
||||
path: Invoice/ServiceInvoiceTest.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTotal\\(\\) on App\\\\Invoice\\\\CalculatorInterface\\|null\\.$#"
|
||||
count: 4
|
||||
@@ -1855,11 +1850,6 @@ parameters:
|
||||
count: 1
|
||||
path: Model/Statistic/MonthTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Pdf\\\\MPdfConverterTest\\:\\:test\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Pdf/MPdfConverterTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$cacheDirectory of class App\\\\Pdf\\\\MPdfConverter constructor expects string, array\\|bool\\|float\\|int\\|string\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -2685,11 +2675,6 @@ parameters:
|
||||
count: 1
|
||||
path: Utils/FormFormatConverterTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Utils\\\\JavascriptFormatConverterTest\\:\\:test\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Utils/JavascriptFormatConverterTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Utils\\\\ProfileManagerTest\\:\\:getCookieProfiles\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -1952,7 +1952,11 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="nsbR30p" resname="sending_company">
|
||||
<source>sending_company</source>
|
||||
<target>Rechnungsabsender</target>
|
||||
<target>Absender der Rechnung</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="BmDRYT." resname="sending_company.help">
|
||||
<source>sending_company.help</source>
|
||||
<target>Dieses Unternehmen wird auf der Rechnung als Aussteller angegeben. Der Name, die Adresse und die Steuerinformationen werden verwendet.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Uzll0ps" resname="Customer portal">
|
||||
<source>Customer portal</source>
|
||||
@@ -1986,6 +1990,58 @@
|
||||
<source>font</source>
|
||||
<target>Schriftart</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="PciSBv9" resname="address_row">
|
||||
<source>address_row</source>
|
||||
<target>Zeile %row%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="_Ep8Ogx" resname="buyerReference">
|
||||
<source>buyerReference</source>
|
||||
<target>Bestellreferenz</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="nPJWKwW" resname="payment_type">
|
||||
<source>payment_type</source>
|
||||
<target>Zahlart</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="uXB48Vj" resname="SEPA Credit Transfer">
|
||||
<source>SEPA Credit Transfer</source>
|
||||
<target>SEPA-Überweisung</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s42A2gm" resname="SEPA Direct Debit">
|
||||
<source>SEPA Direct Debit</source>
|
||||
<target>SEPA-Lastschrift</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4toQj2M" resname="Cash">
|
||||
<source>Cash</source>
|
||||
<target>Bar</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="cWZZc6D" resname="Cheque">
|
||||
<source>Cheque</source>
|
||||
<target>Check</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="jMvh7bn" resname="Credit Transfer">
|
||||
<source>Credit Transfer</source>
|
||||
<target>Überweisung</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="TJQbc0a" resname="Payment To Bank Account">
|
||||
<source>Payment To Bank Account</source>
|
||||
<target>Zahlung auf Bankkonto</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".UIt099" resname="Card Payment">
|
||||
<source>Card Payment</source>
|
||||
<target>Kartenzahlung</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Wnb_qF0" resname="Direct Debit">
|
||||
<source>Direct Debit</source>
|
||||
<target>Lastschrift</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rIdfGxM" resname="payment_account_name">
|
||||
<source>payment_account_name</source>
|
||||
<target>Konto- oder Karteninhaber</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="jcT9Og6" resname="payment_account_number">
|
||||
<source>payment_account_name</source>
|
||||
<target>IBAN oder Kartennummer</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1952,7 +1952,11 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="nsbR30p" resname="sending_company">
|
||||
<source>sending_company</source>
|
||||
<target>Invoice Sender</target>
|
||||
<target>Invoice issuer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="BmDRYT." resname="sending_company.help">
|
||||
<source>sending_company.help</source>
|
||||
<target>This company is shown as the issuer on the invoice. The name, address and tax information will be used.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Uzll0ps" resname="Customer portal">
|
||||
<source>Customer portal</source>
|
||||
@@ -1986,6 +1990,58 @@
|
||||
<source>font</source>
|
||||
<target>Font</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="PciSBv9" resname="address_row">
|
||||
<source>address_row</source>
|
||||
<target>Line %row%</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="_Ep8Ogx" resname="buyerReference">
|
||||
<source>buyerReference</source>
|
||||
<target>Buyer reference</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="nPJWKwW" resname="payment_type">
|
||||
<source>payment_type</source>
|
||||
<target>Payment type</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="uXB48Vj" resname="SEPA Credit Transfer">
|
||||
<source>SEPA Credit Transfer</source>
|
||||
<target>SEPA Credit Transfer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s42A2gm" resname="SEPA Direct Debit">
|
||||
<source>SEPA Direct Debit</source>
|
||||
<target>SEPA Direct Debit</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4toQj2M" resname="Cash">
|
||||
<source>Cash</source>
|
||||
<target>Cash</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="cWZZc6D" resname="Cheque">
|
||||
<source>Cheque</source>
|
||||
<target>Cheque</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="jMvh7bn" resname="Credit Transfer">
|
||||
<source>Credit Transfer</source>
|
||||
<target>Credit Transfer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="TJQbc0a" resname="Payment To Bank Account">
|
||||
<source>Payment To Bank Account</source>
|
||||
<target>Payment To Bank Account</target>
|
||||
</trans-unit>
|
||||
<trans-unit id=".UIt099" resname="Card Payment">
|
||||
<source>Card Payment</source>
|
||||
<target>Card Payment</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="Wnb_qF0" resname="Direct Debit">
|
||||
<source>Direct Debit</source>
|
||||
<target>Direct Debit</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="rIdfGxM" resname="payment_account_name">
|
||||
<source>payment_account_name</source>
|
||||
<target>Account or card holder</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="jcT9Og6" resname="payment_account_number">
|
||||
<source>payment_account_name</source>
|
||||
<target>IBAN or card number</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
Reference in New Issue
Block a user