diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d15d66a37..8994638b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,22 @@ ## Unreleased +### Store Management +- Archived gateways are now listed on the Gateways index page. ([#3839](https://github.com/craftcms/commerce/issues/3839)) - Improved Craft Commerce navigation and breadcrumb labels. +### Extensibility +- Added support for registering custom tax ID validators. +- Added `\craft\commerce\services\Taxes::getEnabledTaxIdValidators()`. +- Added `\craft\commerce\services\Taxes::getTaxIdValidators()`. +- Added `craft\commerce\base\TaxIdValidatorInterface`. +- Added `craft\commerce\services\Gateways\getAllArchivedGateways()`. +- Added `craft\commerce\services\Taxes::EVENT_REGISTER_TAX_ID_VALIDATORS`. +- Added `craft\commerce\taxidvalidators\EuVatIdValidator`. + ## 4.7.3 - 2025-01-22 +- Improved the performance of recalculating shipping method prices. ([#3841](https://github.com/craftcms/commerce/pull/3841)) - Fixed a bug where users products had a “Save as a new product” action even if a plugin was preventing duplication via `craft\services\Elements::EVENT_AUTHORIZE_DUPLICATE`. ([#3819](https://github.com/craftcms/commerce/issues/3819)) - Fixed a PHP error that could occur when updating a cart. ([#3842](https://github.com/craftcms/commerce/issues/3842)) - Fixed a PHP error that could occur when adding an invalid address to a cart. ([#3848](https://github.com/craftcms/commerce/issues/3848)) diff --git a/example-templates/dist/shop/_private/address/fields.twig b/example-templates/dist/shop/_private/address/fields.twig index 185436c4cd..e1ace8190b 100644 --- a/example-templates/dist/shop/_private/address/fields.twig +++ b/example-templates/dist/shop/_private/address/fields.twig @@ -199,11 +199,11 @@ Outputs address form fields for editing an address.
{{ hiddenInput('isPrimaryBilling', 0) }} - +
{{ hiddenInput('isPrimaryShipping', 0) }} - +
{% endif %} @@ -251,4 +251,4 @@ document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }} }); document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }}').dispatchEvent(new Event('change')); -{% endjs %} \ No newline at end of file +{% endjs %} diff --git a/example-templates/src/shop/_private/address/fields.twig b/example-templates/src/shop/_private/address/fields.twig index 2af88ecfa4..b4f12fa80e 100755 --- a/example-templates/src/shop/_private/address/fields.twig +++ b/example-templates/src/shop/_private/address/fields.twig @@ -199,11 +199,11 @@ Outputs address form fields for editing an address.
{{ hiddenInput('isPrimaryBilling', 0) }} - +
{{ hiddenInput('isPrimaryShipping', 0) }} - +
{% endif %} @@ -251,4 +251,4 @@ document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }} }); document.querySelector('select#{{ 'countryCode'|namespaceInputId(addressName) }}').dispatchEvent(new Event('change')); -{% endjs %} \ No newline at end of file +{% endjs %} diff --git a/src/Plugin.php b/src/Plugin.php index a8cf5ad533..214314b9e2 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -210,7 +210,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '4.7.0.1'; + public string $schemaVersion = '4.8.0.0'; /** * @inheritdoc diff --git a/src/adjusters/Tax.php b/src/adjusters/Tax.php index 43331970cc..773df02319 100644 --- a/src/adjusters/Tax.php +++ b/src/adjusters/Tax.php @@ -10,6 +10,7 @@ use Craft; use craft\base\Component; use craft\commerce\base\AdjusterInterface; +use craft\commerce\base\TaxIdValidatorInterface; use craft\commerce\elements\Order; use craft\commerce\helpers\Currency; use craft\commerce\models\OrderAdjustment; @@ -17,6 +18,8 @@ use craft\commerce\models\TaxRate; use craft\commerce\Plugin; use craft\commerce\records\TaxRate as TaxRateRecord; +use craft\commerce\services\Taxes; +use craft\commerce\taxidvalidators\EuVatIdValidator; use craft\elements\Address; use DvK\Vat\Validator; use Exception; @@ -35,11 +38,6 @@ class Tax extends Component implements AdjusterInterface { public const ADJUSTMENT_TYPE = 'tax'; - /** - * @var Validator|null - */ - private ?Validator $_vatValidator = null; - /** * @var Order */ @@ -118,17 +116,17 @@ private function _adjustInternal(): array private function _getAdjustments(TaxRate $taxRate): array { $adjustments = []; - $hasValidEuVatId = false; + $hasValidTaxId = false; $zoneMatches = $taxRate->getIsEverywhere() || ($taxRate->getTaxZone() && $this->_matchAddress($taxRate->getTaxZone())); - if ($zoneMatches && $taxRate->isVat) { - $hasValidEuVatId = $this->organizationTaxId(); + if ($zoneMatches && $taxRate->hasTaxIdValidators()) { + $hasValidTaxId = $this->organizationTaxIdIsValidTaxId($taxRate->getSelectedEnabledTaxIdValidators()); } $removeIncluded = (!$zoneMatches && $taxRate->removeIncluded); - $removeDueToVat = ($zoneMatches && $hasValidEuVatId && $taxRate->removeVatIncluded); - if ($removeIncluded || $removeDueToVat) { + $removeDueToVatId = ($zoneMatches && $hasValidTaxId && $taxRate->removeVatIncluded); + if ($removeIncluded || $removeDueToVatId) { // Is this an order level tax rate? if (in_array($taxRate->taxable, TaxRateRecord::ORDER_TAXABALES, false)) { @@ -195,7 +193,7 @@ private function _getAdjustments(TaxRate $taxRate): array return $adjustments; } - if (!$zoneMatches || ($taxRate->isVat && $hasValidEuVatId)) { + if (!$zoneMatches || ($taxRate->hasTaxIdValidators() && $hasValidTaxId)) { return []; } @@ -323,7 +321,7 @@ private function _matchAddress(TaxAddressZone $zone): bool /** * @return bool */ - private function organizationTaxId(): bool + private function organizationTaxIdIsValidTaxId(array $validators): bool { if (!$this->_address) { return false; @@ -340,7 +338,7 @@ private function organizationTaxId(): bool // If we do not have a valid VAT ID in cache, see if we can get one from the API if (!$validOrganizationTaxId) { - $validOrganizationTaxId = $this->validateVatNumber($this->_address->organizationTaxId); + $validOrganizationTaxId = $this->validateTaxIdNumber($this->_address->organizationTaxId, $validators); } if ($validOrganizationTaxId) { @@ -355,25 +353,34 @@ private function organizationTaxId(): bool /** * @param string $businessVatId * @return bool + * @deprecated in 4.8.0. Use `validateTaxIdNumber()` instead, passing the validators you want to check the ID with. */ protected function validateVatNumber(string $businessVatId): bool + { + $oldValidator = [new EuVatIdValidator()]; + return $this->validateTaxIdNumber($businessVatId, $oldValidator); + } + + /** + * @param string $organizationTaxId + * @param TaxIdValidatorInterface[] $validators + * @return bool + */ + protected function validateTaxIdNumber(string $organizationTaxId, array $validators = []): bool { try { - return $this->_getVatValidator()->validate($businessVatId); + foreach ($validators as $validator) { + if ($validator->validate($organizationTaxId)) { + return true; + } + } } catch (Exception $e) { Craft::error('Communication with VAT API failed: ' . $e->getMessage(), __METHOD__); return false; } - } - - private function _getVatValidator(): Validator - { - if ($this->_vatValidator === null) { - $this->_vatValidator = new Validator(); - } - return $this->_vatValidator; + return false; } private function _createAdjustment(TaxRate $rate): OrderAdjustment diff --git a/src/base/TaxIdValidatorInterface.php b/src/base/TaxIdValidatorInterface.php new file mode 100644 index 0000000000..d588df8f22 --- /dev/null +++ b/src/base/TaxIdValidatorInterface.php @@ -0,0 +1,61 @@ + + * @since 4.8.0 + */ +interface TaxIdValidatorInterface +{ + /** + * The display name of this tax ID type. + * + * @return string + * @since 4.8.0 + */ + public static function displayName(): string; + + /** + * Tests if the ID looks generally correct. This would usually be something like a regex check. + * + * @param string $idNumber + * @return bool + * @since 4.8.0 + */ + public function validateFormat(string $idNumber): bool; + + /** + * Tests if the ID exists as valid in the country's tax system. This would usually be an API call. + * + * @param string $idNumber + * @return bool + * @since 4.8.0 + */ + public function validateExistence(string $idNumber): bool; + + /** + * This would usually just call validateFormat() and then validateExistence() and return the result. + * + * @param string $idNumber + * @return bool + * @since 4.8.0 + */ + public function validate(string $idNumber): bool; + + /** + * Tests if the validator is available for use by tax rates. + * This would usually be a check against the existence or settings or API keys so that the validator can be used. + * + * @return bool + * @since 4.8.0 + */ + public static function isEnabled(): bool; +} diff --git a/src/behaviors/ValidateOrganizationTaxIdBehavior.php b/src/behaviors/ValidateOrganizationTaxIdBehavior.php index 9cfe5747a7..1eed6d651e 100644 --- a/src/behaviors/ValidateOrganizationTaxIdBehavior.php +++ b/src/behaviors/ValidateOrganizationTaxIdBehavior.php @@ -6,7 +6,6 @@ use craft\commerce\Plugin; use craft\elements\Address; use craft\events\DefineRulesEvent; -use DvK\Vat\Validator; use Exception; use RuntimeException; use yii\base\Behavior; @@ -16,11 +15,6 @@ class ValidateOrganizationTaxIdBehavior extends Behavior /** @var Address */ public $owner; - /** - * @var Validator - */ - private Validator $_vatValidator; - /** * @inheritdoc */ @@ -89,23 +83,16 @@ public function validateOrganizationTaxId(): void private function _validateVatNumber(string $businessVatId): bool { try { - return $this->_getVatValidator()->validate($businessVatId); + $validators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); + foreach ($validators as $validator) { + if ($validator->validate($businessVatId)) { + return true; + } + } } catch (Exception $e) { - Craft::error('Communication with VAT API failed: ' . $e->getMessage(), __METHOD__); - - return false; - } - } - - /** - * @return Validator - */ - private function _getVatValidator(): Validator - { - if (!isset($this->_vatValidator)) { - $this->_vatValidator = new Validator(); + Craft::error('Communication with Tax ID validators failed: ' . $e->getMessage(), __METHOD__); } - return $this->_vatValidator; + return false; } } diff --git a/src/console/controllers/UpgradeController.php b/src/console/controllers/UpgradeController.php index 72261be81b..792492241a 100644 --- a/src/console/controllers/UpgradeController.php +++ b/src/console/controllers/UpgradeController.php @@ -438,7 +438,11 @@ private function _customField(string $oldAttribute, string $label, ?string $pref $field = new PlainText(); $field->groupId = ArrayHelper::firstValue(Craft::$app->getFields()->getAllGroups())->id; - $field->columnType = Schema::TYPE_STRING; + if ($oldAttribute == 'notes') { + $field->columnType = Schema::TYPE_TEXT; + } else { + $field->columnType = Schema::TYPE_STRING; + } $field->handle = $this->prompt('Field handle:', [ 'required' => true, 'validator' => function($handle) use ($handlePattern, $fieldsService) { diff --git a/src/controllers/GatewaysController.php b/src/controllers/GatewaysController.php index 5c6b084fa9..e42dfd5bfb 100644 --- a/src/controllers/GatewaysController.php +++ b/src/controllers/GatewaysController.php @@ -8,12 +8,16 @@ namespace craft\commerce\controllers; use Craft; +use craft\base\MissingComponentInterface; use craft\commerce\base\Gateway; use craft\commerce\base\GatewayInterface; +use craft\commerce\db\Table; use craft\commerce\gateways\Dummy; use craft\commerce\helpers\DebugPanel; use craft\commerce\Plugin; +use craft\db\Query; use craft\errors\DeprecationException; +use craft\helpers\Html; use craft\helpers\Json; use yii\base\Exception; use yii\base\InvalidConfigException; @@ -32,9 +36,33 @@ class GatewaysController extends BaseAdminController public function actionIndex(): Response { $gateways = Plugin::getInstance()->getGateways()->getAllGateways(); + $archivedGateways = Plugin::getInstance()->getGateways()->getAllArchivedGateways(); + + if (!empty($archivedGateways)) { + $gatewayIdsWithTransactions = (new Query()) + ->select(['gatewayId']) + ->from(Table::TRANSACTIONS) + ->groupBy(['gatewayId']) + ->column(); + + foreach ($archivedGateways as &$gateway) { + $missing = $gateway instanceof MissingComponentInterface; + $gateway = [ + 'id' => $gateway->id, + 'title' => Craft::t('site', $gateway->name), + 'handle' => Html::encode($gateway->handle), + 'type' => [ + 'missing' => $missing, + 'name' => $missing ? $gateway->expectedType : $gateway->displayName(), + ], + 'hasTransactions' => in_array($gateway->id, $gatewayIdsWithTransactions), + ]; + } + } return $this->renderTemplate('commerce/settings/gateways/index', [ 'gateways' => $gateways, + 'archivedGateways' => array_values($archivedGateways), ]); } diff --git a/src/controllers/TaxRatesController.php b/src/controllers/TaxRatesController.php index 7579ed0232..375beb9752 100644 --- a/src/controllers/TaxRatesController.php +++ b/src/controllers/TaxRatesController.php @@ -127,7 +127,6 @@ public function actionEdit(int $id = null, TaxRate $taxRate = null): Response $view->startJsBuffer(); - $newZone = new TaxAddressZone(); $condition = $newZone->getCondition(); $condition->mainTag = 'div'; @@ -156,6 +155,11 @@ public function actionEdit(int $id = null, TaxRate $taxRate = null): Response ); $variables['newTaxCategoryJs'] = $view->clearJsBuffer(false); + $taxIdValidators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); + foreach ($taxIdValidators as $validator) { + $variables['taxIdValidators'][] = $validator; + } + return $this->renderTemplate('commerce/tax/taxrates/_edit', $variables); } @@ -181,12 +185,15 @@ public function actionSave(): void $taxRate->include = (bool)$this->request->getBodyParam('include'); $taxRate->removeIncluded = (bool)$this->request->getBodyParam('removeIncluded'); $taxRate->removeVatIncluded = (bool)$this->request->getBodyParam('removeVatIncluded'); - $taxRate->isVat = (bool)$this->request->getBodyParam('isVat'); $taxRate->taxable = $this->request->getBodyParam('taxable'); $taxRate->taxCategoryId = (int)$this->request->getBodyParam('taxCategoryId') ?: null; $taxRate->taxZoneId = (int)$this->request->getBodyParam('taxZoneId') ?: null; $taxRate->rate = Localization::normalizePercentage($this->request->getBodyParam('rate')); + // data comes in as className => bool, we want just the class names that are true + $validators = collect($this->request->getBodyParam('taxIdValidators'))->filter(fn($enabled) => (bool)$enabled)->keys(); + $taxRate->taxIdValidators = $validators->toArray(); + // Save it if (Plugin::getInstance()->getTaxRates()->saveTaxRate($taxRate)) { $this->setSuccessFlash(Craft::t('commerce', 'Tax rate saved.')); diff --git a/src/events/TaxIdValidatorsEvent.php b/src/events/TaxIdValidatorsEvent.php new file mode 100644 index 0000000000..5f1caf98fe --- /dev/null +++ b/src/events/TaxIdValidatorsEvent.php @@ -0,0 +1,25 @@ + + * @since 4.8.0 + */ +class TaxIdValidatorsEvent extends Event +{ + /** + * @var TaxIdValidatorInterface[] Holds the registered tax ID validators. + */ + public array $validators = []; +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 4083ee432e..a6f943819d 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -741,7 +741,8 @@ public function createTables(): void 'code' => $this->string(), 'rate' => $this->decimal(14, 10)->notNull(), 'include' => $this->boolean()->notNull()->defaultValue(false), - 'isVat' => $this->boolean()->notNull()->defaultValue(false), // TODO rename to isEuVat #COM-45 + 'isVat' => $this->boolean()->notNull()->defaultValue(false), // Remove in Commerce 6 + 'taxIdValidators' => $this->text(), 'removeIncluded' => $this->boolean()->notNull()->defaultValue(false), 'removeVatIncluded' => $this->boolean()->notNull()->defaultValue(false), 'taxable' => $this->enum('taxable', ['purchasable', 'price', 'shipping', 'price_shipping', 'order_total_shipping', 'order_total_price'])->notNull(), diff --git a/src/migrations/m250120_080035_move_to_tax_id_validators.php b/src/migrations/m250120_080035_move_to_tax_id_validators.php new file mode 100644 index 0000000000..55366b03f5 --- /dev/null +++ b/src/migrations/m250120_080035_move_to_tax_id_validators.php @@ -0,0 +1,40 @@ +addColumn('{{%commerce_taxrates}}', 'taxIdValidators', $this->text()->after('isVat')); + + $taxRates = (new \craft\db\Query()) + ->select(['id', 'isVat']) + ->from(['{{%commerce_taxrates}}']) + ->all(); + + foreach ($taxRates as $taxRate) { + $taxIdValidators = $taxRate['isVat'] ? ['craft\commerce\taxidvalidators\EuVatIdValidator'] : []; + $this->update('{{%commerce_taxrates}}', ['taxIdValidators' => json_encode($taxIdValidators)], ['id' => $taxRate['id']]); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m250120_080035_move_to_tax_id_validators cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/TaxRate.php b/src/models/TaxRate.php index 126346800c..9d986b8496 100644 --- a/src/models/TaxRate.php +++ b/src/models/TaxRate.php @@ -9,6 +9,7 @@ use Craft; use craft\commerce\base\Model; +use craft\commerce\base\TaxIdValidatorInterface; use craft\commerce\Plugin; use craft\commerce\records\TaxRate as TaxRateRecord; use craft\errors\DeprecationException; @@ -63,16 +64,11 @@ class TaxRate extends Model public bool $removeIncluded = false; /** - * @var bool Whether an included VAT tax amount should be removed from VAT-disqualified subject prices + * @var bool Whether an included VAT ID tax amount should be removed from VAT-disqualified subject prices * @since 3.4 */ public bool $removeVatIncluded = false; - /** - * @var bool Whether this tax rate represents VAT - */ - public bool $isVat = false; - /** * @var string The subject to which `$rate` should be applied. Options: * - `price` – line item price @@ -94,6 +90,11 @@ class TaxRate extends Model */ public ?int $taxZoneId = null; + /** + * @var array Tax ID Validators + */ + public array $taxIdValidators = []; + /** * @var DateTime|null * @since 3.4 @@ -116,6 +117,16 @@ class TaxRate extends Model */ private ?TaxAddressZone $_taxZone = null; + /** + * @inheritdoc + */ + public function attributes(): array + { + $names = parent::attributes(); + $names[] = 'isVat'; // TODO remove in Commerce 6.x + return $names; + } + /** * @inheritdoc */ @@ -208,6 +219,63 @@ public function getIsEverywhere(): bool return !$this->getTaxZone(); } + /** + * @return bool + * @deprecated in 4.8.0. + */ + public function getIsVat(): bool + { + Craft::$app->getDeprecator()->log(__METHOD__, 'TaxRate::setIsVat() is deprecated.'); + + return $this->hasTaxIdValidators(); + } + + /** + * @param bool $isVat + * @deprecated in 4.8.0. + */ + public function setIsVat(bool $isVat): void + { + Craft::$app->getDeprecator()->log(__METHOD__, 'TaxRate::setIsVat() is deprecated.'); + } + + /** + * @return bool + * @since 4.8.0 + */ + public function hasTaxIdValidators(): bool + { + return count($this->taxIdValidators) > 0; + } + + /** + * @param string $className + * @return bool + * @since 4.8.0 + */ + public function hasTaxIdValidator(string $className): bool + { + return in_array($className, $this->taxIdValidators, true); + } + + /** + * @return TaxIdValidatorInterface[] + * @throws InvalidConfigException + * @since 4.8.0 + */ + public function getSelectedEnabledTaxIdValidators(): array + { + $selectedValidators = $this->taxIdValidators; + $validators = Plugin::getInstance()->getTaxes()->getEnabledTaxIdValidators(); + $activeValidators = []; + foreach ($validators as $validator) { + if (in_array($validator::class, $selectedValidators)) { + $activeValidators[] = $validator; + } + } + return $activeValidators; + } + /** * @return bool * @throws DeprecationException diff --git a/src/records/TaxRate.php b/src/records/TaxRate.php index 993ce40820..486447e33f 100644 --- a/src/records/TaxRate.php +++ b/src/records/TaxRate.php @@ -27,6 +27,7 @@ * @property int $taxCategoryId * @property TaxZone $taxZone * @property bool $isEverywhere + * @property array $taxIdValidators * @property int $taxZoneId * @author Pixel & Tonic, Inc. * @since 2.0 diff --git a/src/services/Gateways.php b/src/services/Gateways.php index e0cdac9cb4..d49dce97c9 100644 --- a/src/services/Gateways.php +++ b/src/services/Gateways.php @@ -138,6 +138,17 @@ public function getAllGateways(): array return ArrayHelper::where($this->_getAllGateways(), 'isArchived', false); } + /** + * @return array + * @throws DeprecationException + * @throws InvalidConfigException + * @sine 4.8.0 + */ + public function getAllArchivedGateways(): array + { + return ArrayHelper::where($this->_getAllGateways(), 'isArchived', true); + } + /** * Archives a gateway by its ID. * diff --git a/src/services/TaxRates.php b/src/services/TaxRates.php index 32c7295ae4..6acbf93a5a 100644 --- a/src/services/TaxRates.php +++ b/src/services/TaxRates.php @@ -122,13 +122,14 @@ public function saveTaxRate(TaxRate $model, bool $runValidation = true): bool // if not an included tax, then can not be removed. $record->include = $model->include; - $record->isVat = $model->isVat; + $record->isVat = $model->hasTaxIdValidators(); $record->removeIncluded = !$record->include ? false : $model->removeIncluded; $record->removeVatIncluded = (!$record->include || !$record->isVat) ? false : $model->removeVatIncluded; $record->taxable = $model->taxable; $record->taxCategoryId = $model->taxCategoryId; $record->taxZoneId = $model->taxZoneId ?: null; $record->isEverywhere = $model->getIsEverywhere(); + $record->taxIdValidators = $model->taxIdValidators; if (!$record->isEverywhere && $record->taxZoneId && empty($record->getErrors('taxZoneId'))) { $taxZone = Plugin::getInstance()->getTaxZones()->getTaxZoneById($record->taxZoneId); @@ -237,6 +238,11 @@ private function _createTaxRatesQuery(): Query ->orderBy(['include' => SORT_DESC, 'isVat' => SORT_DESC]) ->from([Table::TAXRATES]); + // add taxIdValidators select + if (Craft::$app->getDb()->columnExists(Table::TAXRATES, 'taxIdValidators')) { + $query->addSelect(['taxIdValidators']); + } + return $query; } } diff --git a/src/services/Taxes.php b/src/services/Taxes.php index 82aa990d8f..5c64ba9f4a 100644 --- a/src/services/Taxes.php +++ b/src/services/Taxes.php @@ -9,8 +9,12 @@ use craft\base\Component; use craft\commerce\base\TaxEngineInterface; +use craft\commerce\base\TaxIdValidatorInterface; use craft\commerce\engines\Tax; use craft\commerce\events\TaxEngineEvent; +use craft\commerce\events\TaxIdValidatorsEvent; +use craft\commerce\taxidvalidators\EuVatIdValidator; +use Illuminate\Support\Collection; use yii\base\InvalidConfigException; /** @@ -21,6 +25,27 @@ */ class Taxes extends Component implements TaxEngineInterface { + /** + * @event TaxIdValidatorsEvent The event that is raised when tax ID validators are registered. + * + * Any validator added must be a TaxIdValidatorInterface. + * + * ```php + * use craft\commerce\events\TaxIdValidatorsEvent; + * use craft\commerce\services\Taxes; + * use yii\base\Event; + * + * Event::on( + * Taxes::class, + * Taxes::EVENT_REGISTER_TAX_ID_VALIDATORS, + * function(TaxIdValidatorsEvent $event) { + * $event->validators[] = new MyTaxIdValidator(); + * } + * ); + * ``` + */ + public const EVENT_REGISTER_TAX_ID_VALIDATORS = 'registerTaxIdValidators'; + /** * @event TaxEngineEvent The event that is triggered when determining the tax engine. * @since 3.1 @@ -47,11 +72,56 @@ class Taxes extends Component implements TaxEngineInterface */ public const EVENT_REGISTER_TAX_ENGINE = 'registerTaxEngine'; + /** + * @var ?TaxEngineInterface $engine The tax engine + */ + private ?TaxEngineInterface $_taxEngine = null; + + /** + * @return Collection + * @throws InvalidConfigException + * @since 4.8.0 + */ + public function getTaxIdValidators(): Collection + { + $validators = []; + $validators[] = new EuVatIdValidator(); + + $event = new TaxIdValidatorsEvent([ + 'validators' => $validators, + ]); + + if ($this->hasEventHandlers(self::EVENT_REGISTER_TAX_ID_VALIDATORS)) { + $this->trigger(self::EVENT_REGISTER_TAX_ID_VALIDATORS, $event); + } + + foreach ($event->validators as $validator) { + if (!$validator instanceof TaxIdValidatorInterface) { + throw new InvalidConfigException('Tax ID validator must implement TaxIdValidatorInterface'); + } + } + + return collect($event->validators); + } + + /** + * @return Collection + * @throws InvalidConfigException + */ + public function getEnabledTaxIdValidators(): Collection + { + return $this->getTaxIdValidators()->filter(fn(TaxIdValidatorInterface $validator) => $validator::isEnabled()); + } + /** * Get the current tax engine. */ public function getEngine(): TaxEngineInterface { + if ($this->_taxEngine !== null) { + return $this->_taxEngine; + } + $event = new TaxEngineEvent(['engine' => new Tax()]); // Only allow third party tax engines for PRO edition @@ -64,7 +134,9 @@ public function getEngine(): TaxEngineInterface throw new InvalidConfigException('No tax engine has been registered.'); } - return $event->engine; + $this->_taxEngine = $event->engine; + + return $this->_taxEngine; } /** diff --git a/src/taxidvalidators/EuVatIdValidator.php b/src/taxidvalidators/EuVatIdValidator.php new file mode 100644 index 0000000000..3b9aa15bdf --- /dev/null +++ b/src/taxidvalidators/EuVatIdValidator.php @@ -0,0 +1,55 @@ + + */ +class EuVatIdValidator implements TaxIdValidatorInterface +{ + private Validator $_vatValidator; + + public function __construct() + { + $this->_vatValidator = new Validator(); + } + + public static function displayName(): string + { + return \Craft::t('commerce', 'EU VAT ID'); + } + + public function validateFormat(string $idNumber): bool + { + return $this->_vatValidator->validateFormat($idNumber); + } + + public function validateExistence(string $idNumber): bool + { + return $this->_vatValidator->validateExistence($idNumber); + } + + /** + * @inheritdoc + */ + public static function isEnabled(): bool + { + return true; + } + + public function validate(string $idNumber): bool + { + try { + return $this->_vatValidator->validate($idNumber); + } catch (\Exception $e) { + \Craft::error('Error validating EU VAT ID: ' . $e->getMessage()); + return false; + } + } +} diff --git a/src/templates/_layouts/store-settings.twig b/src/templates/_layouts/store-settings.twig index cb33efb1b9..6541d3e217 100644 --- a/src/templates/_layouts/store-settings.twig +++ b/src/templates/_layouts/store-settings.twig @@ -17,7 +17,7 @@ 'paymentcurrencies': { title: "Payment Currencies"|t('commerce')}, 'donation': { title: "Donations"|t('commerce')}, 'subscriptions': { heading: 'Subscriptions'|t('commerce')}, - 'subscription-plans': { title: 'Plans'|t('commerce')} + 'subscription-plans': { title: 'Subscription Plans'|t('commerce')} } %} {% endif %} diff --git a/src/templates/settings/emails/_edit.twig b/src/templates/settings/emails/_edit.twig index d702a3cb6b..d07790d50f 100644 --- a/src/templates/settings/emails/_edit.twig +++ b/src/templates/settings/emails/_edit.twig @@ -2,7 +2,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: "Emails"|t('commerce'), url: url('commerce/settings/emails') }, ] %} diff --git a/src/templates/settings/gateways/_edit.twig b/src/templates/settings/gateways/_edit.twig index 1c2634b63a..ceb1385209 100644 --- a/src/templates/settings/gateways/_edit.twig +++ b/src/templates/settings/gateways/_edit.twig @@ -2,7 +2,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: "Gateways"|t('commerce'), url: url('commerce/settings/gateways') }, ] %} diff --git a/src/templates/settings/gateways/index.twig b/src/templates/settings/gateways/index.twig index ccc76a7668..f72929180f 100644 --- a/src/templates/settings/gateways/index.twig +++ b/src/templates/settings/gateways/index.twig @@ -18,8 +18,25 @@ {{ 'New gateway'|t('commerce') }} {% endblock %} -{% block content %} -
+{% block main %} +
+ +
+
+
+ {% if archivedGateways|length %} +

+ {{ 'Show archived gateways'|t('commerce') }} +

+ + + {% endif %} +
{% endblock %} {% set tableData = [] %} @@ -73,4 +90,36 @@ reorderFailMessage: Craft.t('commerce', 'Couldn’t reorder gateways.'), tableData: {{ tableData|json_encode|raw }} }); + + {% if archivedGateways|length %} + new Craft.VueAdminTable({ + columns: [ + { name: 'id', title: Craft.t('commerce', 'ID') }, + { name: '__slot:title', title: Craft.t('commerce', 'Name') }, + { name: '__slot:handle', title: Craft.t('commerce', 'Handle') }, + { + name: 'type', + title: Craft.t('commerce', 'Type'), + callback: function(value) { + if (value.missing) { + return ''+value.name+''; + } + + return value.name; + } + }, + { + name: 'hasTransactions', + title: Craft.t('commerce', 'Has Transactions?'), + callback: function(value) { + if (value) { + return '
'; + } + } + }, + ], + container: '#gateways-archived-vue-admin-table', + tableData: {{ archivedGateways|json_encode|raw }} + }); + {% endif %} {% endjs %} diff --git a/src/templates/settings/lineitemstatuses/_edit.twig b/src/templates/settings/lineitemstatuses/_edit.twig index 97e17564b1..4ccf53435e 100644 --- a/src/templates/settings/lineitemstatuses/_edit.twig +++ b/src/templates/settings/lineitemstatuses/_edit.twig @@ -2,7 +2,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: 'Line Item Statuses'|t('commerce'), url: url('commerce/settings/lineitemstatuses') } ] %} diff --git a/src/templates/settings/orderstatuses/_edit.twig b/src/templates/settings/orderstatuses/_edit.twig index ca95d5978b..b0eb2881c1 100644 --- a/src/templates/settings/orderstatuses/_edit.twig +++ b/src/templates/settings/orderstatuses/_edit.twig @@ -2,7 +2,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: 'Order Statuses'|t('commerce'), url: url('commerce/settings/orderstatuses') } ] %} diff --git a/src/templates/settings/pdfs/_edit.twig b/src/templates/settings/pdfs/_edit.twig index fc35f8457c..0d1977eb2f 100644 --- a/src/templates/settings/pdfs/_edit.twig +++ b/src/templates/settings/pdfs/_edit.twig @@ -2,7 +2,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: "PDFs"|t('commerce'), url: url('commerce/settings/pdfs') }, ] %} diff --git a/src/templates/settings/producttypes/_edit.twig b/src/templates/settings/producttypes/_edit.twig index b4dffb2bc4..7172517646 100644 --- a/src/templates/settings/producttypes/_edit.twig +++ b/src/templates/settings/producttypes/_edit.twig @@ -3,7 +3,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, - { label: 'Settings'|t('app'), url: url('commerce/settings') }, + { label: 'Settings'|t('app'), url: url('commerce/settings'), ariaLabel: 'Commerce Settings'|t('commerce') }, { label: "Product Types"|t('commerce'), url: url('commerce/settings/producttypes') }, ] %} diff --git a/src/templates/store-settings/paymentcurrencies/_edit.twig b/src/templates/store-settings/paymentcurrencies/_edit.twig index 56d9b228ac..6ffc346b2e 100644 --- a/src/templates/store-settings/paymentcurrencies/_edit.twig +++ b/src/templates/store-settings/paymentcurrencies/_edit.twig @@ -3,7 +3,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, { label: 'Store Management'|t('commerce'), url: url('commerce/store-settings') }, - { label: "Currencies"|t('commerce'), url: url('commerce/store-settings/paymentcurrencies') }, + { label: "Payment Currencies"|t('commerce'), url: url('commerce/store-settings/paymentcurrencies') }, ] %} {% set selectedSubnavItem = 'store-settings' %} diff --git a/src/templates/store-settings/subscription-plans/_edit.twig b/src/templates/store-settings/subscription-plans/_edit.twig index 775c70cae7..65f528670e 100644 --- a/src/templates/store-settings/subscription-plans/_edit.twig +++ b/src/templates/store-settings/subscription-plans/_edit.twig @@ -4,7 +4,7 @@ {% set crumbs = [ { label: 'Commerce'|t('commerce'), url: url('commerce') }, { label: 'Store Management'|t('commerce'), url: url('commerce/store-settings') }, - { label: "Subscription plans"|t('commerce'), url: url('commerce/store-settings/subscription-plans') }, + { label: "Subscription Plans"|t('commerce'), url: url('commerce/store-settings/subscription-plans') }, ] %} {% set selectedSubnavItem = 'store-settings' %} diff --git a/src/templates/tax/taxrates/_fields.twig b/src/templates/tax/taxrates/_fields.twig index 776395409e..43a55896e6 100644 --- a/src/templates/tax/taxrates/_fields.twig +++ b/src/templates/tax/taxrates/_fields.twig @@ -174,13 +174,15 @@ ) }} {% set isVatInput %} + {% for taxIdValidator in taxIdValidators %} {{ forms.checkboxField({ - label: "EU VAT ID"|t('commerce'), - name: 'isVat', - checked: taxRate.isVat, - errors: taxRate.getErrors('isVat'), - toggle: '#isVatContainer' + label: taxIdValidator.displayName(), + name: 'taxIdValidators[' ~ className(taxIdValidator) ~ ']', + checked: taxRate.hasTaxIdValidator(className(taxIdValidator)), + errors: taxRate.getErrors('taxIdValidators'), + toggle: '#isTaxIdContainer' }) }} + {% endfor %} {% endset %} {{ forms.field({ @@ -258,11 +260,11 @@ }) }} -