diff --git a/Api/EnsManagementInterface.php b/Api/EnsManagementInterface.php new file mode 100644 index 00000000..96d9bf36 --- /dev/null +++ b/Api/EnsManagementInterface.php @@ -0,0 +1,63 @@ +config->getMerchantName(); } - /** - * @return string|null - */ - public function getPayeeEmail() - { - return $this->config->getPayeeEmail(); - } - /** * @return string */ diff --git a/CHANGELOG.md b/CHANGELOG.md index 648675aa..411622b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [master] - 2019-10 +## [3.4.0] - 2019-11-15 ### Added +- M1 to M2 Stored Card migration tool + - New `bin/magento braintree:migrate` console command to connect to your remote M1 database and potentially copy across customers + stored cards. This should be run whilst Braintree is in Production mode. +- Kount ENS webhook + - Allow "suspected fraud" orders in Magento to be accepted or decline by changing status in your Kount portal - CVV Re-verification for Stored Cards - This option can be enabled so that registered Customers need to provide the CVV in order to use a Stored Card - Information about Apple Pay on-boarding +- Information about Custom Fields ### Fixed - Level 2/3 Processing data now only used for Credit/Debit card transactions and now includes shipping tax @@ -17,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bug that stopped Admins creating orders in the backend when Braintree was the only payment method - API validation check now uses correct Store IDs when a multi-store is being used +### Removed +- Removed old PayPal `payee email` configuration option as it has been deprecated by Braintree + +## [3.3.3] +### Fixed +- Updated PayPal Credit APR percentages + ## [3.3.2] - 2019-09-26 ### Fixed - Level 2 / 3 Processing data should now only send shipping data if a shipping address is present. @@ -102,7 +115,8 @@ a bug in core Magento 2.3.1 means that if the Vault is turned off, cards are alw ### Fixed - Vaulted cards now work correctly -[master]: https://github.com/genecommerce/module-braintree-magento2/compare/3.3.2...master +[3.4.0]: https://github.com/genecommerce/module-braintree-magento2/compare/3.3.3...3.4.0 +[3.3.3]: https://github.com/genecommerce/module-braintree-magento2/compare/3.3.2...3.3.3 [3.3.2]: https://github.com/genecommerce/module-braintree-magento2/compare/3.3.1...3.3.2 [3.3.1]: https://github.com/genecommerce/module-braintree-magento2/compare/3.3.0...3.3.1 [3.3.0]: https://github.com/genecommerce/module-braintree-magento2/compare/3.2.1...3.3.0 diff --git a/Console/VaultMigrate.php b/Console/VaultMigrate.php new file mode 100644 index 00000000..3fe74f70 --- /dev/null +++ b/Console/VaultMigrate.php @@ -0,0 +1,406 @@ + 'AE', + 'discover' => 'DI', + 'jcb' => 'JCB', + 'mastercard' => 'MC', + 'master-card' => 'MC', + 'visa' => 'VI', + 'maestro' => 'MI', + 'diners-club' => 'DN', + 'unionpay' => 'CUP' + ]; + + /** + * @var $customers + */ + private $customers; + /** + * @var ConnectionFactory + */ + private $connectionFactory; + /** + * @var BraintreeAdapter + */ + private $braintreeAdapter; + /** + * @var CustomerRepositoryInterface + */ + private $customerRepository; + /** + * @var PaymentTokenFactory + */ + private $paymentToken; + /** + * @var PaymentTokenRepositoryInterface + */ + private $paymentTokenRepository; + /** + * @var SerializerInterface + */ + private $json; + /** + * @var EncryptorInterface + */ + private $encryptor; + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * VaultMigrate constructor. + * + * @param ConnectionFactory $connectionFactory + * @param BraintreeAdapter $braintreeAdapter + * @param CustomerRepositoryInterface $customerRepository + * @param PaymentTokenFactory $paymentToken + * @param PaymentTokenRepositoryInterface $paymentTokenRepository + * @param EncryptorInterface $encryptor + * @param SerializerInterface $json + * @param StoreManagerInterface $storeManager + */ + public function __construct( + ConnectionFactory $connectionFactory, + BraintreeAdapter $braintreeAdapter, + CustomerRepositoryInterface $customerRepository, + PaymentTokenFactory $paymentToken, + PaymentTokenRepositoryInterface $paymentTokenRepository, + EncryptorInterface $encryptor, + SerializerInterface $json, + StoreManagerInterface $storeManager + ) { + $this->connectionFactory = $connectionFactory; + $this->braintreeAdapter = $braintreeAdapter; + $this->customerRepository = $customerRepository; + $this->paymentToken = $paymentToken; + $this->paymentTokenRepository = $paymentTokenRepository; + $this->encryptor = $encryptor; + $this->json = $json; + $this->storeManager = $storeManager; + + parent::__construct(); + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this->setName('braintree:migrate'); + $this->setDescription('Migrate stored cards from a Magento 1 database'); + $this->setDefinition($this->getOptionsList()); + + parent::configure(); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + */ + protected function interact(InputInterface $input, OutputInterface $output) + { + /** @var QuestionHelper $questionHelper */ + $questionHelper = $this->getHelper('question'); + + if (!$input->getOption(self::HOST)) { + $question = new Question('Database host/IP address:', null); + $input->setOption(self::HOST, $questionHelper->ask($input, $output, $question)); + } + + if (!$input->getOption(self::DBNAME)) { + $question = new Question('Database name:', null); + $input->setOption(self::DBNAME, $questionHelper->ask($input, $output, $question)); + } + + if (!$input->getOption(self::USERNAME)) { + $question = new Question('Database username:', null); + $question->setHidden(true); + $input->setOption(self::USERNAME, $questionHelper->ask($input, $output, $question)); + } + + if (!$input->getOption(self::PASSWORD)) { + $question = new Question('Database user password:', null); + $question->setHidden(true); + $input->setOption(self::PASSWORD, $questionHelper->ask($input, $output, $question)); + } + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|void|null + * @throws NotFound + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $host = $input->getOption(self::HOST); + $databaseName = $input->getOption(self::DBNAME); + $username = $input->getOption(self::USERNAME); + $password = $input->getOption(self::PASSWORD); + + // Create connection to Magento 1 database + $db = $this->getDbConnection($host, $databaseName, $username, $password ?? ''); + + // Get the `braintree_customer_id` attribute ID + $eavAttributeId = $this->getEavAttributeId($db); + + if (!$eavAttributeId) { + $output->writeln('Could not find `braintree_customer_id` attribute.'); + return; + } + + // Find all instances of `braintree_customer_id` in the customer entity table + $storedCards = $this->getStoredCards($db, $output, $eavAttributeId); + + if (!$storedCards) { + $output->writeln('Could not find any stored cards.'); + return; + } + + // For each record, look up the Braintree ID + $braintreeCustomers = $this->findBraintreeCustomers($output, $storedCards); + + if (!$braintreeCustomers) { + $output->writeln('Could not find any matching customers in Magento 2.'); + return; + } + + $this->customers = $this->remapCustomerData($braintreeCustomers); + + if (!$this->customers) { + $output->writeln('Could not remap Customer data.'); + return; + } + + // For each customer, locate them in the M2 database and save their stored cards + $this->migrateStoredCards($output, $this->customers); + + return Cli::RETURN_SUCCESS; + } + + /** + * @return array + */ + public function getOptionsList(): array + { + return [ + new InputOption(self::HOST, null, InputOption::VALUE_REQUIRED, 'Hostname/IP. Port is optional'), + new InputOption(self::DBNAME, null, InputOption::VALUE_REQUIRED, 'Database name'), + new InputOption( + self::USERNAME, + null, + InputOption::VALUE_REQUIRED, + 'Database username. Must have read access' + ), + new InputOption(self::PASSWORD, null, InputOption::VALUE_REQUIRED, 'Password') + ]; + } + + /** + * @param string $host + * @param string $databaseName + * @param string $username + * @param string $password + * @return AdapterInterface + */ + private function getDbConnection( + string $host, + string $databaseName, + string $username, + string $password + ): AdapterInterface { + return $this->connectionFactory->create([ + 'host' => $host, + 'dbname' => $databaseName, + 'username' => $username, + 'password' => $password + ]); + } + + /** + * @param $db + * @return string + */ + private function getEavAttributeId(AdapterInterface $db): string + { + $select = $db->select() + ->where('attribute_code = ?', 'braintree_customer_id') + ->from(self::EAV_ATTRIBUTE_TABLE, self::ATTRIBUTE_ID); + return $db->fetchOne($select); + } + + /** + * @param AdapterInterface $db + * @param OutputInterface $output + * @param $eavAttributeId + * @return mixed + */ + private function getStoredCards(AdapterInterface $db, OutputInterface $output, $eavAttributeId) + { + $select = $db->select() + ->join('customer_entity', 'customer_entity.entity_id = customer_entity_varchar.entity_id') + ->where(self::ATTRIBUTE_ID . ' = ?', $eavAttributeId) + ->from(self::CUSTOMER_ENTITY_TABLE, self::VALUE . ' as braintree_id'); + $result = $db->fetchAll($select); + + if ($result) { + $output->writeln(''. count($result) .' stored cards found'); + return $result; + } + + return false; + } + + /** + * @param OutputInterface $output + * @param $storedCards + * @return array + * @throws NotFound + */ + private function findBraintreeCustomers(OutputInterface $output, $storedCards): array + { + $customers = []; + foreach ($storedCards as $storedCard) { + $output->writeln('Search Braintree for Customer ID ' . $storedCard['braintree_id'] . '...'); + $customers[] = $this->braintreeAdapter->getCustomerById($storedCard['braintree_id']); + } + + return $customers; + } + + /** + * @param $customers + * @return array + */ + public function remapCustomerData($customers): array + { + $remappedCustomerData = []; + + foreach ($customers as $customer) { + $customerData = [ + 'braintree_id' => $customer->id, + 'email' => $customer->email + ]; + + if ($customer->creditCards) { + // grab each stored credit card + foreach ($customer->creditCards as $creditCard) { + $customerData['storedCards'][] = [ + 'token' => $creditCard->token, + 'expirationMonth' => $creditCard->expirationMonth, + 'expirationYear' => $creditCard->expirationYear, + 'last4' => $creditCard->last4, + 'cardType' => self::CC_MAPPER[str_replace(' ', '-', strtolower($creditCard->cardType))] + ]; + } + } + + // Add customer data to the main customer array + $remappedCustomerData[] = $customerData; + } + + return $remappedCustomerData; + } + + /** + * @param OutputInterface $output + * @param array $customers + */ + private function migrateStoredCards(OutputInterface $output, array $customers) + { + $websites = $this->storeManager->getWebsites(); + + foreach ($websites as $website) { + foreach ($customers as $customer) { + try { + $m2Customer = $this->customerRepository->get($customer['email'], $website->getId()); + + $output->write( + "Customer {$customer['braintree_id']} found in {$website->getName()}..." + ); + + foreach ($customer['storedCards'] as $storedCard) { + // Create new vault payment token. + $vaultPaymentToken = $this->paymentToken->create(PaymentTokenFactory::TOKEN_TYPE_CREDIT_CARD); + $vaultPaymentToken->setCustomerId($m2Customer->getId()); + $vaultPaymentToken->setPaymentMethodCode('braintree'); + $vaultPaymentToken->setExpiresAt( + sprintf( + '%s-%s-01 00:00:00', + $storedCard['expirationYear'], + $storedCard['expirationMonth'] + ) + ); + $vaultPaymentToken->setGatewayToken($storedCard['token']); + $vaultPaymentToken->setTokenDetails($this->json->serialize([ + 'type' => $storedCard['cardType'], + 'maskedCC' => $storedCard['last4'], + 'expirationDate' => $storedCard['expirationMonth'] . '/' . $storedCard['expirationYear'] + ])); + $vaultPaymentToken->setPublicHash( + $this->encryptor->getHash( + $m2Customer->getId() + . $vaultPaymentToken->getPaymentMethodCode() + . $vaultPaymentToken->getType() + . $vaultPaymentToken->getTokenDetails() + ) + ); + + if ($this->paymentTokenRepository->save($vaultPaymentToken)) { + $output->writeln('Card stored successfully!'); + } + } + } catch (NoSuchEntityException $e) { + $output->writeln( + "Customer {$customer['braintree_id']} not found in {$website->getName()}." + ); + } catch (LocalizedException $e) { + $output->writeln("{$e->getMessage()}"); + } + } + } + + $output->writeln('Migration complete!'); + } +} diff --git a/Controller/Kount/Ens.php b/Controller/Kount/Ens.php new file mode 100644 index 00000000..a9f49984 --- /dev/null +++ b/Controller/Kount/Ens.php @@ -0,0 +1,103 @@ +ensConfig = $ensConfig; + $this->remoteAddress = $remoteAddress; + $this->xmlSecurity = $xmlSecurity; + } + + /** + * @return ResponseInterface|ResultInterface + * @throws LocalizedException + * @throws Exception + */ + public function execute() + { + $response = $this->resultFactory->create(ResultFactory::TYPE_JSON); + + if (!$this->isAllowed()) { + $response->setHttpResponseCode(401); + return $response; + } + + $request = $this->getRequest()->getContent(); + + if (!$this->xmlSecurity->scan($request)) { + $response->setHttpResponseCode(400); + return $response; + } + + $xml = simplexml_load_string($request); + + if (empty($xml['merchant'])) { + throw new LocalizedException(__('Invalid ENS XML')); + } + + if (!$this->ensConfig->validateMerchantId((string) $xml['merchant'])) { + throw new LocalizedException(__('Invalid Merchant ID')); + } + + foreach ($xml->children() as $event) { + $this->ensConfig->processEvent($event); + } + + return $response; + } + + /** + * @return bool + */ + public function isAllowed(): bool + { + return $this->ensConfig->isAllowed($this->remoteAddress->getRemoteAddress()); + } +} diff --git a/Gateway/Config/Config.php b/Gateway/Config/Config.php index ebd200b8..03ff9686 100755 --- a/Gateway/Config/Config.php +++ b/Gateway/Config/Config.php @@ -36,6 +36,7 @@ class Config extends \Magento\Payment\Gateway\Config\Config const VALUE_3DSECURE_ALL = 0; const CODE_3DSECURE = 'three_d_secure'; const KEY_KOUNT_MERCHANT_ID = 'kount_id'; + const KEY_KOUNT_SKIP_ADMIN = 'kount_skip_admin'; const FRAUD_PROTECTION = 'fraudprotection'; const FRAUD_PROTECTION_THRESHOLD = 'fraudprotection_threshold'; @@ -260,6 +261,16 @@ public function getKountMerchantId() ); } + /** + * @return bool + * @throws InputException + * @throws NoSuchEntityException + */ + public function canSkipAdminFraudProtection(): bool + { + return (bool) $this->getValue(self::KEY_KOUNT_SKIP_ADMIN, $this->storeConfigResolver->getStoreId()); + } + /** * Get Merchant Id * diff --git a/Gateway/Config/PayPal/Config.php b/Gateway/Config/PayPal/Config.php index 81dacafb..b6c23c5a 100644 --- a/Gateway/Config/PayPal/Config.php +++ b/Gateway/Config/PayPal/Config.php @@ -22,7 +22,6 @@ class Config extends \Magento\Payment\Gateway\Config\Config const KEY_ALLOW_TO_EDIT_SHIPPING_ADDRESS = 'allow_shipping_address_override'; const KEY_MERCHANT_NAME_OVERRIDE = 'merchant_name_override'; const KEY_REQUIRE_BILLING_ADDRESS = 'require_billing_address'; - const KEY_PAYEE_EMAIL = 'payee_email'; const KEY_PAYPAL_DISABLED_FUNDING_CHECKOUT = 'disabled_funding_checkout'; const KEY_PAYPAL_DISABLED_FUNDING_CART = 'disabled_funding_cart'; const KEY_PAYPAL_DISABLED_FUNDING_PDP = 'disabled_funding_productpage'; @@ -142,16 +141,6 @@ public function getTitle() return $this->getValue(self::KEY_TITLE); } - /** - * Get payee email - * - * @return string|null - */ - public function getPayeeEmail() - { - return $this->getValue(self::KEY_PAYEE_EMAIL); - } - /** * Retrieve the button style config values * diff --git a/Gateway/Request/FraudDataBuilder.php b/Gateway/Request/FraudDataBuilder.php index d3ba7451..fb14020f 100755 --- a/Gateway/Request/FraudDataBuilder.php +++ b/Gateway/Request/FraudDataBuilder.php @@ -1,7 +1,11 @@ + * + * Add logical checks to enable/disable fraud checks. */ class FraudDataBuilder implements BuilderInterface { @@ -27,30 +31,40 @@ class FraudDataBuilder implements BuilderInterface * @var SubjectReader $subjectReader */ private $subjectReader; + /** + * @var State + */ + private $state; /** * FraudDataBuilder constructor. + * * @param Config $config * @param SubjectReader $subjectReader + * @param State $state */ public function __construct( Config $config, - SubjectReader $subjectReader + SubjectReader $subjectReader, + State $state ) { $this->config = $config; $this->subjectReader = $subjectReader; + $this->state = $state; } /** - * Skip advanced fraud checks if the order amount is equal to or greater than the defined threshold * @inheritdoc + * @throws LocalizedException */ public function build(array $buildSubject): array { $threshold = $this->config->getFraudProtectionThreshold(); $amount = $this->formatPrice($this->subjectReader->readAmount($buildSubject)); - if ($threshold && $amount >= $threshold) { + if (($threshold && $amount >= $threshold) || + ($this->state->getAreaCode() === Area::AREA_ADMINHTML && $this->config->canSkipAdminFraudProtection()) + ) { return [ 'options' => [self::SKIP_ADVANCED_FRAUD_CHECKING => true] ]; diff --git a/Gateway/Request/PayPal/PayeeDataBuilder.php b/Gateway/Request/PayPal/PayeeDataBuilder.php deleted file mode 100755 index fe6ad16e..00000000 --- a/Gateway/Request/PayPal/PayeeDataBuilder.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -class PayeeDataBuilder implements BuilderInterface -{ - /** - * @var Config - */ - private $config; - - /** - * PayeeDataBuilder constructor. - * @param Config $config - */ - public function __construct(Config $config) - { - $this->config = $config; - } - - /** - * @inheritdoc - */ - public function build(array $buildSubject): array - { - $email = $this->config->getPayeeEmail(); - if ($email) { - return [ - 'options' => [ - 'paypal' => [ - 'payeeEmail' => $email - ] - ] - ]; - } - - return []; - } -} diff --git a/Gateway/Response/RiskDataHandler.php b/Gateway/Response/RiskDataHandler.php index e2e8cd69..997175ad 100755 --- a/Gateway/Response/RiskDataHandler.php +++ b/Gateway/Response/RiskDataHandler.php @@ -6,9 +6,11 @@ namespace Magento\Braintree\Gateway\Response; use Braintree\Transaction; +use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Gateway\Helper\ContextHelper; use Magento\Braintree\Gateway\Helper\SubjectReader; use Magento\Payment\Gateway\Response\HandlerInterface; +use Magento\Sales\Model\Order\Payment; /** * Class RiskDataHandler @@ -51,6 +53,7 @@ public function __construct(SubjectReader $subjectReader) * @param array $handlingSubject * @param array $response * @return void + * @throws LocalizedException */ public function handle(array $handlingSubject, array $response) { @@ -63,14 +66,17 @@ public function handle(array $handlingSubject, array $response) return; } + /** @var Payment $payment */ $payment = $paymentDO->getPayment(); ContextHelper::assertOrderPayment($payment); $payment->setAdditionalInformation(self::RISK_DATA_ID, $transaction->riskData->id); $payment->setAdditionalInformation(self::RISK_DATA_DECISION, $transaction->riskData->decision); - // mark payment as fraud + // Mark payment as fraud if ($transaction->riskData->decision === self::$statusReview) { + // We have to set the transaction to pending, so it is not captured right away. + $payment->setIsTransactionPending(true); $payment->setIsFraudDetected(true); } } diff --git a/Model/Adapter/BraintreeAdapter.php b/Model/Adapter/BraintreeAdapter.php index 4f22131e..b686a706 100755 --- a/Model/Adapter/BraintreeAdapter.php +++ b/Model/Adapter/BraintreeAdapter.php @@ -8,6 +8,9 @@ use Braintree\ClientToken; use Braintree\Configuration; use Braintree\CreditCard; +use Braintree\Customer; +use Braintree\CustomerSearch; +use Braintree\Exception\NotFound; use Braintree\PaymentMethod; use Braintree\PaymentMethodNonce; use Braintree\ResourceCollection; @@ -167,6 +170,15 @@ public function search(array $filters) return Transaction::search($filters); } + /** + * @param string $id + * @return Transaction|null + */ + public function findById(string $id) + { + return Transaction::find($id); + } + /** * @param string $token * @return Successful|Error @@ -243,4 +255,14 @@ public function updatePaymentMethod($token, $attribs) { return PaymentMethod::update($token, $attribs); } + + /** + * @param $id + * @return Customer + * @throws NotFound + */ + public function getCustomerById($id) + { + return Customer::find($id); + } } diff --git a/Model/Config/Source/KountEnsUrl.php b/Model/Config/Source/KountEnsUrl.php new file mode 100644 index 00000000..d19976ad --- /dev/null +++ b/Model/Config/Source/KountEnsUrl.php @@ -0,0 +1,51 @@ +scopeConfig = $scopeConfig; + } + + /** + * {@inheritDoc} + * @param AbstractElement $element + * @return string + */ + public function _getElementHtml(AbstractElement $element): string + { + $baseUrl = $this->scopeConfig->getValue('web/secure/base_url'); + return $baseUrl . self::ENS_URL; + } +} diff --git a/Model/Kount/EnsConfig.php b/Model/Kount/EnsConfig.php new file mode 100644 index 00000000..65e876be --- /dev/null +++ b/Model/Kount/EnsConfig.php @@ -0,0 +1,359 @@ +scopeConfig = $scopeConfig; + $this->storeManager = $storeManager; + $this->order = $order; + $this->braintreeAdapter = $braintreeAdapter; + $this->transactionFactory = $transactionFactory; + $this->creditmemoFactory = $creditmemoFactory; + $this->creditmemoService = $creditmemoService; + $this->orderRepository = $orderRepository; + } + + /** + * @return array + */ + public function getAllowedIps(): array + { + $ips = $this->scopeConfig->getValue(self::CONFIG_ALLOWED_IPS); + return $ips ? explode(',', $ips) : []; + } + + /** + * This method will check if a given IP (as a string) falls into a valid range, e.g "192.168.0.0/255". + * + * @param string $ip + * @param string $range + * @return bool + */ + public function isIpInRange(string $ip, string $range): bool + { + // If no "range" is set, add the full range of 255. + if (strpos($range, '/') === false) { + $range .= '/255'; + } + + // $range is in IP/CIDR format eg 127.0.0.1/255 + list($range, $netmask) = explode('/', $range, 2); // Get the starting IP and the netmask of the IP range. + + $range_decimal = ip2long($range); + $ip_decimal = ip2long($ip); + $wildcard_decimal = (2 ** (32 - $netmask)) - 1; + $netmask_decimal = ~ $wildcard_decimal; + + return (($ip_decimal & $netmask_decimal) === ($range_decimal & $netmask_decimal)); + } + + /** + * @param string $remoteAddress + * @return bool + */ + public function isAllowed(string $remoteAddress): bool + { + $allowedIps = $this->getAllowedIps(); + + if (!$allowedIps) { + return true; + } + + foreach ($allowedIps as $allowedIp) { + if ($this->isIpInRange($remoteAddress, $allowedIp)) { + return true; + } + } + + return false; + } + + /** + * @param string $merchantId + * @return bool + */ + public function validateMerchantId(string $merchantId): bool + { + $stores = $this->storeManager->getStores(); + + foreach ($stores as $store) { + $storeMerchantId = $this->scopeConfig->getValue( + self::CONFIG_KOUNT_ID, + ScopeInterface::SCOPE_STORE, + $store->getId() + ); + + if ($storeMerchantId === $merchantId) { + return true; + } + } + + return false; + } + + /** + * @param SimpleXMLElement $event + * @return bool + * @throws Exception + */ + public function processEvent(SimpleXMLElement $event): bool + { + if ((string) $event->name === self::ENS_WORKFLOW_EDIT) { + return $this->workflowStatusEdit($event); + } + + return false; + } + + /** + * @param $event + * @return bool + * @throws Exception + */ + public function workflowStatusEdit($event): bool + { + $incrementId = $this->getIncrementId($event); + $kountTransactionId = $this->getKountTransactionId($event); + + if ($incrementId && $kountTransactionId) { + /** @var Order $order */ + $order = $this->order->loadByIncrementId($incrementId); + + if ($order) { + /** @var Payment $payment */ + $payment = $order->getPayment(); + $paymentKountId = $payment->getAdditionalInformation('riskDataId'); + + if ($kountTransactionId === $paymentKountId) { + if ((string) $event->old_value === self::RESPONSE_REVIEW || + (string) $event->old_value === self::RESPONSE_ESCALATE + ) { + if ((string) $event->new_value === self::RESPONSE_APPROVE) { + return $this->approveOrder($order); + } + + if ((string) $event->new_value === self::RESPONSE_DECLINE) { + return $this->declineOrder($order); + } + } + } + } + } + + return false; + } + + /** + * @param $event + * @return int|null + */ + public function getIncrementId($event) + { + if (isset($event->key['order_number'])) { + return (int) $event->key['order_number']; + } + + return null; + } + + /** + * @param $event + * @return string|null + */ + public function getKountTransactionId($event) + { + if (isset($event->key)) { + return (string) $event->key; + } + + return null; + } + + /** + * @param OrderInterface $order + * @return bool + * @throws Exception + */ + public function approveOrder(OrderInterface $order): bool + { + /** @var Order $order */ + if ($order->getStatus() === Order::STATUS_FRAUD || $order->getStatus() === Order::STATE_PAYMENT_REVIEW) { + $order->getPayment()->accept(); + $this->orderRepository->save($order); + return true; + } + + return false; + } + + /** + * @param OrderInterface $order + * @return bool + * @throws Exception + */ + public function declineOrder(OrderInterface $order): bool + { + /** @var Order $order */ + if ($order->getStatus() === Order::STATUS_FRAUD || $order->getStatus() === Order::STATE_PAYMENT_REVIEW) { + $braintreeId = $order->getPayment()->getCcTransId(); + + /** @var Transaction $braintreeTransaction */ + $braintreeTransaction = $this->braintreeAdapter->findById($braintreeId); + + if ($braintreeTransaction) { + if ($braintreeTransaction->status === Transaction::AUTHORIZED + || $braintreeTransaction->status === Transaction::SUBMITTED_FOR_SETTLEMENT) { + return $this->voidOrder($order); + } + + if ($braintreeTransaction->status === Transaction::SETTLED) { + return $this->refundOrder($order); + } + } + } + + return false; + } + + /** + * @param OrderInterface $order + * @return bool + * @throws Exception + */ + public function voidOrder(OrderInterface $order): bool + { + /** @var Collection $invoices */ + $invoices = $order->getInvoiceCollection(); + + if (count($invoices->getItems()) > 0) { + foreach ($invoices as $invoice) { + /** @var Invoice $invoice */ + $invoice->void(); + $invoice->getOrder()->setStatus(Order::STATE_CANCELED); + $invoice->getOrder()->addCommentToStatusHistory( + __('Order declined through Kount, order voided in Magento.') + ); + + $this->transactionFactory->create() + ->addObject($invoice) + ->addObject($invoice->getOrder()) + ->save(); + + } + } elseif ($order->getPayment()) { + $order->getPayment()->deny(); + $this->orderRepository->save($order); + } + + return true; + } + + /** + * @param OrderInterface $order + * @return bool + * @throws LocalizedException + */ + public function refundOrder(OrderInterface $order): bool + { + /** @var Collection $invoices */ + $invoices = $order->getInvoiceCollection(); + + if (count($invoices->getItems()) > 0) { + foreach ($invoices as $invoice) { + /** @var Invoice $invoice */ + if ($invoice->getState() !== Order\Invoice::STATE_PAID) { + $invoice->pay(); + } + + if ($invoice->canRefund()) { + $creditMemo = $this->creditmemoFactory->createByInvoice($invoice); + $creditMemo->setInvoice($invoice); + $this->creditmemoService->refund($creditMemo); + } + } + + return true; + } + + return false; + } +} diff --git a/Model/Ui/PayPal/ConfigProvider.php b/Model/Ui/PayPal/ConfigProvider.php index 9812ddde..0ce016c1 100644 --- a/Model/Ui/PayPal/ConfigProvider.php +++ b/Model/Ui/PayPal/ConfigProvider.php @@ -65,7 +65,6 @@ public function getConfig(): array 'title' => $this->config->getTitle(), 'isAllowShippingAddressOverride' => $this->config->isAllowToEditShippingAddress(), 'merchantName' => $this->config->getMerchantName(), - 'payeeEmail' => $this->config->getPayeeEmail(), 'locale' => $this->resolver->getLocale(), 'paymentAcceptanceMarkSrc' => 'https://www.paypalobjects.com/webstatic/en_US/i/buttons/pp-acceptance-medium.png', @@ -83,7 +82,6 @@ public function getConfig(): array 'title' => __('PayPal Credit'), 'isAllowShippingAddressOverride' => $this->config->isAllowToEditShippingAddress(), 'merchantName' => $this->config->getMerchantName(), - 'payeeEmail' => $this->config->getPayeeEmail(), 'locale' => $this->resolver->getLocale(), 'paymentAcceptanceMarkSrc' => 'https://www.paypalobjects.com/webstatic/en_US/i/buttons/ppc-acceptance-medium.png', diff --git a/README.md b/README.md index d8c867f4..65e470ab 100755 --- a/README.md +++ b/README.md @@ -27,6 +27,35 @@ This module overwrites the original Magento Braintree module, to provide additio ## Additional Features +### M1 to M2 Stored Card migration tool +If you are looking to migrate to M2 and want to offer the best experience for existing customers by migrating their stored +credit cards, this is now possible with the new console command. + +To use the new command, ensure that +- Your M1 database is online and accessible +- Your M2 store is in Braintree Production mode +- You have already migrated the customers from M1 to M2 + +Run the following command on your M2 server + +`bin/magento braintree:migrate --host= --dbname=` + +You will be prompted for the DB Username and Password and after that, the tool will query your M1 DB, find any stored cards +and locate them in your Braintree account (this is why you must run it with Braintree in Production mode). +Any matching records that are found are then queried in your M2 database, and the card details* are stored for that customer. + + +* Credit Card information is stored by way of a token that matches a Vault record in Braintree. +No sensitive card data is ever exposed. + + +### Kount ENS Webhook +If your Kount and Braintree accounts have been linked, you can now configure Braintree with your Kount Merchant ID to +enable the ENS webhook. Add the ENS URL to your Kount portal (more info in the configuration options) and any orders +that get flagged as "Review" or "Escalate" can be accepted or declined through Kount. The ENS webhook in Magento will +pick up this status change and handle the Magento Order accordingly. +More information available [here](https://articles.braintreepayments.com/guides/fraud-tools/advanced/kount-custom). + ### Custom Fields If you would like to add [Custom Fields](https://articles.braintreepayments.com/control-panel/custom-fields) to your Braintree transactions, we provide an example module [here](https://github.com/genecommerce/module-braintree-customfields-example) diff --git a/Test/Unit/Console/VaultMigrateTest.php b/Test/Unit/Console/VaultMigrateTest.php new file mode 100644 index 00000000..043b5900 --- /dev/null +++ b/Test/Unit/Console/VaultMigrateTest.php @@ -0,0 +1,141 @@ +connectionFactoryMock = $this->createMock(ConnectionFactory::class); + $this->braintreeAdapterMock = $this->createMock(BraintreeAdapter::class); + $this->customerRepositoryMock = $this->createMock(CustomerRepositoryInterface::class); + $this->paymentTokenFactoryMock = $this->createMock(PaymentTokenFactory::class); + $this->paymentTokenRepositoryMock = $this->createMock(PaymentTokenRepositoryInterface::class); + $this->encryptorMock = $this->createMock(EncryptorInterface::class); + $this->jsonMock = $this->createMock(SerializerInterface::class); + + $this->command = new VaultMigrate( + $this->connectionFactoryMock, + $this->braintreeAdapterMock, + $this->customerRepositoryMock, + $this->paymentTokenFactoryMock, + $this->paymentTokenRepositoryMock, + $this->encryptorMock, + $this->jsonMock + ); + } + + /** + * @param $customers + * @dataProvider remapCustomerDataDataProvider + */ + public function testRemapCustomerData($customers) + { + $foo = $this->command->remapCustomerData($customers); + $this->assertArrayHasKey('braintree_id', $foo[0]); + $this->assertArrayHasKey('email', $foo[0]); + $this->assertArrayHasKey('storedCards', $foo[0]); + $this->assertGreaterThanOrEqual(1, $foo[0]['storedCards']); + } + + /** + * @return array + */ + public function remapCustomerDataDataProvider(): array + { + return [ + [ + [ + (object) [ + 'id' => '886658184', + 'email' => 'roni_cost@example.com', + 'creditCards' => [ + (object) [ + 'token' => '5p7529', + 'expirationMonth' => '01', + 'expirationYear' => '2021', + 'last4' => '1000', + 'cardType' => 'Visa' + ] + ] + ] + ] + ] + ]; + } + + /** + * @param $description + * @dataProvider getOptionListDataProvider + */ + public function testGetOptionsList($description) + { + /* @var \Symfony\Component\Console\Input\InputArgument[] $argsList */ + $argsList = $this->command->getOptionsList(); + + $this->assertEquals(VaultMigrate::HOST, $argsList[0]->getName()); + $this->assertEquals($description, $argsList[0]->getDescription()); + } + + /** + * @return array + */ + public function getOptionListDataProvider() + { + return [ + [ + 'description' => 'Hostname/IP. Port is optional' + ] + ]; + } +} diff --git a/composer.json b/composer.json index 8a1cc939..0c74ccc6 100755 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "gene/module-braintree", "description": "Fork from the Magento Braintree 2.2.0 module by Gene Commerce for PayPal.", - "version": "3.3.3", + "version": "3.4.0", "type": "magento2-module", "license": "proprietary", "require": { @@ -19,6 +19,7 @@ "magento/module-theme": "100.2.*||101.0.*", "magento/module-ui": "101.0.*||101.1.*", "ext-json": "*", + "ext-simplexml": "*", "php": "^7.0", "league/iso3166": "^2.1" }, diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 7431d92a..773680b2 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -135,25 +135,62 @@ payment/braintree_cc_vault/title - + If you don't specify the merchant account to use to process a transaction, Braintree will process it using your default merchant account. payment/braintree/merchant_account_id - + Magento\Config\Model\Config\Source\Yesno Be sure to Enable Advanced Fraud Protection in Your Braintree Account in Settings/Processing Section payment/braintree/fraudprotection - - - accounts@braintreepayments.com to setup your Kount account.]]> + + + + Magento\Config\Block\System\Config\Form\Fieldset 1 - payment/braintree/kount_id - + + + + This is the ENS URL you will need to add into your website in the Kount AWC control panel. + This URL must be publicly accessible for the ENS to function correctly. + You'll need to add this ENS URL to the 'OPT-IN' website. + + Magento\Braintree\Model\Config\Source\KountEnsUrl + + + + + accounts@braintreepayments.com to setup your Kount account. + ]]> + + payment/braintree/kount_id + + + + + Prevents the transaction from being sent to Kount for evaluation as part of Advanced + Fraud Tools checks, on orders placed through the admin only. + + Magento\Config\Model\Config\Source\Yesno + payment/braintree/kount_skip_admin + + + + + The IPs that have access to the ENS endpoint above. These can be individual IP's or + ranges separated with commas. To allow from all IPs leave this field empty (not recommended). + + payment/braintree/kount_allowed_ips + + + Advanced fraud protection checks will be bypassed if this threshold is met or exceeded. Leaving this field blank will disable this option. @@ -382,11 +419,6 @@ payment/braintree_paypal/display_on_shopping_cart Also affects mini-shopping cart. - - - payment/braintree_paypal/payee_email - Consult Braintree Support before enabling this option. - diff --git a/etc/config.xml b/etc/config.xml index 80be7089..a0067814 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -39,6 +39,7 @@ cvv,number avsPostalCodeResponseCode,avsStreetAddressResponseCode,cvvResponseCode,processorAuthorizationCode,processorResponseCode,processorResponseText,liabilityShifted,liabilityShiftPossible,riskDataId,riskDataDecision,transactionSource cc_type,cc_number,avsPostalCodeResponseCode,avsStreetAddressResponseCode,cvvResponseCode,processorAuthorizationCode,processorResponseCode,processorResponseText,liabilityShifted,liabilityShiftPossible,riskDataId,riskDataDecision,transactionSource + 208.75.112.0/22,209.81.12.0/24 BraintreePayPalFacade diff --git a/etc/di.xml b/etc/di.xml index 5bcae982..0c0119e3 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -341,7 +341,6 @@ Magento\Braintree\Gateway\Request\TransactionSourceDataBuilder Magento\Braintree\Gateway\Request\CustomFieldsDataBuilder Magento\Braintree\Gateway\Request\PayPal\VaultDataBuilder - Magento\Braintree\Gateway\Request\PayPal\PayeeDataBuilder Magento\Braintree\Gateway\Request\PayPal\DeviceDataBuilder Magento\Braintree\Gateway\Request\DescriptorDataBuilder Magento\Braintree\Gateway\Request\AddressDataBuilder @@ -380,7 +379,6 @@ Magento\Braintree\Gateway\Request\CustomFieldsDataBuilder Magento\Braintree\Gateway\Request\AddressDataBuilder Magento\Braintree\Gateway\Request\DescriptorDataBuilder - Magento\Braintree\Gateway\Request\PayPal\PayeeDataBuilder @@ -1033,4 +1031,18 @@ + + + + + Magento\Braintree\Console\VaultMigrate + + + + + + + Magento\Braintree\Model\Adapter\BraintreeAdapter\Proxy + + diff --git a/etc/module.xml b/etc/module.xml index bb05cd13..6d7f2c18 100755 --- a/etc/module.xml +++ b/etc/module.xml @@ -6,7 +6,7 @@ */ --> - + diff --git a/view/frontend/templates/paypal/button.phtml b/view/frontend/templates/paypal/button.phtml index 67b88ba7..ce934673 100644 --- a/view/frontend/templates/paypal/button.phtml +++ b/view/frontend/templates/paypal/button.phtml @@ -25,7 +25,6 @@ $config = [ 'environment' => $block->getEnvironment(), 'clientToken' => $block->getClientToken(), 'displayName' => $block->getMerchantName(), - 'payeeEmail' => $block->getPayeeEmail(), 'actionSuccess' => $block->getActionSuccess(), 'offerCredit' => false, 'fundingicons' => $block->getDisabledFunding(), @@ -42,7 +41,6 @@ if ($block->isCreditActive()) { 'environment' => $block->getEnvironment(), 'clientToken' => $block->getClientToken(), 'displayName' => $block->getMerchantName(), - 'payeeEmail' => $block->getPayeeEmail(), 'actionSuccess' => $block->getActionSuccess(), 'offerCredit' => true, 'shape' => $block->getButtonShape(), diff --git a/view/frontend/templates/paypal/product_page.phtml b/view/frontend/templates/paypal/product_page.phtml index a1a0f18b..b7450be0 100644 --- a/view/frontend/templates/paypal/product_page.phtml +++ b/view/frontend/templates/paypal/product_page.phtml @@ -21,7 +21,6 @@ $config = [ 'environment' => $block->getEnvironment(), 'clientToken' => $block->getClientToken(), 'displayName' => $block->getMerchantName(), - 'payeeEmail' => $block->getPayeeEmail(), 'actionSuccess' => $block->getActionSuccess(), 'offerCredit' => false, 'fundingicons' => true, @@ -39,7 +38,6 @@ if ($block->isCreditActive()) { 'environment' => $block->getEnvironment(), 'clientToken' => $block->getClientToken(), 'displayName' => $block->getMerchantName(), - 'payeeEmail' => $block->getPayeeEmail(), 'actionSuccess' => $block->getActionSuccess(), 'offerCredit' => true, 'shape' => $block->getButtonShape(), diff --git a/view/frontend/web/js/paypal/button.js b/view/frontend/web/js/paypal/button.js index 813d134b..fec3c3c0 100644 --- a/view/frontend/web/js/paypal/button.js +++ b/view/frontend/web/js/paypal/button.js @@ -52,11 +52,6 @@ define( */ clientToken: null, - /** - * {String} - */ - payeeEmail: null, - /** * {String} */ @@ -135,9 +130,6 @@ define( currency: $this.data('currency'), flow: 'checkout', enableShippingAddress: true, - payee: { - email: this.payeeEmail - }, displayName: this.displayName, offerCredit: this.offerCredit };