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
};