From 28d91eb69b1059da0f1e2b875f711652634ef27a Mon Sep 17 00:00:00 2001 From: Isabelle Date: Fri, 11 Sep 2026 15:57:19 +0200 Subject: [PATCH] Initial commit: ws_vatcheck at v1.0.3 Includes the fix for the GR/EL VAT prefix bug that broke exemption for every Greek VAT number (in_array-on-values vs array_key_exists in CacheTaxManager/VatNumber, plus the missing use import in EuValidatorAdapter). Adds IMPROVEMENTS.txt with a checklist of features adopted from comparing against the vatchecker module (offline-fallback policy, failure logging, company-name check, configurable EU country list, per-product exclusion, GB/XI mapping, AJAX live validation, address-priority strategy). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + IMPROVEMENTS.txt | 66 +++++++++++++++ composer.json | 29 +++++++ composer.lock | 91 ++++++++++++++++++++ config.xml | 11 +++ config/services.yml | 8 ++ config_nl.xml | 11 +++ index.php | 35 ++++++++ logo.png | Bin 0 -> 2067 bytes src/Adapter/EuValidatorAdapter.php | 44 ++++++++++ src/Command/BaseCommand.php | 47 +++++++++++ src/Command/BulkCheckCommand.php | 62 ++++++++++++++ src/Command/CleanCommand.php | 31 +++++++ src/Config/Config.php | 24 ++++++ src/Console/WebInput.php | 89 ++++++++++++++++++++ src/Console/WebOutput.php | 88 ++++++++++++++++++++ src/Model/VatCache.php | 60 ++++++++++++++ src/Model/VatNumber.php | 129 +++++++++++++++++++++++++++++ src/Module/Admin/AdminHooks.php | 13 +++ src/Module/Front/FrontHooks.php | 71 ++++++++++++++++ src/Module/Install.php | 56 +++++++++++++ src/Service/LoggingService.php | 52 ++++++++++++ src/TaxManager/CacheTaxManager.php | 54 ++++++++++++ upgrade/upgrade-1.0.2.php | 10 +++ ws_vatcheck.php | 54 ++++++++++++ 25 files changed, 1136 insertions(+) create mode 100644 .gitignore create mode 100644 IMPROVEMENTS.txt create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config.xml create mode 100644 config/services.yml create mode 100644 config_nl.xml create mode 100644 index.php create mode 100644 logo.png create mode 100644 src/Adapter/EuValidatorAdapter.php create mode 100644 src/Command/BaseCommand.php create mode 100644 src/Command/BulkCheckCommand.php create mode 100644 src/Command/CleanCommand.php create mode 100644 src/Config/Config.php create mode 100644 src/Console/WebInput.php create mode 100644 src/Console/WebOutput.php create mode 100644 src/Model/VatCache.php create mode 100644 src/Model/VatNumber.php create mode 100644 src/Module/Admin/AdminHooks.php create mode 100644 src/Module/Front/FrontHooks.php create mode 100644 src/Module/Install.php create mode 100644 src/Service/LoggingService.php create mode 100644 src/TaxManager/CacheTaxManager.php create mode 100644 upgrade/upgrade-1.0.2.php create mode 100644 ws_vatcheck.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48b8bf9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/IMPROVEMENTS.txt b/IMPROVEMENTS.txt new file mode 100644 index 0000000..d6bae6c --- /dev/null +++ b/IMPROVEMENTS.txt @@ -0,0 +1,66 @@ +ws_vatcheck — planned improvements +adapted from a comparison against the "vatchecker" module (Inform-All & Keraweb), 2026-09-11 +cache TTL (1 year) explicitly kept as-is, not on this list. + +[ ] 1. Configurable VIES-offline fallback policy + - Add a setting (e.g. VATCHECK_OFFLINE_MODE) with explicit modes instead of an + implicit fail-closed default: + - always mark invalid (current implicit behavior) + - always mark valid + - reuse last cached result, fall back to invalid if none + - reuse last cached result, fall back to valid if none + - Directly related to the GR/EL bug fixed this session: the fallback behavior + should be an explicit, chosen policy, not an accidental side effect of + whether an exception happens to get caught. + +[ ] 2. Log every VIES check failure + - Currently EuValidatorAdapter::checkAndStore() has no logging at all on + failure (this silence was a big part of why the GR/EL bug took so long to + diagnose). Add PrestaShopLogger::addLog(...) (or equivalent) whenever the + Europa/Validator call throws, including the VAT number, country code, and + the fault message. + +[ ] 3. Company-name cross-check against VIES response + - When VIES returns a company name for a validated VAT number, optionally + compare it (normalized: lowercase, strip punctuation/legal-entity suffixes + like bv/gmbh/ltd/nv/sa) against the address's company field. Make this + togglable, since not all member states return a name. + +[ ] 4. Configurable EU country allow-list + - Replace hardcoded VatNumber::$euIso-driven behavior with an admin-configurable + list of which EU countries are allowed to order VAT-free, similar to + vatchecker's VATCHECKER_EU_COUNTRIES setting. + +[ ] 5. Per-product VAT exclusion + - Some products (e.g. certain digital services) may need to always charge VAT + regardless of customer VAT-exempt status. Add a product-level flag/table + (mirroring vatchecker_excluded_products) and consult it before applying the + 0% tax manager for a given product line. + +[ ] 6. GB -> XI (Northern Ireland) mapping + - VatNumber::$euIso has no entry at all for the UK/Northern Ireland. If this + shop (or any future ws_vatcheck deployment) ships to Northern Ireland with an + XI-prefixed VAT number, it will hit the same class of key/value bug already + fixed for Greece. Add the mapping and cover it with the same key-based checks. + +[ ] 7. Front-end AJAX live VAT validation + - Add an AJAX front controller + JS so the address form validates the VAT + number as the customer types, instead of only on form submit + (hookActionValidateCustomerAddressForm). Mirrors vatchecker's ajax.php + + front.js/front.css pattern. Improves UX and surfaces bad VAT numbers earlier. + +[ ] 8. Configurable address-priority strategy + - Add a setting for which address's VAT number is authoritative when invoice + and delivery addresses differ (shipping only / invoice only / prioritize + invoice / prioritize shipping), instead of relying on implicit PS tax-address + resolution. Mirrors vatchecker's VATCHECKER_ADDRESS_SELECT. + +Explicitly deferred / not adopting: +- Shortening the 1-year cache TTL (Config::CACHE_VALIDITY) — decided to leave as-is. +- Class overrides (Carrier/Product) as used by vatchecker — do not port this pattern; + it conflicts with other modules that override the same core classes and is fragile + across PS core upgrades. If shipping/product tax rule assignment ever needs + behavior beyond what the taxManager hook covers, investigate a hook-based solution + first before reaching for an override. +- Legacy $this->l() / translations/{iso}.php system — ws_vatcheck already uses + $this->trans() correctly per house convention for PS >= 1.7.6.0. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b2c2a48 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "websmid/vatcheck", + "description": "Maakt het vrijstellen van BTW mogelijk via een gecachte vat check", + "authors": [ + { + "name": "Johan van der Klis - De Websmid BV", + "email": "johan@dewebsmid.nl" + } + ], + "require": { + "ph-7/eu-vat-validator": "3.1" + }, + "autoload": { + "psr-4": { + "Websmid\\VatCheck\\": "src/" + }, + "classmap": [ + "ws_vatcheck.php" + ], + "exclude-from-classmap": [] + }, + "config": { + "preferred-install": "dist", + "prepend-autoloader": false + }, + "type": "prestashop-module", + "author": "Johan van der Klis - De Websmid BV", + "license": "MIT" +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..4d1805e --- /dev/null +++ b/composer.lock @@ -0,0 +1,91 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "ccb92e4629eb4c104b85c939daa54a7c", + "packages": [ + { + "name": "ph-7/eu-vat-validator", + "version": "3.1", + "source": { + "type": "git", + "url": "https://github.com/pH-7/eu-vat-validator.git", + "reference": "03dc5305984ad71a71c363ab4cb1f691a5d9408b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pH-7/eu-vat-validator/zipball/03dc5305984ad71a71c363ab4cb1f691a5d9408b", + "reference": "03dc5305984ad71a71c363ab4cb1f691a5d9408b", + "shasum": "" + }, + "require": { + "ext-soap": "*", + "php": ">=7.1.0" + }, + "require-dev": { + "phake/phake": "^3.1", + "phpunit/phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "PH7\\Eu\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "Pierre-Henry Soria", + "email": "hi@ph7.me", + "homepage": "https://github.com/pH-7/eu-vat-validator", + "role": "Software Engineer" + } + ], + "description": "A simple and clean PHP class that validates EU VAT numbers against the central ec.europa.eu database (using the official europa API).", + "homepage": "https://pierrehenry.be", + "keywords": [ + "TVA", + "eu vat", + "tva number", + "validate eu vat", + "validate vat number", + "validation", + "validator", + "vat", + "vat number", + "vat number validator", + "vat validator", + "vies" + ], + "support": { + "issues": "https://github.com/pH-7/eu-vat-validator/issues", + "source": "https://github.com/pH-7/eu-vat-validator/tree/3.1" + }, + "funding": [ + { + "url": "https://github.com/pH-7", + "type": "github" + }, + { + "url": "https://ko-fi.com/phenry", + "type": "ko_fi" + } + ], + "time": "2023-01-04T11:50:01+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config.xml b/config.xml new file mode 100644 index 0000000..dc19e99 --- /dev/null +++ b/config.xml @@ -0,0 +1,11 @@ + + + ws_vatcheck + + + + + + 0 + 0 + \ No newline at end of file diff --git a/config/services.yml b/config/services.yml new file mode 100644 index 0000000..6b21f91 --- /dev/null +++ b/config/services.yml @@ -0,0 +1,8 @@ +services: + Websmid\VatCheck\Command\CleanCommand: + class: Websmid\VatCheck\Command\CleanCommand + tags: ['console.command'] + + Websmid\VatCheck\Command\BulkCheckCommand: + class: Websmid\VatCheck\Command\BulkCheckCommand + tags: ['console.command'] \ No newline at end of file diff --git a/config_nl.xml b/config_nl.xml new file mode 100644 index 0000000..dc19e99 --- /dev/null +++ b/config_nl.xml @@ -0,0 +1,11 @@ + + + ws_vatcheck + + + + + + 0 + 0 + \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..dedd77e --- /dev/null +++ b/index.php @@ -0,0 +1,35 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License 3.0 (AFL-3.0) + */ + +header("Expires: Mon, 26 Jul 1997 05:00:00 GMT"); +header("Last-Modified: ".gmdate("D, d M Y H:i:s")." GMT"); + +header("Cache-Control: no-store, no-cache, must-revalidate"); +header("Cache-Control: post-check=0, pre-check=0", false); +header("Pragma: no-cache"); + +header("Location: ../"); +exit; diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d0df3a7b6fae6eb0bdb19e8274efe41d564a4aa6 GIT binary patch literal 2067 zcmaJ?X;f2Z8V&_niYS{P0`?|A0g;3R5=bNjNZ3h$ut+PVX1PGnn;UWi3DHteQDBsz zgPz)vQvsD_WD`Y^9SX7uG9xI9s8tZq)TKhHfH;X2+aJT+bMO6@^E}V{zTf+t^JRy! z{PaHE_9+U5(hKlsu$3dA`m{Bbe?%^yR}N+ppJ+)q94C=-#30Ix4|72v0OG`hY>>lG zP3i^RQ7CncAR<~4%?zgSU2a6eu$-9Q85V;iHL&3Nu^S()ENtl<8gR0nXJ+v5FC{VM)6 zb_;}jSc=%c$1|Cf03jmb2zg)tgNjiqU(_F8@z1ab2#;3Xn({nE{FgDInih3`4;Ck|~1qb0Ms&_kqh_ zKNsh9xj1DqI8|f+(&)7M%sk_J78(|m518t6&V*$@gAM2M~v{g zCks02)!^`S(HA2p4Tp@)(^=8MpZ!!+4J-#uXZ4O0ARF>Fn@v?DqucX+a;?jfOncO5 zJ-5^R2O5eJ=!VU6dUh}GaU}+avj#32RG4I(IC{8tkJZV0#;(kefta#m<_}soMnt>i zo-_BnM1qCSkN&5mrN+W8uBi3ay!y>^6e}mD0?mg*=D+k=< z`lY||B0LIK8qB=alkX0dY|G4@W+V;y`+0qG-hC?IlJ%&*d3n~?)TM2nVGW1tAAe@> zH_gs}Xg(@fMk#)6eB2$IIo7{;k=q-rUZwNTgK~rK^`+G|o`j79QxWsWQfzF4Z2f5a z;>#yPVzP0bu8v*sBmcDH9pl}(+mDQnHivz_^==;-vwCw&d!^SVlKo1J#j98|@;$Xe z?Y|qr6^PFDFOE1QZ4BuMI_EczPQ5(U5N$E;Q)hFvAJYyFT7_75>KAYPj(pCzR0Cca z%RJxs?bJ*3(BtP6OqblV`l9D&YqF;TT><@Qm=7`cMKZ?B^vx-+vb2Cs=G3Hp*YWV{!O1lDV{W0CD{x{ zBp=izea#P%50cWIQ}Va$LQBzUPY4S+p2nAq@SORKm&U{78TTsi~SZ-0=<-&Gi*V2>Iz9({s!cE|gr>l{o!4cuE zA)SKLl(V_lV}UoA_EqU?I&PKSr)BZ6>C(LV)@$>+?dcfw^?`egvi%wn5pRnt_ zmZKTlh;F1cxw^!prZz3x13tQEdD|^rK*;giB@5%bo1=Q1*|?k?x9HpJC!e`p-D!9v zP~0=KcsE8ZWZG_4r~AxOLG#G<>21a|Hx0L$0rXj~;R;4<&w;&RjN#cA^!bFGEwyKK zui?A6IhlUp&T}nU2`>9hkJsIPw@9rSeUvqMvatNr = str_replace($isoCode, '', $vatNr); + $this->isoCode = $isoCode; + } + + public function checkAndStore() + { + try { + $euVatValidator = new Validator(new Europa, $this->vatNr, $this->isoCode); + } catch(Exception $e){ + return false; + } + + if( $euVatValidator instanceof Validator ){ + $fullVat = $this->isoCode.$this->vatNr; + $vatCache = VatCache::getOrCreateByVat($fullVat); + $vatCache->vat_nr = $fullVat; + $vatCache->valid = $euVatValidator->check(); + + $vatCache->save(); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php new file mode 100644 index 0000000..3b78fdb --- /dev/null +++ b/src/Command/BaseCommand.php @@ -0,0 +1,47 @@ +logger)) { + $this->logger->log($message, $object, $id); + } else { + $now = (new \DateTime())->format('Y-m-d H:i:s'); + $line = $now . ' --> ' . $message . PHP_EOL; + $this->logs[] = $line; + $this->output->write($line); + } + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->output = $output; + $this->input = $input; + + set_time_limit(0); + ini_set('memory_limit', '-1'); + + $this->executeCommand(); + } + + public abstract function executeCommand(); +} \ No newline at end of file diff --git a/src/Command/BulkCheckCommand.php b/src/Command/BulkCheckCommand.php new file mode 100644 index 0000000..833044d --- /dev/null +++ b/src/Command/BulkCheckCommand.php @@ -0,0 +1,62 @@ +setName('vatcheck:bulkcheck'); + } + + public function executeCommand() + { + $this->log('Start process'); + $this->logger = new LoggingService(); + $this->logger->setConsole($this->output); + //$this->logger->enablePrestaLog(); + + $vatInfosToCheck = Db::getInstance()->executeS(' + SELECT + `vat_number`, `id_country` + FROM + `'._DB_PREFIX_.'address` + WHERE + `vat_number` NOT IN( + SELECT + `vat_nr` + FROM + `'._DB_PREFIX_.'vat_cache` + ) AND `vat_number` != "" + '); + + foreach($vatInfosToCheck as $vatInfo){ + + $isoCode = Country::getIsoById((int)$vatInfo['id_country']); + $vatNumber = new VatNumber($vatInfo['vat_number'], $isoCode); + + if( $vatNumber->hasErrors() || $vatNumber->isNotEuIso() ){ + continue; + } + + $euValidator = new EuValidatorAdapter($vatNumber->getFormattedVat(), $isoCode); + $euValidator->checkAndStore(); + + $this->log('Iteration end, now ging to sleep 3s'); + sleep(3); + } + + return $this->log('Stored VATS checked!'); + } +} \ No newline at end of file diff --git a/src/Command/CleanCommand.php b/src/Command/CleanCommand.php new file mode 100644 index 0000000..a866f3e --- /dev/null +++ b/src/Command/CleanCommand.php @@ -0,0 +1,31 @@ +setName('vatcheck:cleanrecords'); + } + + public function executeCommand() + { + $this->log('Start process'); + $this->logger = new LoggingService(); + $this->logger->setConsole($this->output); + //$this->logger->enablePrestaLog(); + + VatCache::cleanOldRecords(); + + return $this->log('Records cleaned!'); + } +} \ No newline at end of file diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..3ae3daf --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,24 @@ + 'Vrijgestelde intracommunautaire levering – artikel 39bis W.BTW', + 'fr' => 'Livraison intracommunautaire exonérée de TVA – article 39bis du Code TVA', + 'en' => 'Intra-Community supply exempt from VAT according to Article 39bis of the Belgian VAT Code.', + 'de' => 'Von der Mehrwertsteuer befreite innergemeinschaftliche Lieferung – Artikel 39bis des belgischen Mehrwertsteuergesetzbuches', + ]; +} \ No newline at end of file diff --git a/src/Console/WebInput.php b/src/Console/WebInput.php new file mode 100644 index 0000000..6c9a8ec --- /dev/null +++ b/src/Console/WebInput.php @@ -0,0 +1,89 @@ +cleaned) { + //ob_end_flush(); + $this->cleaned = true; + } + echo $messages . '
'; + $this->flush_buffers(); + } + + public function flush_buffers() + { + //$result = ob_get_contents(); + //echo $result; + flush(); + } + + public function writeln($messages, $options = 0) + { + // TODO: Implement writeln() method. + } + + public function setVerbosity($level) + { + // TODO: Implement setVerbosity() method. + } + + public function getVerbosity() + { + // TODO: Implement getVerbosity() method. + } + + public function isQuiet() + { + // TODO: Implement isQuiet() method. + } + + public function isVerbose() + { + // TODO: Implement isVerbose() method. + } + + public function isVeryVerbose() + { + // TODO: Implement isVeryVerbose() method. + } + + public function isDebug() + { + // TODO: Implement isDebug() method. + } + + public function setDecorated($decorated) + { + // TODO: Implement setDecorated() method. + } + + public function isDecorated() + { + // TODO: Implement isDecorated() method. + } + + public function setFormatter(OutputFormatterInterface $formatter) + { + // TODO: Implement setFormatter() method. + } + + public function getFormatter() + { + // TODO: Implement getFormatter() method. + } +} \ No newline at end of file diff --git a/src/Model/VatCache.php b/src/Model/VatCache.php new file mode 100644 index 0000000..252f189 --- /dev/null +++ b/src/Model/VatCache.php @@ -0,0 +1,60 @@ + 'vat_cache', + 'primary' => 'id_vat', + 'fields' => [ + 'vat_nr' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'required' => true], + 'valid' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool', 'required' => true], + 'date_add' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'], + 'date_upd' => ['type' => self::TYPE_DATE, 'validate' => 'isDate'], + ], + ]; + + public static function getIdByVat(string $vatNr) + { + return Db::getInstance()->getValue('SELECT `id_vat` FROM `'._DB_PREFIX_.'vat_cache` WHERE `vat_nr` = "'.pSQL($vatNr).'" AND `date_add` > DATE_SUB(NOW(), INTERVAL '.(int)Config::CACHE_VALIDITY.' YEAR)'); + } + + public static function checkVatIsCached(string $vatNr) + { + return (bool)self::getIdByVat($vatNr); + } + + public static function checkVatIsValid(string $vatNr) + { + return (bool)Db::getInstance()->getValue('SELECT `valid` FROM `'._DB_PREFIX_.'vat_cache` WHERE `vat_nr` = "'.pSQL($vatNr).'" ORDER BY `id_vat` DESC'); + } + + public static function getOrCreateByVat(string $vatNr) + { + $className = get_called_class(); + $idVat = $className::getIdByVat($vatNr); + + return ( empty($idVat) ) ? new $className() : new $className($idVat); + } + + public static function cleanOldRecords() + { + return Db::getInstance()->execute('DELETE FROM `'._DB_PREFIX_.'vat_cache` WHERE `date_add` < DATE_SUB(NOW(), INTERVAL '.(int)Config::CACHE_VALIDITY.' YEAR)'); + } +} \ No newline at end of file diff --git a/src/Model/VatNumber.php b/src/Model/VatNumber.php new file mode 100644 index 0000000..a96449f --- /dev/null +++ b/src/Model/VatNumber.php @@ -0,0 +1,129 @@ + 'AT', + 'BE' => 'BE', + 'DK' => 'DK', + 'FI' => 'FI', + 'FR' => 'FR', + 'FX' => 'FR', + 'DE' => 'DE', + 'GR' => 'EL', + 'IE' => 'IE', + 'IT' => 'IT', + 'LU' => 'LU', + 'NL' => 'NL', + 'PT' => 'PT', + 'ES' => 'ES', + 'SE' => 'SE', + 'CY' => 'CY', + 'EE' => 'EE', + 'HU' => 'HU', + 'LV' => 'LV', + 'LT' => 'LT', + 'MT' => 'MT', + 'PL' => 'PL', + 'SK' => 'SK', + 'CZ' => 'CZ', + 'SI' => 'SI', + 'RO' => 'RO', + 'BG' => 'BG', + 'HR' => 'HR', + ]; + + private $isoPattern = [ + 'AT' => 'U[A-Z\d]{8}', + 'BE' => '[0|1]{1}\d{9}', + 'BG' => '\d{9,10}', + 'CY' => '\d{8}[A-Z]', + 'CZ' => '\d{8,10}', + 'DE' => '\d{9}', + 'DK' => '(\d{2} ?){3}\d{2}', + 'EE' => '\d{9}', + 'GR' => '\d{9}', + 'ES' => '[A-Z]\d{7}[A-Z]|\d{8}[A-Z]|[A-Z]\d{8}', + 'FI' => '\d{8}', + 'FR' => '([A-Z0-9]{2})\d{9}', + 'FX' => '([A-Z0-9]{2})\d{9}', + 'HR' => '\d{11}', + 'HU' => '\d{8}', + 'IE' => '[A-Z\d]{8}|[A-Z\d]{9}', + 'IT' => '\d{11}', + 'LT' => '(\d{9}|\d{12})', + 'LU' => '\d{8}', + 'LV' => '\d{11}', + 'MT' => '\d{8}', + 'NL' => '\d{9}B\d{2}', + 'PL' => '\d{10}', + 'PT' => '\d{9}', + 'RO' => '\d{2,10}', + 'SE' => '\d{12}', + 'SI' => '\d{8}', + 'SK' => '\d{10}', + ]; + + public function __construct( + string $rawVatNumber, + string $rawIsoCode + ) { + $this->vatNumber = $this->commonFormatVatNumber($rawVatNumber); + + $this->isoCode = strtoupper($rawIsoCode); + + $this->validateVatNr(); + } + + protected function validateVatNr() + { + $vatPrefix = self::$euIso[$this->isoCode] ?? $this->isoCode; + + if( substr($this->vatNumber, 0, 2) != $vatPrefix ){ + $this->errorMessage = Context::getContext()->getTranslator()->trans('ISO in VAT number doesn\'t seem to match selected country', [], 'Modules.Ws_VatCheck.Front'); + } + + $trimmedVat = str_replace($vatPrefix, '', $this->vatNumber); + $isoPattern = $this->isoPattern[$this->isoCode]; + + if( !preg_match('/^(?:'.$isoPattern.')$/', $trimmedVat) ){ + $this->errorMessage = Context::getContext()->getTranslator()->trans('VAT number doesn\'t seem to be valid', [], 'Modules.Ws_VatCheck.Front'); + } + } + + public function getFormattedVat() + { + return $this->vatNumber; + } + + public function hasErrors() + { + return !empty($this->errorMessage); + } + + public function isNotEuIso() + { + return !array_key_exists($this->isoCode, self::$euIso); + } + + private function commonFormatVatNumber(string $rawVatNumber) + { + return strtoupper(preg_replace('/[ .]/', '', $rawVatNumber)); + } + + public function getErrorMessage() + { + return $this->errorMessage; + } +} \ No newline at end of file diff --git a/src/Module/Admin/AdminHooks.php b/src/Module/Admin/AdminHooks.php new file mode 100644 index 0000000..5572a2f --- /dev/null +++ b/src/Module/Admin/AdminHooks.php @@ -0,0 +1,13 @@ +getValue('vat_number'); + $vatNumber = str_replace(' ', '', $vatNumber); + + if (empty($vatNumber)) { + return true; + } + + $countryIso = (string)Country::getIsoById((string)$form->getValue('id_country')); + $vatNumber = new VatNumber($vatNumber, $countryIso); + + if( $vatNumber->isNotEuIso() ){ + return true; + } + + $form->setValue('vat_number', $vatNumber->getFormattedVat()); + if( $vatNumber->hasErrors() ){ + $form->getField('vat_number')->addError($vatNumber->getErrorMessage()); + } + + return $vatNumber->hasErrors(); + } + + public function hookTaxManager($args) + { + if (call_user_func(['Websmid\VatCheck\TaxManager\CacheTaxManager', 'isAvailableForThisAddress'], $args['address'])) { + return new CacheTaxManager(); + } + + return false; + } + + public function hookDisplayInvoiceLegalFreeText(array $params) + { + $order = $params['order']; + $address = new Address((int) $order->{Configuration::get('PS_TAX_ADDRESS_TYPE')}); + + if (!CacheTaxManager::isAvailableForThisAddress($address)) { + return false; + } + + $existingText = Configuration::get('PS_INVOICE_LEGAL_FREE_TEXT', (int) $order->id_lang, null, (int) $order->id_shop); + + return trim($existingText . "\n\n" . $this->getIntraCommunityExemptionText((int) $order->id_lang)); + } + + private function getIntraCommunityExemptionText(int $idLang) + { + $isoCode = strtolower(Language::getIsoById($idLang)); + + return Config::INTRACOMMUNITY_EXEMPTION_TEXTS[$isoCode] ?? Config::INTRACOMMUNITY_EXEMPTION_TEXTS['en']; + } +} \ No newline at end of file diff --git a/src/Module/Install.php b/src/Module/Install.php new file mode 100644 index 0000000..46307bb --- /dev/null +++ b/src/Module/Install.php @@ -0,0 +1,56 @@ +createTables()) { + return false; + } + + if (!$this->registerHooks()) { + return false; + } + + return true; + } + + private function registerHooks() + { + foreach (Config::HOOKS as $hook) { + if (!$this->registerHook($hook)) { + return false; + } + } + + return true; + } + + private function createTables() + { + $sqlToExec = []; + $sqlToExec[] = ' + CREATE TABLE IF NOT EXISTS '._DB_PREFIX_.'vat_cache ( + `id_vat` INTEGER(12) PRIMARY KEY AUTO_INCREMENT, + `vat_nr` VARCHAR(256), + `valid` TINYINT(1), + `date_add` DATE NOT NULL, + `date_upd` DATE NOT NULL + );'; + + foreach($sqlToExec as $sqlBlock){ + Db::getInstance()->execute($sqlBlock); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Service/LoggingService.php b/src/Service/LoggingService.php new file mode 100644 index 0000000..2d635d6 --- /dev/null +++ b/src/Service/LoggingService.php @@ -0,0 +1,52 @@ +output = $output; + return $this; + } + + public function enableEcho() : self + { + $this->echo = true; + return $this; + } + + public function enablePrestaLog() : self + { + $this->presta = true; + return $this; + } + + public function log($message, $object = null, $object_id = null) + { + $now = (new \DateTime())->format('Y-m-d H:i:s'); + $line = $now . ' --> '.$message . PHP_EOL; + $this->logs[] = $line; + if (!is_null($this->output)) { + $this->output->write($line); + } + + if ($this->echo) { + echo $line; + } + + if ($this->presta) { + \PrestaShopLogger::addLog($message, 1, null, $object, $object_id, true); + } + } +} \ No newline at end of file diff --git a/src/TaxManager/CacheTaxManager.php b/src/TaxManager/CacheTaxManager.php new file mode 100644 index 0000000..5256ad0 --- /dev/null +++ b/src/TaxManager/CacheTaxManager.php @@ -0,0 +1,54 @@ +vat_number) ){ + return false; + } + + if( (int)$address->id_country == (int)Configuration::get('PS_COUNTRY_DEFAULT') ){ + return false; + } + + $isoCode = Country::getIsoById((int)$address->id_country); + if( !array_key_exists($isoCode, VatNumber::$euIso) ){ + return false; + } + + if( VatCache::checkVatIsCached($address->vat_number) ){ + return VatCache::checkVatIsValid($address->vat_number); + } + + $vatPrefix = VatNumber::$euIso[$isoCode]; + $euValidator = new EuValidatorAdapter($address->vat_number, $vatPrefix); + if( !$euValidator->checkAndStore() ){ + return false; + } + + return VatCache::checkVatIsValid($address->vat_number); + } + + public function getTaxCalculator() + { + $tax = new Tax(); + $tax->rate = 0; + + return new TaxCalculator([$tax]); + } +} diff --git a/upgrade/upgrade-1.0.2.php b/upgrade/upgrade-1.0.2.php new file mode 100644 index 0000000..c541260 --- /dev/null +++ b/upgrade/upgrade-1.0.2.php @@ -0,0 +1,10 @@ +registerHook('displayInvoiceLegalFreeText'); +} diff --git a/ws_vatcheck.php b/ws_vatcheck.php new file mode 100644 index 0000000..d3d2f02 --- /dev/null +++ b/ws_vatcheck.php @@ -0,0 +1,54 @@ + + * @copyright 2007-2024 De Websmid BV + * @license Valid for 1 website (or project) for each purchase of license + * International Registered Trademark & Property of De Websmid BV + */ + +use Websmid\VatCheck\Module\Install; +use Websmid\VatCheck\Module\Front\FrontHooks; +use Websmid\VatCheck\Module\Admin\AdminHooks; + +if (file_exists(__DIR__ . '/vendor/autoload.php')) { + require_once __DIR__ . '/vendor/autoload.php'; +} + +if (!defined('_PS_VERSION_')) { + exit; +} +class Ws_VatCheck extends \Module +{ + use Install; + use AdminHooks; + use FrontHooks; + + public function __construct() + { + $this->name = 'ws_vatcheck'; + $this->version = '1.0.3'; + $this->author = 'Johan van der Klis | De Websmid BV'; + $this->need_instance = 0; + $this->bootstrap = true; + + parent::__construct(); + + $this->displayName = $this->trans('Vat Check', [], 'Modules.Ws_VatCheck.Admin'); + $this->description = $this->trans('Gecachte vat check', [], 'Modules.Ws_VatCheck.Description'); + $this->ps_versions_compliancy = ['min' => '1.7.0.0', 'max' => _PS_VERSION_]; + } +}