From 4c8ba5bbb65697e8bf9cb2d19e799da9d083f4ad Mon Sep 17 00:00:00 2001 From: Huy Truong Date: Thu, 15 Oct 2020 16:10:21 +0700 Subject: [PATCH] NEXT-11193 - Create migration for VAT handling Epic --- .../2020-10-19-vat-handling-for-companies.md | 10 + .../CustomerAddressCollection.php | 6 + .../CustomerAddressDefinition.php | 3 +- .../CustomerAddress/CustomerAddressEntity.php | 8 + .../Checkout/Customer/CustomerCollection.php | 20 ++ .../Checkout/Customer/CustomerDefinition.php | 7 + src/Core/Checkout/Customer/CustomerEntity.php | 23 ++ .../DataAbstractionLayer/CustomerIndexer.php | 20 +- .../CustomerVatIdsDeprecationUpdater.php | 52 ++++ .../Checkout/DependencyInjection/customer.xml | 5 + .../Indexing/VatIdWriteDeprecationTest.php | 168 ++++++++++++ ...erVatIdFromCustomerAddressIntoCustomer.php | 74 ++++++ ...02822727AddVatHandlingIntoCountryTable.php | 92 +++++++ ...tIdFromCustomerAddressIntoCustomerTest.php | 246 ++++++++++++++++++ src/Core/System/Country/CountryDefinition.php | 17 +- src/Core/System/Country/CountryEntity.php | 69 +++++ .../Resources/config/loginRegistration.xml | 7 + 17 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 changelog/_unreleased/2020-10-19-vat-handling-for-companies.md create mode 100644 src/Core/Checkout/Customer/DataAbstractionLayer/CustomerVatIdsDeprecationUpdater.php create mode 100644 src/Core/Checkout/Test/Customer/DataAbstractionLayer/Indexing/VatIdWriteDeprecationTest.php create mode 100644 src/Core/Migration/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomer.php create mode 100644 src/Core/Migration/Migration1602822727AddVatHandlingIntoCountryTable.php create mode 100644 src/Core/Migration/Test/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomerTest.php diff --git a/changelog/_unreleased/2020-10-19-vat-handling-for-companies.md b/changelog/_unreleased/2020-10-19-vat-handling-for-companies.md new file mode 100644 index 00000000000..d935eea38fc --- /dev/null +++ b/changelog/_unreleased/2020-10-19-vat-handling-for-companies.md @@ -0,0 +1,10 @@ +--- +title: VAT handling for companies +issue: NEXT-10559 +flag: FEATURE_NEXT_10559 +--- +# Core +* Added `vat_ids` column and moved `vat_id` data from `customer_address` to `customer` table. +* Added `company_tax_free`, `check_vat_id_pattern` and `vat_id_pattern` columns to `country` table. +* Added `VAT ID field required` to login/registration setting. +* Added `Shopware\Core\Checkout\Customer\DataAbstractionLayer\CustomerVatIdsDeprecationUpdater`. diff --git a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressCollection.php b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressCollection.php index e598cefe382..13f4a8f602e 100644 --- a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressCollection.php +++ b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressCollection.php @@ -60,6 +60,9 @@ public function filterByCountryStateId(string $id): self }); } + /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - Will be removed and use CustomerCollection:getListVatIds() instead + */ public function getVatIds(): array { return $this->fmap(function (CustomerAddressEntity $customerAddress) { @@ -67,6 +70,9 @@ public function getVatIds(): array }); } + /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - Will be removed and use CustomerCollection:filterByVatId() instead + */ public function filterByVatId(string $id): self { return $this->filter(function (CustomerAddressEntity $customerAddress) use ($id) { diff --git a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressDefinition.php b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressDefinition.php index 906a53ba68c..b0d9b657cff 100644 --- a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressDefinition.php +++ b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressDefinition.php @@ -6,6 +6,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Deprecated; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\SearchRanking; @@ -66,7 +67,7 @@ protected function defineFields(): FieldCollection (new StringField('street', 'street'))->addFlags(new Required(), new SearchRanking(SearchRanking::HIGH_SEARCH_RANKING)), new StringField('department', 'department'), new StringField('title', 'title'), - new StringField('vat_id', 'vatId'), + (new StringField('vat_id', 'vatId'))->addFlags(new Deprecated('v4', 'v4')), new StringField('phone_number', 'phoneNumber'), (new StringField('additional_address_line1', 'additionalAddressLine1'))->addFlags(new SearchRanking(SearchRanking::MIDDLE_SEARCH_RANKING)), (new StringField('additional_address_line2', 'additionalAddressLine2'))->addFlags(new SearchRanking(SearchRanking::MIDDLE_SEARCH_RANKING)), diff --git a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressEntity.php b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressEntity.php index f6b91f2fa7f..a3acc00f478 100644 --- a/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressEntity.php +++ b/src/Core/Checkout/Customer/Aggregate/CustomerAddress/CustomerAddressEntity.php @@ -74,6 +74,8 @@ class CustomerAddressEntity extends Entity protected $street; /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - Will be removed + * * @var string|null */ protected $vatId; @@ -238,11 +240,17 @@ public function setStreet(string $street): void $this->street = $street; } + /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - Will be removed + */ public function getVatId(): ?string { return $this->vatId; } + /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - Will be removed + */ public function setVatId(string $vatId): void { $this->vatId = $vatId; diff --git a/src/Core/Checkout/Customer/CustomerCollection.php b/src/Core/Checkout/Customer/CustomerCollection.php index addd0207405..7bbdbc5ffb6 100644 --- a/src/Core/Checkout/Customer/CustomerCollection.php +++ b/src/Core/Checkout/Customer/CustomerCollection.php @@ -157,6 +157,26 @@ public function getDefaultShippingAddress(): CustomerAddressCollection ); } + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function getListVatIds(): array + { + return $this->fmap(function (CustomerEntity $customer) { + return $customer->getVatIds(); + }); + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function filterByVatId(string $id): self + { + return $this->filter(function (CustomerEntity $customer) use ($id) { + return in_array($id, $customer->getVatIds() ?? [], true); + }); + } + public function getApiAlias(): string { return 'customer_collection'; diff --git a/src/Core/Checkout/Customer/CustomerDefinition.php b/src/Core/Checkout/Customer/CustomerDefinition.php index 8ffbf31bb56..eac67e1196c 100644 --- a/src/Core/Checkout/Customer/CustomerDefinition.php +++ b/src/Core/Checkout/Customer/CustomerDefinition.php @@ -31,6 +31,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\WriteProtected; use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField; use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ListField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyIdField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; @@ -150,6 +151,12 @@ protected function defineFields(): FieldCollection ); } + if (Feature::isActive('FEATURE_NEXT_10559')) { + $fields->add( + new ListField('vat_ids', 'vatIds', StringField::class) + ); + } + return $fields; } } diff --git a/src/Core/Checkout/Customer/CustomerEntity.php b/src/Core/Checkout/Customer/CustomerEntity.php index fc31cf09767..1266563da37 100644 --- a/src/Core/Checkout/Customer/CustomerEntity.php +++ b/src/Core/Checkout/Customer/CustomerEntity.php @@ -100,6 +100,13 @@ class CustomerEntity extends Entity */ protected $title; + /** + * @internal (flag:FEATURE_NEXT_10559) + * + * @var array|null + */ + protected $vatIds; + /** * @var string|null */ @@ -472,6 +479,22 @@ public function setTitle(?string $title): void $this->title = $title; } + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function getVatIds(): ?array + { + return $this->vatIds; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function setVatIds(?array $vatIds): void + { + $this->vatIds = $vatIds; + } + public function getActive(): bool { return $this->active; diff --git a/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerIndexer.php b/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerIndexer.php index f911b1bb019..f389f9c58c8 100644 --- a/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerIndexer.php +++ b/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerIndexer.php @@ -11,6 +11,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexer; use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexingMessage; use Shopware\Core\Framework\DataAbstractionLayer\Indexing\ManyToManyIdFieldUpdater; +use Shopware\Core\Framework\Feature; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class CustomerIndexer extends EntityIndexer @@ -40,18 +41,27 @@ class CustomerIndexer extends EntityIndexer */ private $eventDispatcher; + /** + * @feature-deprecated (flag:FEATURE_NEXT_10559) tag:v6.4.0 - property $customerVatIdsDeprecationUpdater will be removed in 6.4.0 + * + * @var CustomerVatIdsDeprecationUpdater + */ + private $customerVatIdsDeprecationUpdater; + public function __construct( IteratorFactory $iteratorFactory, EntityRepositoryInterface $repository, CacheClearer $cacheClearer, ManyToManyIdFieldUpdater $manyToManyIdFieldUpdater, - EventDispatcherInterface $eventDispatcher + EventDispatcherInterface $eventDispatcher, + CustomerVatIdsDeprecationUpdater $customerVatIdsDeprecationUpdater ) { $this->iteratorFactory = $iteratorFactory; $this->repository = $repository; $this->cacheClearer = $cacheClearer; $this->manyToManyIdFieldUpdater = $manyToManyIdFieldUpdater; $this->eventDispatcher = $eventDispatcher; + $this->customerVatIdsDeprecationUpdater = $customerVatIdsDeprecationUpdater; } public function getName(): string @@ -80,6 +90,14 @@ public function update(EntityWrittenContainerEvent $event): ?EntityIndexingMessa return null; } + if (Feature::isActive('FEATURE_NEXT_10559')) { + $customerEvent = $event->getEventByEntityName(CustomerDefinition::ENTITY_NAME); + + if ($customerEvent) { + $this->customerVatIdsDeprecationUpdater->updateByEvent($customerEvent); + } + } + return new CustomerIndexingMessage(array_values($updates), null, $event->getContext()); } diff --git a/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerVatIdsDeprecationUpdater.php b/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerVatIdsDeprecationUpdater.php new file mode 100644 index 00000000000..532319e415a --- /dev/null +++ b/src/Core/Checkout/Customer/DataAbstractionLayer/CustomerVatIdsDeprecationUpdater.php @@ -0,0 +1,52 @@ +connection = $connection; + } + + public function updateByEvent(EntityWrittenEvent $event): void + { + foreach ($event->getPayloads() as $payload) { + if (isset($payload['vatIds'])) { + $this->updateVatIdsInCustomer($payload['vatIds'], $payload['id']); + } + } + } + + private function updateVatIdsInCustomer(array $vatIds, string $customerId): void + { + if (!empty($vatIds)) { + $this->connection->executeUpdate( + 'UPDATE `customer_address` SET `vat_id` = :vatId + WHERE `customer_address`.`customer_id` = :customerId + AND (customer_address.vat_id <> :vatId OR customer_address.vat_id IS NULL)', + [ + 'vatId' => $vatIds[0], + 'customerId' => Uuid::fromHexToBytes($customerId), + ] + ); + } else { + $this->connection->executeUpdate( + 'UPDATE `customer_address` SET `vat_id` = NULL WHERE `customer_id` = :customerId', + ['customerId' => Uuid::fromHexToBytes($customerId)] + ); + } + } +} diff --git a/src/Core/Checkout/DependencyInjection/customer.xml b/src/Core/Checkout/DependencyInjection/customer.xml index cfbb8c84ab7..8e4f6333747 100644 --- a/src/Core/Checkout/DependencyInjection/customer.xml +++ b/src/Core/Checkout/DependencyInjection/customer.xml @@ -253,12 +253,17 @@ + + + + + diff --git a/src/Core/Checkout/Test/Customer/DataAbstractionLayer/Indexing/VatIdWriteDeprecationTest.php b/src/Core/Checkout/Test/Customer/DataAbstractionLayer/Indexing/VatIdWriteDeprecationTest.php new file mode 100644 index 00000000000..dce00ff8ec0 --- /dev/null +++ b/src/Core/Checkout/Test/Customer/DataAbstractionLayer/Indexing/VatIdWriteDeprecationTest.php @@ -0,0 +1,168 @@ +ids = new TestDataCollection(Context::createDefaultContext()); + } + + /** + * @dataProvider newFieldVatIdData + */ + public function testInsertNewFieldCustomerVatIds(array $vatId): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer($vatId); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + if (empty($vatId)) { + // Inserting without vat id + static::assertNull($customer->getVatIds()); + static::assertNull($customerAddress->getVatId()); + static::assertEquals($customer->getVatIds(), $customerAddress->getVatId()); + } else { + // Inserting with vat id + static::assertIsArray($customer->getVatIds()); + static::assertNotEmpty($customer->getVatIds()); + static::assertNotNull($customerAddress->getVatId()); + static::assertIsString($customerAddress->getVatId()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + } + } + + /** + * @dataProvider newFieldVatIdData + */ + public function testUpdateNewFieldCustomerVatIds(array $vatId): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer($vatId); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $customerRepository->update([[ + 'id' => $this->ids->get('customer_id'), + 'vatIds' => ['AU123123123'], + ]], $this->ids->context); + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + static::assertNotNull($customerAddress->getVatId()); + static::assertIsString($customerAddress->getVatId()); + static::assertEquals('AU123123123', $customerAddress->getVatId()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + } + + public function testUpdateNewFieldCustomerVatIdsToEmpty(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer(['vatIds' => ['AU123123123']]); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $customerRepository->update([[ + 'id' => $this->ids->get('customer_id'), + 'vatIds' => [], + ]], $this->ids->context); + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + static::assertNull($customerAddress->getVatId()); + static::assertEmpty($customerAddress->getVatId()); + static::assertIsArray($customer->getVatIds()); + static::assertEmpty($customer->getVatIds()); + } + + public function newFieldVatIdData(): array + { + return [ + 'Inserting/updating with vatIds' => [ + 'customer vat ids' => [ + 'vatIds' => ['GR123123123'], + ], + ], + 'Inserting/updating without vatIds' => [ + [], + ], + ]; + } + + private function createCustomer($additionalData = []): void + { + $email = Uuid::randomHex() . '@example.com'; + $password = 'shopware'; + $data = [ + 'id' => $this->ids->create('customer_id'), + 'salesChannelId' => Defaults::SALES_CHANNEL, + 'defaultShippingAddress' => [ + 'id' => $this->ids->create('address_id'), + 'firstName' => 'Huy', + 'lastName' => 'Truong', + 'street' => 'DN', + 'city' => 'DN', + 'zipcode' => '12345', + 'salutationId' => $this->getValidSalutationId(), + 'countryId' => $this->getValidCountryId(), + 'company' => 'Test Company', + ], + 'defaultBillingAddressId' => $this->ids->get('address_id'), + 'defaultPaymentMethodId' => $this->getValidPaymentMethodId(), + 'groupId' => Defaults::FALLBACK_CUSTOMER_GROUP, + 'email' => $email, + 'password' => $password, + 'firstName' => 'Huy', + 'lastName' => 'Truong', + 'salutationId' => $this->getValidSalutationId(), + 'customerNumber' => '12345', + 'company' => 'Test Company', + ]; + $insertData = array_merge_recursive($data, $additionalData); + $this->getContainer()->get('customer.repository')->create([$insertData], $this->ids->context); + } +} diff --git a/src/Core/Migration/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomer.php b/src/Core/Migration/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomer.php new file mode 100644 index 00000000000..899e784c499 --- /dev/null +++ b/src/Core/Migration/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomer.php @@ -0,0 +1,74 @@ +executeUpdate(' + ALTER TABLE `customer` + ADD COLUMN `vat_ids` JSON NULL DEFAULT NULL AFTER `title`; + '); + + $this->addInsertTrigger($connection); + $this->addUpdateTrigger($connection); + + $connection->executeUpdate(' + UPDATE `customer`, `customer_address` + SET `customer`.`vat_ids` = JSON_ARRAY(`customer_address`.`vat_id`) + WHERE `customer`.`default_billing_address_id` = `customer_address`.`id` AND `customer_address`.`vat_id` IS NOT NULL; + '); + } + + public function updateDestructive(Connection $connection): void + { + // implement update destructive + } + + /** + * Adds a database trigger that keeps the fields 'vat_ids' in `customer` and 'vat_id' in `customer_address` in sync. + * That means inserting either value will update the other. + */ + private function addInsertTrigger(Connection $connection): void + { + $query = 'CREATE TRIGGER customer_address_vat_id_insert AFTER INSERT ON customer_address + FOR EACH ROW BEGIN + IF NEW.vat_id IS NOT NULL THEN + UPDATE customer SET vat_ids = JSON_ARRAY(NEW.vat_id) + WHERE customer.default_billing_address_id = NEW.id + AND (JSON_CONTAINS(vat_ids, \'"$NEW.vat_id"\') = 0 OR vat_ids IS NULL); + END IF; + END;'; + $this->createTrigger($connection, $query); + } + + /** + * Adds a database trigger that keeps the fields 'vat_ids' in `customer` and 'vat_id' in `customer_address` in sync. + * That means updating either value will update the other. + */ + private function addUpdateTrigger(Connection $connection): void + { + $query = 'CREATE TRIGGER customer_address_vat_id_update AFTER UPDATE ON customer_address + FOR EACH ROW BEGIN + IF (OLD.vat_id IS NOT NULL AND NEW.vat_id IS NULL) THEN + UPDATE customer SET vat_ids = JSON_REMOVE(vat_ids, JSON_UNQUOTE(JSON_SEARCH(vat_ids, \'one\', OLD.vat_id))) + WHERE customer.default_billing_address_id = NEW.id + AND JSON_SEARCH(vat_ids, \'one\', OLD.vat_id) IS NOT NULL; + ELSEIF (OLD.vat_id IS NULL AND NEW.vat_id IS NOT NULL) OR (OLD.vat_id <> NEW.vat_id) THEN + UPDATE customer SET vat_ids = JSON_ARRAY(NEW.vat_id) + WHERE customer.default_billing_address_id = NEW.id + AND (JSON_CONTAINS(vat_ids, \'"$NEW.vat_id"\') = 0 OR vat_ids IS NULL); + END IF; + END;'; + $this->createTrigger($connection, $query); + } +} diff --git a/src/Core/Migration/Migration1602822727AddVatHandlingIntoCountryTable.php b/src/Core/Migration/Migration1602822727AddVatHandlingIntoCountryTable.php new file mode 100644 index 00000000000..92e51305810 --- /dev/null +++ b/src/Core/Migration/Migration1602822727AddVatHandlingIntoCountryTable.php @@ -0,0 +1,92 @@ +executeUpdate(' + ALTER TABLE `country` + ADD COLUMN `company_tax_free` TINYINT (1) NOT NULL DEFAULT 0 AFTER `force_state_in_registration`, + ADD COLUMN `check_vat_id_pattern` TINYINT (1) NOT NULL DEFAULT 0 AFTER `company_tax_free`, + ADD COLUMN `vat_id_pattern` VARCHAR (255) NULL AFTER `check_vat_id_pattern`; + '); + + $this->addCountryVatPattern($connection); + } + + public function updateDestructive(Connection $connection): void + { + // implement update destructive + } + + private function addCountryVatPattern(Connection $connection): void + { + $this->fetchCountryIds($connection); + + foreach ($this->getCountryVatPattern() as $isoCode => $countryVatPattern) { + if (!array_key_exists($isoCode, $this->countryIds)) { + // country was deleted by shop owner + continue; + } + + $connection->update('country', ['vat_id_pattern' => $countryVatPattern], ['id' => $this->countryIds[$isoCode]]); + } + } + + private function fetchCountryIds(Connection $connection): void + { + $countries = $connection->executeQuery('SELECT `id`, `iso` FROM `country`')->fetchAll(); + + foreach ($countries as $country) { + $this->countryIds[$country['iso']] = $country['id']; + } + } + + private function getCountryVatPattern(): array + { + return [ + 'AT' => '(AT)?U[0-9]{8}', + 'BE' => '(BE)?0[0-9]{9}', + 'BG' => '(BG)?[0-9]{9,10}', + 'CY' => '(CY)?[0-9]{8}L', + 'CZ' => '(CZ)?[0-9]{8,10}', + 'DE' => '(DE)?[0-9]{9}', + 'DK' => '(DK)?[0-9]{8}', + 'EE' => '(EE)?[0-9]{9}', + 'GR' => '(EL|GR)?[0-9]{9}', + 'ES' => '(ES)?[0-9A-Z][0-9]{7}[0-9A-Z]', + 'FI' => '(FI)?[0-9]{8}', + 'FR' => '(FR)?[0-9A-Z]{2}[0-9]{9}', + 'GB' => '(GB)?([0-9]{9}([0-9]{3})?|[A-Z]{2}[0-9]{3})', + 'HU' => '(HU)?[0-9]{8}', + 'IE' => '(IE)?[0-9]S[0-9]{5}L', + 'IT' => '(IT)?[0-9]{11}', + 'LT' => '(LT)?([0-9]{9}|[0-9]{12})', + 'LU' => '(LU)?[0-9]{8}', + 'LV' => '(LV)?[0-9]{11}', + 'MT' => '(MT)?[0-9]{8}', + 'NL' => '(NL)?[0-9]{9}B[0-9]{2}', + 'PL' => '(PL)?[0-9]{10}', + 'PT' => '(PT)?[0-9]{9}', + 'RO' => '(RO)?[0-9]{2,10}', + 'SE' => '(SE)?[0-9]{12}', + 'SI' => '(SI)?[0-9]{8}', + 'SK' => '(SK)?[0-9]{10}', + ]; + } +} diff --git a/src/Core/Migration/Test/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomerTest.php b/src/Core/Migration/Test/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomerTest.php new file mode 100644 index 00000000000..7f056721cd4 --- /dev/null +++ b/src/Core/Migration/Test/Migration1602745374AddVatIdsColumnAndTransferVatIdFromCustomerAddressIntoCustomerTest.php @@ -0,0 +1,246 @@ +ids = new TestDataCollection(Context::createDefaultContext()); + } + + public function testNoChanges(): void + { + /** @var Connection $conn */ + $conn = $this->getContainer()->get(Connection::class); + $expectedProductSchema = $conn->fetchAssoc('SHOW CREATE TABLE `customer`')['Create Table']; + + $migration = new Migration1604056363CustomerWishlist(); + + $migration->update($conn); + $actualProductSchema = $conn->fetchAssoc('SHOW CREATE TABLE `customer`')['Create Table']; + static::assertSame($expectedProductSchema, $actualProductSchema, 'Schema changed!. Run init again to have clean state'); + } + + public function testTriggersSet(): void + { + $databaseName = substr(parse_url($_SERVER['DATABASE_URL'])['path'], 1); + + /** @var Connection $conn */ + $conn = $this->getContainer()->get(Connection::class); + $updateTrigger = $conn->fetchAll('SHOW TRIGGERS IN ' . $databaseName . ' WHERE `Trigger` = \'customer_address_vat_id_update\''); + + static::assertCount(1, $updateTrigger); + + $insertTrigger = $conn->fetchAll('SHOW TRIGGERS IN ' . $databaseName . ' WHERE `Trigger` = \'customer_address_vat_id_insert\''); + + static::assertCount(1, $insertTrigger); + } + + public function testInsertNewValueWithCustomerAddressVatId(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer([ + 'defaultShippingAddress' => [ + 'vatId' => 'test', + ], + ]); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertNotNull($customer->getVatIds()); + static::assertIsArray($customer->getVatIds()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + } + + public function testInsertNewValueWithCustomerAddressVatIdIsNull(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer(); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertNull($customer->getVatIds()); + static::assertNull($customerAddress->getVatId()); + static::assertEquals($customer->getVatIds(), $customerAddress->getVatId()); + } + + public function testInsertNewValueWithCustomerVatIds(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer([ + 'vatIds' => ['GR123123123'], + ]); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertNotNull($customer->getVatIds()); + static::assertIsArray($customer->getVatIds()); + static::assertEquals(['GR123123123'], $customer->getVatIds()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + } + + public function testUpdateCustomerAddressVatIdToNewValue(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer([ + 'defaultShippingAddress' => [ + 'vatId' => 'test', + ], + ]); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $customerAddressRepository->update([[ + 'id' => $this->ids->get('address_id'), + 'vatId' => 'AU123123123', + ]], $this->ids->context); + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertNotNull($customer->getVatIds()); + static::assertIsArray($customer->getVatIds()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + static::assertEquals(['AU123123123'], $customer->getVatIds()); + } + + public function testUpdateCustomerAddressVatIdNotNullToNull(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer([ + 'defaultShippingAddress' => [ + 'vatId' => 'test', + ], + ]); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $customerAddressRepository->update([[ + 'id' => $this->ids->get('address_id'), + 'vatId' => null, + ]], $this->ids->context); + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertIsArray($customer->getVatIds()); + static::assertEmpty($customer->getVatIds()); + static::assertNull($customerAddress->getVatId()); + } + + public function testUpdateCustomerAddressVatIdNullToNewValue(): void + { + Feature::skipTestIfInActive('FEATURE_NEXT_10559', $this); + + $this->createCustomer(); + + $customerAddressRepository = $this->getContainer()->get('customer_address.repository'); + + $customerAddressRepository->update([[ + 'id' => $this->ids->get('address_id'), + 'vatId' => 'AU123123123', + ]], $this->ids->context); + $criteria = new Criteria([$this->ids->get('address_id')]); + $customerAddress = $customerAddressRepository->search($criteria, $this->ids->context)->first(); + + $customerRepository = $this->getContainer()->get('customer.repository'); + + $criteria = new Criteria([$this->ids->get('customer_id')]); + $customer = $customerRepository->search($criteria, $this->ids->context)->first(); + + static::assertNotNull($customer->getVatIds()); + static::assertIsArray($customer->getVatIds()); + static::assertEquals($customer->getVatIds()[0], $customerAddress->getVatId()); + } + + private function createCustomer($additionalData = []): void + { + $email = Uuid::randomHex() . '@example.com'; + $password = 'shopware'; + $data = [ + 'id' => $this->ids->create('customer_id'), + 'salesChannelId' => Defaults::SALES_CHANNEL, + 'defaultShippingAddress' => [ + 'id' => $this->ids->create('address_id'), + 'firstName' => 'Huy', + 'lastName' => 'Truong', + 'street' => 'DN', + 'city' => 'DN', + 'zipcode' => '12345', + 'salutationId' => $this->getValidSalutationId(), + 'countryId' => $this->getValidCountryId(), + 'company' => 'Test Company', + ], + 'defaultBillingAddressId' => $this->ids->get('address_id'), + 'defaultPaymentMethodId' => $this->getValidPaymentMethodId(), + 'groupId' => Defaults::FALLBACK_CUSTOMER_GROUP, + 'email' => $email, + 'password' => $password, + 'firstName' => 'Huy', + 'lastName' => 'Truong', + 'salutationId' => $this->getValidSalutationId(), + 'customerNumber' => '12345', + 'company' => 'Test Company', + ]; + $insertData = array_merge_recursive($data, $additionalData); + $this->getContainer()->get('customer.repository')->create([$insertData], $this->ids->context); + } +} diff --git a/src/Core/System/Country/CountryDefinition.php b/src/Core/System/Country/CountryDefinition.php index 7a230dd9829..f99aab2fd53 100644 --- a/src/Core/System/Country/CountryDefinition.php +++ b/src/Core/System/Country/CountryDefinition.php @@ -21,6 +21,7 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField; use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection; +use Shopware\Core\Framework\Feature; use Shopware\Core\System\Country\Aggregate\CountryState\CountryStateDefinition; use Shopware\Core\System\Country\Aggregate\CountryTranslation\CountryTranslationDefinition; use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelCountry\SalesChannelCountryDefinition; @@ -53,7 +54,7 @@ public function since(): ?string protected function defineFields(): FieldCollection { - return new FieldCollection([ + $fields = new FieldCollection([ (new IdField('id', 'id'))->addFlags(new PrimaryKey(), new Required()), (new TranslatedField('name'))->addFlags(new SearchRanking(SearchRanking::HIGH_SEARCH_RANKING)), (new StringField('iso', 'iso'))->addFlags(new SearchRanking(SearchRanking::MIDDLE_SEARCH_RANKING)), @@ -74,5 +75,19 @@ protected function defineFields(): FieldCollection (new ManyToManyAssociationField('salesChannels', SalesChannelDefinition::class, SalesChannelCountryDefinition::class, 'country_id', 'sales_channel_id'))->addFlags(new ReadProtected(SalesChannelApiSource::class)), (new OneToManyAssociationField('taxRules', TaxRuleDefinition::class, 'country_id', 'id'))->addFlags(new RestrictDelete(), new ReadProtected(SalesChannelApiSource::class)), ]); + + if (Feature::isActive('FEATURE_NEXT_10559')) { + $fields->add( + new BoolField('company_tax_free', 'companyTaxFree') + ); + $fields->add( + new BoolField('check_vat_id_pattern', 'checkVatIdPattern') + ); + $fields->add( + new StringField('vat_id_pattern', 'vatIdPattern') + ); + } + + return $fields; } } diff --git a/src/Core/System/Country/CountryEntity.php b/src/Core/System/Country/CountryEntity.php index ae4e56d567c..a6022d94847 100644 --- a/src/Core/System/Country/CountryEntity.php +++ b/src/Core/System/Country/CountryEntity.php @@ -60,6 +60,27 @@ class CountryEntity extends Entity */ protected $forceStateInRegistration; + /** + * @internal (flag:FEATURE_NEXT_10559) + * + * @var bool + */ + protected $companyTaxFree; + + /** + * @internal (flag:FEATURE_NEXT_10559) + * + * @var bool + */ + protected $checkVatIdPattern; + + /** + * @internal (flag:FEATURE_NEXT_10559) + * + * @var string|null + */ + protected $vatIdPattern; + /** * @var CountryStateCollection|null */ @@ -190,6 +211,54 @@ public function setForceStateInRegistration(bool $forceStateInRegistration): voi $this->forceStateInRegistration = $forceStateInRegistration; } + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function getCompanyTaxFree(): bool + { + return $this->companyTaxFree; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function setCompanyTaxFree(bool $companyTaxFree): void + { + $this->companyTaxFree = $companyTaxFree; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function getCheckVatIdPattern(): bool + { + return $this->checkVatIdPattern; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function setCheckVatIdPattern(bool $checkVatIdPattern): void + { + $this->checkVatIdPattern = $checkVatIdPattern; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function getVatIdPattern(): ?string + { + return $this->vatIdPattern; + } + + /** + * @internal (flag:FEATURE_NEXT_10559) + */ + public function setVatIdPattern(?string $vatIdPattern): void + { + $this->vatIdPattern = $vatIdPattern; + } + public function getStates(): ?CountryStateCollection { return $this->states; diff --git a/src/Core/System/Resources/config/loginRegistration.xml b/src/Core/System/Resources/config/loginRegistration.xml index baddb7f198d..27fda050a8b 100644 --- a/src/Core/System/Resources/config/loginRegistration.xml +++ b/src/Core/System/Resources/config/loginRegistration.xml @@ -122,5 +122,12 @@ + + + vatIdFieldRequired + + + FEATURE_NEXT_10559 +