diff --git a/Api/ServiceInterface.php b/Api/ServiceInterface.php index 4f5ae42..2527e9d 100644 --- a/Api/ServiceInterface.php +++ b/Api/ServiceInterface.php @@ -16,6 +16,7 @@ namespace Airwallex\Payments\Api; use Airwallex\Payments\Api\Data\PlaceOrderResponseInterface; +use Exception; use Magento\Quote\Api\Data\AddressInterface; use Magento\Quote\Api\Data\PaymentInterface; @@ -55,4 +56,41 @@ public function airwallexPlaceOrder( * @return string */ public function redirectUrl(): string; + + /** + * Get express data when initialize and quote data updated + * + * @return string + */ + public function expressData(); + + /** + * Add to cart + * + * @return string + */ + public function addToCart(); + + /** + * Post Address to get method and quote data + * + * @return string + */ + public function postAddress(); + + /** + * Apple pay validate merchant + * + * @return string + * @throws Exception + */ + public function validateMerchant(); + + /** + * Validate addresses before placing order + * + * @return string + * @throws Exception + */ + public function validateAddresses(); } diff --git a/Helper/AvailablePaymentMethodsHelper.php b/Helper/AvailablePaymentMethodsHelper.php index 5d5217c..a3ac082 100644 --- a/Helper/AvailablePaymentMethodsHelper.php +++ b/Helper/AvailablePaymentMethodsHelper.php @@ -21,6 +21,7 @@ use Magento\Framework\App\CacheInterface; use Magento\Framework\Serialize\SerializerInterface; use Magento\Store\Model\StoreManagerInterface; +use Magento\Checkout\Helper\Data as CheckoutData; class AvailablePaymentMethodsHelper { @@ -51,6 +52,17 @@ class AvailablePaymentMethodsHelper */ private Configuration $configuration; + /** + * @var array + */ + + private CheckoutData $checkoutHelper; + + protected $methodsInExpress = [ + 'googlepay', + 'applepay', + ]; + /** * AvailablePaymentMethodsHelper constructor. * @@ -65,13 +77,15 @@ public function __construct( CacheInterface $cache, SerializerInterface $serializer, StoreManagerInterface $storeManager, - Configuration $configuration + Configuration $configuration, + CheckoutData $checkoutHelper ) { $this->availablePaymentMethod = $availablePaymentMethod; $this->cache = $cache; $this->storeManager = $storeManager; $this->serializer = $serializer; $this->configuration = $configuration; + $this->checkoutHelper = $checkoutHelper; } /** @@ -98,6 +112,9 @@ public function isMobileDetectInstalled(): bool */ public function isAvailable(string $code): bool { + if ($code === 'express') { + return $this->canInitialize() && !!array_intersect($this->methodsInExpress, $this->getAllMethods()); + } return $this->canInitialize() && in_array($code, $this->getAllMethods(), true); } @@ -124,7 +141,6 @@ private function getAllMethods(): array AbstractMethod::CACHE_TAGS, self::CACHE_TIME ); - return $methods; } @@ -142,7 +158,7 @@ private function getCacheName(): string private function getCurrencyCode(): string { try { - return $this->storeManager->getStore()->getBaseCurrency()->getCode(); + return $this->checkoutHelper->getQuote()->getQuoteCurrencyCode() ?: ''; } catch (Exception $exception) { return ''; } diff --git a/Helper/Configuration.php b/Helper/Configuration.php index 5e3fba1..82a7fbd 100644 --- a/Helper/Configuration.php +++ b/Helper/Configuration.php @@ -13,6 +13,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Airwallex\Payments\Helper; use Airwallex\Payments\Model\Config\Source\Mode; @@ -25,7 +26,11 @@ class Configuration extends AbstractHelper private const DEMO_BASE_URL = 'https://pci-api-demo.airwallex.com/api/v1/'; private const PRODUCTION_BASE_URL = 'https://pci-api.airwallex.com/api/v1/'; + private const EXPRESS_PREFIX = 'payment/airwallex_payments_express/'; + /** + * Client id + * * @return string|null */ public function getClientId(): ?string @@ -34,6 +39,8 @@ public function getClientId(): ?string } /** + * Api key + * * @return string|null */ public function getApiKey(): ?string @@ -42,14 +49,18 @@ public function getApiKey(): ?string } /** + * Is request logger enabled + * * @return bool */ public function isRequestLoggerEnable(): bool { - return (bool) $this->scopeConfig->getValue('airwallex/general/request_logger'); + return (bool)$this->scopeConfig->getValue('airwallex/general/request_logger'); } /** + * Webhook secret key + * * @return string */ public function getWebhookSecretKey(): string @@ -60,6 +71,8 @@ public function getWebhookSecretKey(): string } /** + * Mode + * * @return string */ public function getMode(): string @@ -68,34 +81,138 @@ public function getMode(): string } /** + * Api url + * + * @return string + */ + public function getApiUrl(): string + { + return $this->isDemoMode() ? self::DEMO_BASE_URL : self::PRODUCTION_BASE_URL; + } + + /** + * Is demo mode + * + * @return bool + */ + private function isDemoMode(): bool + { + return $this->getMode() === Mode::DEMO; + } + + /** + * Card capture enabled + * + * @return bool + */ + public function isCardCaptureEnabled(): bool + { + return $this->scopeConfig->getValue('payment/airwallex_payments_card/airwallex_payment_action') + === MethodInterface::ACTION_AUTHORIZE_CAPTURE; + } + + /** + * Card enabled + * + * @return bool + */ + public function isCardActive(): bool + { + return !!$this->scopeConfig->getValue('payment/airwallex_payments_card/active'); + } + + /** + * Express capture enabled + * + * @return bool + */ + public function isExpressCaptureEnabled(): bool + { + return $this->scopeConfig->getValue('payment/airwallex_payments_express/airwallex_payment_action') + === MethodInterface::ACTION_AUTHORIZE_CAPTURE; + } + + /** + * Express display area + * * @return string */ - public function getCardPaymentAction(): string + public function expressDisplayArea(): string { - return $this->scopeConfig->getValue('payment/airwallex_payments_card/airwallex_payment_action'); + return $this->scopeConfig->getValue('payment/airwallex_payments_express/display_area'); } /** + * Express seller name + * * @return string */ - public function getApiUrl(): string + public function getExpressSellerName(): string { - return $this->isDemoMode() ? self::DEMO_BASE_URL : self::PRODUCTION_BASE_URL; + return $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'seller_name') ?: ''; + } + + /** + * Express style + * + * @return array + */ + public function getExpressStyle(): array + { + return [ + "button_height" => $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'button_height'), + "apple_pay_button_theme" => $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'apple_pay_button_theme'), + "google_pay_button_theme" => $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'google_pay_button_theme'), + "apple_pay_button_type" => $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'apple_pay_button_type'), + "google_pay_button_type" => $this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'google_pay_button_type'), + ]; } /** + * Is express active + * * @return bool */ - private function isDemoMode(): bool + public function isExpressActive(): bool { - return $this->getMode() === Mode::DEMO; + if (!$this->isCardActive()) { + return false; + } + return !!$this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'active'); } /** + * Is express phone required + * * @return bool */ - public function isCaptureEnabled() + public function isExpressPhoneRequired(): bool + { + return $this->scopeConfig->getValue('customer/address/telephone_show') === "req"; + } + + /** + * Country code + * + * @return string + */ + public function getCountryCode(): string + { + return $this->scopeConfig->getValue('paypal/general/merchant_country') + ?: $this->scopeConfig->getValue('general/country/default'); + } + + /** + * Express button sort + * + * @return array + */ + public function getExpressButtonSort(): array { - return $this->scopeConfig->getValue('payment/airwallex_payments_card/airwallex_payment_action') === MethodInterface::ACTION_AUTHORIZE_CAPTURE; + $sorts = []; + $sorts['google'] = (int)$this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'google_pay_sort_order'); + $sorts['apple'] = (int)$this->scopeConfig->getValue(self::EXPRESS_PREFIX . 'apple_pay_sort_order'); + asort($sorts); + return array_keys($sorts); } } diff --git a/Model/Adminhtml/Notifications/ExpressDisabled.php b/Model/Adminhtml/Notifications/ExpressDisabled.php new file mode 100644 index 0000000..a797d9a --- /dev/null +++ b/Model/Adminhtml/Notifications/ExpressDisabled.php @@ -0,0 +1,72 @@ +configuration = $configuration; + $this->expressDisabled(); + } + + /** + * @return string + */ + public function getIdentity(): string + { + return 'airwallex_payments_notification_express_disabled'; + } + + /** + * @return bool + */ + public function isDisplayed(): bool + { + return $this->displayedText !== null; + } + + /** + * @return string + */ + public function getText(): string + { + return $this->displayedText; + } + + /** + * @return int + */ + public function getSeverity(): int + { + return self::SEVERITY_NOTICE; + } + + /** + * @return void + */ + private function expressDisabled(): void { + if (!$this->configuration->isCardActive()) { + $this->displayedText = __('Airwallex Express Checkout is also disabled. To use Express Checkout, you must first enable Credit Card payments.'); + } + } +} diff --git a/Model/Client/Request/ApplePayValidateMerchant.php b/Model/Client/Request/ApplePayValidateMerchant.php new file mode 100644 index 0000000..c59f04f --- /dev/null +++ b/Model/Client/Request/ApplePayValidateMerchant.php @@ -0,0 +1,55 @@ +getBody(); + } + + public function setInitiativeParams($initiative): AbstractClient + { + $this->setParams($initiative); + return $this; + } +} diff --git a/Model/Client/Request/PaymentIntents/Create.php b/Model/Client/Request/PaymentIntents/Create.php index 8727378..4ac4f64 100644 --- a/Model/Client/Request/PaymentIntents/Create.php +++ b/Model/Client/Request/PaymentIntents/Create.php @@ -32,8 +32,8 @@ class Create extends AbstractClient implements BearerAuthenticationInterface public function setQuote(Quote $quote, string $returnUrl): self { return $this->setParams([ - 'amount' => $quote->getBaseGrandTotal(), - 'currency' => $quote->getBaseCurrencyCode(), + 'amount' => $quote->getGrandTotal(), + 'currency' => $quote->getQuoteCurrencyCode(), 'merchant_order_id' => $quote->getReservedOrderId(), 'supplementary_amount' => 1, 'return_url' => $returnUrl, @@ -118,7 +118,7 @@ private function getQuoteProducts(Quote $quote): array 'name' => $name, 'quantity' => $item->getQty(), 'sku' => $item->getSku(), - 'unit_price' => $item->getPrice(), + 'unit_price' => $item->getConvertedPrice(), 'url' => $item->getProduct()->getProductUrl() ]; }, $quote->getAllItems()); diff --git a/Model/Client/Request/PaymentIntents/Get.php b/Model/Client/Request/PaymentIntents/Get.php index d0f0b46..b632188 100644 --- a/Model/Client/Request/PaymentIntents/Get.php +++ b/Model/Client/Request/PaymentIntents/Get.php @@ -21,6 +21,10 @@ class Get extends AbstractClient implements BearerAuthenticationInterface { + public const INTENT_STATUS_SUCCESS = 'SUCCEEDED'; + + public const INTENT_STATUS_REQUIRES_CAPTURE = 'REQUIRES_CAPTURE'; + /** * @var string */ diff --git a/Model/Config/Source/Express/ApplePay/ButtonTheme.php b/Model/Config/Source/Express/ApplePay/ButtonTheme.php new file mode 100644 index 0000000..00d0220 --- /dev/null +++ b/Model/Config/Source/Express/ApplePay/ButtonTheme.php @@ -0,0 +1,24 @@ + 'black', + 'label' => __('Black') + ], + [ + 'value' => 'white', + 'label' => __('White') + ], + [ + 'value' => 'white-outline', + 'label' => __('White with outline') + ] + ]; + } +} diff --git a/Model/Config/Source/Express/ApplePay/ButtonType.php b/Model/Config/Source/Express/ApplePay/ButtonType.php new file mode 100644 index 0000000..f4c1c79 --- /dev/null +++ b/Model/Config/Source/Express/ApplePay/ButtonType.php @@ -0,0 +1,72 @@ + 'add-money', + 'label' => __('Add money') + ], + [ + 'value' => 'book', + 'label' => __('Book') + ], + [ + 'value' => 'buy', + 'label' => __('Buy') + ], + [ + 'value' => 'check-out', + 'label' => __('Check-out') + ], + [ + 'value' => 'continue', + 'label' => __('Continue') + ], + [ + 'value' => 'contribute', + 'label' => __('Contribute') + ], + [ + 'value' => 'donate', + 'label' => __('Donate') + ], + [ + 'value' => 'order', + 'label' => __('Order') + ], + // [ + // 'value' => 'plain', + // 'label' => __('Plain') + // ], + [ + 'value' => 'reload', + 'label' => __('Reload') + ], + [ + 'value' => 'rent', + 'label' => __('Rent') + ], + [ + 'value' => 'subscribe', + 'label' => __('Subscribe') + ], + [ + 'value' => 'support', + 'label' => __('Support') + ], + [ + 'value' => 'tip', + 'label' => __('Tip') + ], + [ + 'value' => 'top-up', + 'label' => __('Top-up') + ] + ]; + } +} diff --git a/Model/Config/Source/Express/DisplayArea.php b/Model/Config/Source/Express/DisplayArea.php new file mode 100644 index 0000000..d421b18 --- /dev/null +++ b/Model/Config/Source/Express/DisplayArea.php @@ -0,0 +1,28 @@ + "product_page", + 'label' => __('Product pages') + ], + [ + 'value' => "minicart", + 'label' => __('Minicart') + ], + [ + 'value' => "cart_page", + 'label' => __('Shopping cart page') + ], + [ + 'value' => "checkout_page", + 'label' => __('Checkout page') + ] + ]; + } +} diff --git a/Model/Config/Source/Express/GooglePay/ButtonTheme.php b/Model/Config/Source/Express/GooglePay/ButtonTheme.php new file mode 100644 index 0000000..f65921e --- /dev/null +++ b/Model/Config/Source/Express/GooglePay/ButtonTheme.php @@ -0,0 +1,20 @@ + 'black', + 'label' => __('Black') + ], + [ + 'value' => 'white', + 'label' => __('White') + ] + ]; + } +} diff --git a/Model/Config/Source/Express/GooglePay/ButtonType.php b/Model/Config/Source/Express/GooglePay/ButtonType.php new file mode 100644 index 0000000..48ca704 --- /dev/null +++ b/Model/Config/Source/Express/GooglePay/ButtonType.php @@ -0,0 +1,48 @@ + 'book', + 'label' => __('Book') + ], + [ + 'value' => 'buy', + 'label' => __('Buy') + ], + [ + 'value' => 'checkout', + 'label' => __('Checkout') + ], + [ + 'value' => 'continue', + 'label' => __('Continue') + ], + [ + 'value' => 'donate', + 'label' => __('Donate') + ], + [ + 'value' => 'order', + 'label' => __('Order') + ], + [ + 'value' => 'pay', + 'label' => __('Pay') + ], + // [ + // 'value' => 'plain', + // 'label' => __('Plain') + // ], + [ + 'value' => 'subscribe', + 'label' => __('Subscribe') + ] + ]; + } +} diff --git a/Model/Methods/AbstractMethod.php b/Model/Methods/AbstractMethod.php index 346466b..873673b 100644 --- a/Model/Methods/AbstractMethod.php +++ b/Model/Methods/AbstractMethod.php @@ -40,6 +40,7 @@ use Magento\Quote\Api\Data\CartInterface; use Psr\Log\LoggerInterface; use RuntimeException; +use Airwallex\Payments\Model\Client\Request\PaymentIntents\Get; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -54,7 +55,7 @@ abstract class AbstractMethod extends Adapter /** * @var LoggerInterface|null */ - protected $logger; + protected ?LoggerInterface $logger; /** * @var Refund @@ -100,6 +101,8 @@ abstract class AbstractMethod extends Adapter */ protected PaymentIntents $paymentIntents; + protected Get $intentGet; + /** * Payment constructor. * @@ -118,6 +121,7 @@ abstract class AbstractMethod extends Adapter * @param AvailablePaymentMethodsHelper $availablePaymentMethodsHelper * @param CancelHelper $cancelHelper * @param PaymentIntentRepository $paymentIntentRepository + * @param Get $intentGet * @param CommandPoolInterface|null $commandPool * @param ValidatorPoolInterface|null $validatorPool * @param CommandManagerInterface|null $commandExecutor @@ -139,6 +143,7 @@ public function __construct( AvailablePaymentMethodsHelper $availablePaymentMethodsHelper, CancelHelper $cancelHelper, PaymentIntentRepository $paymentIntentRepository, + Get $intentGet, CommandPoolInterface $commandPool = null, ValidatorPoolInterface $validatorPool = null, CommandManagerInterface $commandExecutor = null, @@ -154,7 +159,7 @@ public function __construct( $commandPool, $validatorPool, $commandExecutor, - $logger + $logger, ); $this->paymentIntents = $paymentIntents; $this->logger = $logger; @@ -166,6 +171,7 @@ public function __construct( $this->cancelHelper = $cancelHelper; $this->confirm = $confirm; $this->checkoutHelper = $checkoutHelper; + $this->intentGet = $intentGet; } /** @@ -184,7 +190,6 @@ public function assignData(DataObject $data): self } } - return $this; } @@ -314,13 +319,4 @@ protected function getPaymentMethodCode(): string { return str_replace(self::PAYMENT_PREFIX, '', $this->getCode()); } - - /** - * @return string - * @throws LocalizedException - */ - protected function getIntentStatus(): string - { - return $this->getInfoInstance()->getAdditionalInformation('intent_status'); - } } diff --git a/Model/Methods/CardMethod.php b/Model/Methods/CardMethod.php index 5a0edf9..c28f807 100644 --- a/Model/Methods/CardMethod.php +++ b/Model/Methods/CardMethod.php @@ -19,6 +19,7 @@ use GuzzleHttp\Exception\GuzzleException; use Magento\Framework\Exception\LocalizedException; use Magento\Payment\Model\InfoInterface; +use Airwallex\Payments\Model\Client\Request\PaymentIntents\Get; class CardMethod extends AbstractMethod { @@ -37,9 +38,13 @@ public function capture(InfoInterface $payment, $amount): self $payment->setTransactionId($intentId); - $status = $this->getIntentStatus(); + $resp = $this->intentGet->setPaymentIntentId($intentId)->send(); + $respArr = json_decode($resp, true); + if (!isset($respArr['status'])) { + throw new LocalizedException(__('Something went wrong while trying to capture the payment.')); + } - if ($status === PaymentIntentInterface::INTENT_STATUS_REQUIRES_CAPTURE) { + if ($respArr['status'] === PaymentIntentInterface::INTENT_STATUS_REQUIRES_CAPTURE) { try { $result = $this->capture ->setPaymentIntentId($intentId) @@ -52,7 +57,6 @@ public function capture(InfoInterface $payment, $amount): self } } - return $this; } diff --git a/Model/Methods/ExpressCheckout.php b/Model/Methods/ExpressCheckout.php new file mode 100644 index 0000000..8e11ecf --- /dev/null +++ b/Model/Methods/ExpressCheckout.php @@ -0,0 +1,10 @@ +paymentIntentsCancel = $paymentIntentsCancel; $this->paymentIntentsCreate = $paymentIntentsCreate; $this->checkoutSession = $checkoutSession; @@ -101,7 +104,7 @@ public function __construct( * @throws GuzzleException * @throws LocalizedException * @throws NoSuchEntityException - * @throws \JsonException + * @throws JsonException */ public function getIntents(): array { @@ -151,7 +154,7 @@ public function removeIntents(): void */ private function getCacheKey(Quote $quote): string { - return 'airwallex-intent-' . $quote->getId(); + return 'airwallex-intent-' . $quote->getId() . '-' . sprintf("%.4f", $quote->getGrandTotal()); } /** @@ -174,9 +177,9 @@ private function saveQuote(Quote $quote): void * @throws GuzzleException * @throws LocalizedException * @throws NoSuchEntityException - * @throws \JsonException + * @throws JsonException */ - public function cancelIntent(string $intentId) + public function cancelIntent(string $intentId): mixed { try { $response = $this->paymentIntentsCancel diff --git a/Model/Service.php b/Model/Service.php index 0b0af76..fedfee5 100644 --- a/Model/Service.php +++ b/Model/Service.php @@ -20,21 +20,43 @@ use Airwallex\Payments\Api\Data\PlaceOrderResponseInterfaceFactory; use Airwallex\Payments\Api\ServiceInterface; use Airwallex\Payments\Helper\Configuration; -use Airwallex\Payments\Model\Methods\CardMethod; +use Airwallex\Payments\Model\Client\Request\ApplePayValidateMerchant; use Airwallex\Payments\Plugin\ReCaptchaValidationPlugin; +use Exception; use GuzzleHttp\Exception\GuzzleException; use JsonException; +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Checkout\Api\Data\ShippingInformationInterface; use Magento\Checkout\Api\GuestPaymentInformationManagementInterface; use Magento\Checkout\Api\PaymentInformationManagementInterface; use Magento\Checkout\Helper\Data as CheckoutData; +use Magento\Directory\Model\RegionFactory; use Magento\Framework\App\CacheInterface; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\DataObject; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Payment\Model\MethodInterface; use Magento\Quote\Api\Data\AddressInterface; +use Magento\Quote\Api\CartRepositoryInterface; use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Quote\Model\QuoteIdToMaskedQuoteIdInterface; +use Magento\Quote\Model\ResourceModel\Quote\QuoteIdMask as QuoteIdMaskResourceModel; +use Magento\Store\Model\StoreManagerInterface; +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Framework\Filter\LocalizedToNormalized; +use Magento\Framework\Locale\Resolver; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Quote\Api\ShipmentEstimationInterface; +use Magento\Customer\Api\Data\RegionInterfaceFactory; +use Magento\Checkout\Api\ShippingInformationManagementInterface; +use Magento\Checkout\Api\Data\ShippingInformationInterfaceFactory; +use Airwallex\Payments\Model\Ui\ConfigProvider; use Airwallex\Payments\Model\Client\Request\PaymentIntents\Get; +use Magento\Customer\Model\Address\Validator\Country; +use Magento\Customer\Model\Address\Validator\Postcode; +use Magento\Quote\Model\ValidationRules\ShippingAddressValidationRule; +use Magento\Quote\Model\ValidationRules\BillingAddressValidationRule; class Service implements ServiceInterface { @@ -45,8 +67,30 @@ class Service implements ServiceInterface protected PaymentInformationManagementInterface $paymentInformationManagement; protected PlaceOrderResponseInterfaceFactory $placeOrderResponseFactory; protected CacheInterface $cache; + protected QuoteIdToMaskedQuoteIdInterface $quoteIdToMaskedQuoteId; + protected StoreManagerInterface $storeManager; + protected RequestInterface $request; + protected ProductRepositoryInterface $productRepository; + private SerializerInterface $serializer; protected Get $intentGet; + private LocalizedToNormalized $localizedToNormalized; + private Resolver $localeResolver; + private CartRepositoryInterface $quoteRepository; + private QuoteIdMaskFactory $quoteIdMaskFactory; + private QuoteIdMaskResourceModel $quoteIdMaskResourceModel; + private ShipmentEstimationInterface $shipmentEstimation; + private RegionInterfaceFactory $regionInterfaceFactory; + private RegionFactory $regionFactory; + private ShippingInformationManagementInterface $shippingInformationManagement; + private ShippingInformationInterfaceFactory $shippingInformationFactory; + private ConfigProvider $configProvider; + private ApplePayValidateMerchant $validateMerchant; + private Country $countryValidator; + private Postcode $postcodeValidator; + private ShippingAddressValidationRule $shippingAddressValidationRule; + private BillingAddressValidationRule $billingAddressValidationRule; + /** * Index constructor. * @@ -57,6 +101,28 @@ class Service implements ServiceInterface * @param PaymentInformationManagementInterface $paymentInformationManagement * @param PlaceOrderResponseInterfaceFactory $placeOrderResponseFactory * @param CacheInterface $cache + * @param QuoteIdToMaskedQuoteIdInterface $quoteIdToMaskedQuoteId + * @param StoreManagerInterface $storeManager + * @param RequestInterface $request + * @param ProductRepositoryInterface $productRepository + * @param SerializerInterface $serializer + * @param LocalizedToNormalized $localizedToNormalized + * @param Resolver $localeResolver + * @param CartRepositoryInterface $quoteRepository + * @param QuoteIdMaskFactory $quoteIdMaskFactory + * @param QuoteIdMaskResourceModel $quoteIdMaskResourceModel + * @param ShipmentEstimationInterface $shipmentEstimation + * @param RegionInterfaceFactory $regionInterfaceFactory + * @param RegionFactory $regionFactory + * @param ShippingInformationManagementInterface $shippingInformationManagement + * @param ShippingInformationInterfaceFactory $shippingInformationFactory + * @param ConfigProvider $configProvider + * @param Get $intentGet + * @param ApplePayValidateMerchant $validateMerchant + * @param Country $countryValidator + * @param Postcode $postcodeValidator + * @param ShippingAddressValidationRule $shippingAddressValidationRule + * @param BillingAddressValidationRule $billingAddressValidationRule */ public function __construct( PaymentIntents $paymentIntents, @@ -66,7 +132,28 @@ public function __construct( PaymentInformationManagementInterface $paymentInformationManagement, PlaceOrderResponseInterfaceFactory $placeOrderResponseFactory, CacheInterface $cache, - Get $intentGet + QuoteIdToMaskedQuoteIdInterface $quoteIdToMaskedQuoteId, + StoreManagerInterface $storeManager, + RequestInterface $request, + ProductRepositoryInterface $productRepository, + SerializerInterface $serializer, + LocalizedToNormalized $localizedToNormalized, + Resolver $localeResolver, + CartRepositoryInterface $quoteRepository, + QuoteIdMaskFactory $quoteIdMaskFactory, + QuoteIdMaskResourceModel $quoteIdMaskResourceModel, + ShipmentEstimationInterface $shipmentEstimation, + RegionInterfaceFactory $regionInterfaceFactory, + RegionFactory $regionFactory, + ShippingInformationManagementInterface $shippingInformationManagement, + ShippingInformationInterfaceFactory $shippingInformationFactory, + ConfigProvider $configProvider, + Get $intentGet, + ApplePayValidateMerchant $validateMerchant, + Country $countryValidator, + Postcode $postcodeValidator, + ShippingAddressValidationRule $shippingAddressValidationRule, + BillingAddressValidationRule $billingAddressValidationRule ) { $this->paymentIntents = $paymentIntents; $this->configuration = $configuration; @@ -75,9 +162,29 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->placeOrderResponseFactory = $placeOrderResponseFactory; $this->cache = $cache; + $this->quoteIdToMaskedQuoteId = $quoteIdToMaskedQuoteId; + $this->storeManager = $storeManager; + $this->request = $request; + $this->productRepository = $productRepository; + $this->serializer = $serializer; + $this->localizedToNormalized = $localizedToNormalized; + $this->localeResolver = $localeResolver; + $this->quoteRepository = $quoteRepository; + $this->quoteIdMaskFactory = $quoteIdMaskFactory; + $this->quoteIdMaskResourceModel = $quoteIdMaskResourceModel; + $this->shipmentEstimation = $shipmentEstimation; + $this->regionInterfaceFactory = $regionInterfaceFactory; + $this->regionFactory = $regionFactory; + $this->shippingInformationManagement = $shippingInformationManagement; + $this->shippingInformationFactory = $shippingInformationFactory; + $this->configProvider = $configProvider; $this->intentGet = $intentGet; + $this->validateMerchant = $validateMerchant; + $this->countryValidator = $countryValidator; + $this->postcodeValidator = $postcodeValidator; + $this->shippingAddressValidationRule = $shippingAddressValidationRule; + $this->billingAddressValidationRule = $billingAddressValidationRule; } - /** * Return URL * @@ -97,25 +204,6 @@ public function redirectUrl(): string return $checkout->getAirwallexPaymentsRedirectUrl(); } - /** - * Checks if payment should be captured on order placement - * - * @param string $method - * - * @return array - */ - private function getExtraConfiguration(string $method): array - { - $data = []; - - if ($method === CardMethod::CODE) { - $paymentAction = $this->configuration->getCardPaymentAction(); - $data['card']['auto_capture'] = $paymentAction === MethodInterface::ACTION_AUTHORIZE_CAPTURE; - } - - return $data; - } - /** * @throws NoSuchEntityException * @throws CouldNotSaveException @@ -142,18 +230,23 @@ public function airwallexGuestPlaceOrder( 'client_secret' => $intent['clientSecret'] ]); } else { - $this->checkIntent($intentId); - $orderId = $this->guestPaymentInformationManagement->savePaymentInformationAndPlaceOrder( - $cartId, - $email, - $paymentMethod, - $billingAddress - ); + try { + $this->checkIntent($intentId); + $orderId = $this->guestPaymentInformationManagement->savePaymentInformationAndPlaceOrder( + $cartId, + $email, + $paymentMethod, + $billingAddress + ); - $response->setData([ - 'response_type' => 'success', - 'order_id' => $orderId - ]); + $response->setData([ + 'response_type' => 'success', + 'order_id' => $orderId + ]); + } catch (Exception $e) { + $this->paymentIntents->removeIntents(); + throw $e; + } } return $response; @@ -185,29 +278,351 @@ public function airwallexPlaceOrder( 'client_secret' => $intent['clientSecret'] ]); } else { - $this->checkIntent($intentId); - $orderId = $this->paymentInformationManagement->savePaymentInformationAndPlaceOrder( - $cartId, - $paymentMethod, - $billingAddress - ); + try { + $this->checkIntent($intentId); + $orderId = $this->paymentInformationManagement->savePaymentInformationAndPlaceOrder( + $cartId, + $paymentMethod, + $billingAddress + ); - $response->setData([ - 'response_type' => 'success', - 'order_id' => $orderId - ]); + $response->setData([ + 'response_type' => 'success', + 'order_id' => $orderId + ]); + } catch (Exception $e) { + $this->paymentIntents->removeIntents(); + throw $e; + } } return $response; } + /** + * Get express data when initialize and quote data updated + * + * @return string + * @throws NoSuchEntityException + */ + public function expressData(): string + { + $res = $this->quoteData(); + $res['settings'] = $this->settings(); + + if ($this->request->getParam("is_product_page") === '1') { + $res['product_is_virtual'] = $this->getProductIsVirtual(); + } + + return json_encode($res); + } + + /** + * Get quote data + * + * @return array + */ + private function quoteData(): array + { + $quote = $this->checkoutHelper->getQuote(); + $cartId = $quote->getId() ?? 0; + try { + $maskCartId = $this->quoteIdToMaskedQuoteId->execute($cartId); + } catch (NoSuchEntityException $e) { + $maskCartId = ''; + } + + $taxAmount = $quote->isVirtual() + ? $quote->getBillingAddress()->getTaxAmount() + : $quote->getShippingAddress()->getTaxAmount(); + + return [ + 'subtotal' => $quote->getSubtotal() ?? 0, + 'grand_total' => $quote->getGrandTotal() ?? 0, + 'shipping_amount' => $quote->getShippingAddress()->getShippingAmount() ?? 0, + 'tax_amount' => $taxAmount ?: 0, + 'subtotal_with_discount' => $quote->getSubtotalWithDiscount() ?? 0, + 'cart_id' => $cartId, + 'mask_cart_id' => $maskCartId, + 'is_virtual' => $quote->isVirtual(), + 'customer_id' => $quote->getCustomer()->getId(), + 'quote_currency_code' => $quote->getQuoteCurrencyCode(), + 'email' => $quote->getCustomer()->getEmail(), + 'items_qty' => $quote->getItemsQty() ?? 0, + 'billing_address' => $quote->getBillingAddress()->toArray([ + 'city', + 'country_id', + 'postcode', + 'region', + 'street', + 'firstname', + 'lastname', + 'email', + ]), + ]; + } + + /** + * Get admin settings + * + * @return array + * @throws NoSuchEntityException + */ + private function settings(): array + { + return [ + 'mode' => $this->configuration->getMode(), + 'express_seller_name' => $this->configuration->getExpressSellerName(), + 'is_express_active' => $this->configuration->isExpressActive(), + 'is_express_phone_required' => $this->configuration->isExpressPhoneRequired(), + 'is_express_capture_enabled' => $this->configuration->isExpressCaptureEnabled(), + 'express_style' => $this->configuration->getExpressStyle(), + 'express_button_sort' => $this->configuration->getExpressButtonSort(), + 'country_code' => $this->configuration->getCountryCode(), + 'store_code' => $this->storeManager->getStore()->getCode(), + 'display_area' => $this->configuration->expressDisplayArea(), + 'recaptcha_settings' => $this->configProvider->getReCaptchaConfig(), + 'is_recaptcha_enabled' => $this->configProvider->isReCaptchaEnabled(), + ]; + } + + /** + * Get product type from product_id from request + * + * @return bool + * @throws NoSuchEntityException + */ + private function getProductIsVirtual(): bool + { + $product = $this->productRepository->getById( + $this->request->getParam("product_id"), + false, + $this->storeManager->getStore()->getId(), + false + ); + return $product->isVirtual(); + } + + /** + * Add product when click pay in product page + * + * @return string + * @throws CouldNotSaveException + */ + public function addToCart(): string + { + $params = $this->request->getParams(); + $productId = $params['product']; + $related = $params['related_product']; + + if (isset($params['qty'])) { + $this->localizedToNormalized->setOptions(['locale' => $this->localeResolver->getLocale()]); + $params['qty'] = $this->localizedToNormalized->filter((string)$params['qty']); + } + + $quote = $this->checkoutHelper->getQuote(); + + try { + $storeId = $this->storeManager->getStore()->getId(); + $product = $this->productRepository->getById($productId, false, $storeId); + + $groupedProductIds = []; + if (!empty($params['super_group']) && is_array($params['super_group'])) { + $groupedProductSelections = $params['super_group']; + $groupedProductIds = array_keys($groupedProductSelections); + } + + foreach ($quote->getAllItems() as $item) { + if ($item->getProductId() == $productId || in_array($item->getProductId(), $groupedProductIds)) { + $this->checkoutHelper->getQuote()->removeItem($item->getId()); + } + } + + $this->checkoutHelper->getQuote()->addProduct($product, new DataObject($params)); + + if (!empty($related)) { + $productIds = explode(',', $related); + $this->checkoutHelper->getQuote()->addProductsByIds($productIds); + } + + $this->quoteRepository->save($quote); + + $quote->setTotalsCollectedFlag(false); + $quote->collectTotals(); + $this->quoteRepository->save($quote); + + try { + $maskCartId = $this->quoteIdToMaskedQuoteId->execute($quote->getId()); + } catch (NoSuchEntityException $e) { + $maskCartId = ''; + } + if ($maskCartId === '') { + $quoteIdMask = $this->quoteIdMaskFactory->create(); + $quoteIdMask->setQuoteId($quote->getId()); + $this->quoteIdMaskResourceModel->save($quoteIdMask); + $maskCartId = $this->quoteIdToMaskedQuoteId->execute($quote->getId()); + } + return $this->serializer->serialize([ + 'cart_id' => $quote->getId(), + 'mask_cart_id' => $maskCartId, + ]); + } catch (Exception $e) { + throw new CouldNotSaveException(__($e->getMessage()), $e); + } + } + + /** + * Post Address to get method and quote data + * + * @return string + * @throws Exception + */ + public function postAddress(): string + { + $countryId = $this->request->getParam('country_id'); + if (!$countryId) { + throw new Exception(__('Country is required.')); + } + $city = $this->request->getParam('city'); + + $region = $this->request->getParam('region'); + $postcode = $this->request->getParam('postcode'); + + $regionId = $this->regionFactory->create()->loadByName($region, $countryId)->getRegionId(); + if (!$regionId) { + $regionId = $this->regionFactory->create()->loadByCode($region, $countryId)->getRegionId(); + } + + $quote = $this->checkoutHelper->getQuote(); + + $cartId = $quote->getId(); + if (!is_numeric($cartId)) { + $cartId = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id')->getQuoteId(); + } + + $address = $quote->getShippingAddress(); + $address->setCountryId($countryId); + $address->setCity($city); + if ($regionId) { + $address->setRegionId($regionId); + } else { + $address->setRegionId(0); + $address->setRegion($region); + } + $address->setPostcode($postcode); + + $methods = $this->shipmentEstimation->estimateByExtendedAddress($cartId, $address); + + $res = []; + if (!$quote->isVirtual()) { + if (!count($methods)) { + throw new Exception(__('There are no available shipping method found.')); + } + + $selectedMethod = $methods[0]; + foreach ($methods as $method) { + if ($method->getMethodCode() === $this->request->getParam('methodId')) { + $selectedMethod = $method; + break; + } + } + + $shippingInformation = $this->shippingInformationFactory->create([ + 'data' => [ + ShippingInformationInterface::SHIPPING_ADDRESS => $address, + ShippingInformationInterface::SHIPPING_CARRIER_CODE => $selectedMethod->getCarrierCode(), + ShippingInformationInterface::SHIPPING_METHOD_CODE => $selectedMethod->getMethodCode(), + ], + ]); + $this->shippingInformationManagement->saveAddressInformation($cartId, $shippingInformation); + + foreach ($methods as $method) { + if ($method->getAvailable()) { + $res['methods'][]=$this->formatShippingMethod($method); + } + } + $res['selected_method'] = $this->formatShippingMethod($selectedMethod); + } + $res['quote_data'] = $this->quoteData(); + $res['region_id'] = $regionId; // we need this because magento internal bug + + return json_encode($res); + } + + /** + * Apple pay validate merchant + * + * @return string + * @throws Exception|GuzzleException + */ + public function validateMerchant() + { + $validationUrl = $this->request->getParam('validationUrl'); + if ( empty( $validationUrl ) ) { + throw new Exception( 'Validation URL is empty.' ); + } + + $initiativeContext = $this->request->getParam('origin'); + if ( empty( $initiativeContext ) ) { + throw new Exception( 'Initiative Context is empty.' ); + } + + return $this->validateMerchant->setInitiativeParams([ + 'validation_url' => $validationUrl, + 'initiative_context' => $initiativeContext, + ])->send(); + } + + /** + * Validate addresses before placing order + * + * @return string + * @throws Exception + */ + public function validateAddresses() + { + $quote = $this->checkoutHelper->getQuote(); + $errors = $this->shippingAddressValidationRule->validate($quote); + if ($errors and $errors[0]->getErrors()) { + throw new Exception(__(implode(' ', $errors[0]->getErrors()))); + } + $errors = $this->billingAddressValidationRule->validate($quote); + if ($errors and $errors[0]->getErrors()) { + throw new Exception(__(implode(' ', $errors[0]->getErrors()))); + } + return 'ok'; + } + + /** + * Format shipping method + * + * @param $method + * @return array + */ + private function formatShippingMethod($method): array + { + return [ + 'carrier_code' => $method->getCarrierCode(), + 'carrier_title' => $method->getCarrierTitle(), + 'amount' => $method->getAmount(), + 'method_code' => $method->getMethodCode(), + 'method_title' => $method->getMethodTitle(), + ]; + } + + /** + * @throws GuzzleException + * @throws JsonException + * @throws Exception + */ protected function checkIntent($id) { $resp = $this->intentGet->setPaymentIntentId($id)->send(); $respArr = json_decode($resp, true); - if (!in_array($respArr['status'], ["SUCCEEDED","REQUIRES_CAPTURE"], true)) { - throw new \Exception("Something went wrong while processing your request. Please try again later."); + $okStatus = [$this->intentGet::INTENT_STATUS_SUCCESS, $this->intentGet::INTENT_STATUS_REQUIRES_CAPTURE]; + if (!in_array($respArr['status'], $okStatus, true)) { + throw new Exception(__('Something went wrong while processing your request. Please try again later.')); } } } diff --git a/Model/Ui/ConfigProvider.php b/Model/Ui/ConfigProvider.php index e9ec2b5..a3321b0 100644 --- a/Model/Ui/ConfigProvider.php +++ b/Model/Ui/ConfigProvider.php @@ -8,11 +8,11 @@ * to newer versions in the future. * * @copyright Copyright (c) 2021 Magebit, -Ltd. (https://magebit.com/) + * Ltd. (https://magebit.com/) * @license GNU General Public License ("GPL") v3.0 * * For the full copyright and license information, -please view the LICENSE + * please view the LICENSE * file that was distributed with this source code. */ @@ -26,7 +26,7 @@ class ConfigProvider implements ConfigProviderInterface { - const AIRWALLEX_RECAPTCHA_FOR = 'airwallex_card'; + const AIRWALLEX_RECAPTCHA_FOR = 'place_order'; protected Configuration $configuration; protected IsCaptchaEnabledInterface $isCaptchaEnabled; @@ -39,9 +39,9 @@ class ConfigProvider implements ConfigProviderInterface * @param ReCaptcha $reCaptchaBlock */ public function __construct( - Configuration $configuration, + Configuration $configuration, IsCaptchaEnabledInterface $isCaptchaEnabled, - ReCaptcha $reCaptchaBlock + ReCaptcha $reCaptchaBlock ) { $this->configuration = $configuration; $this->isCaptchaEnabled = $isCaptchaEnabled; @@ -56,25 +56,49 @@ public function __construct( */ public function getConfig(): array { - $recaptchaEnabled = $this->isCaptchaEnabled->isCaptchaEnabledFor(self::AIRWALLEX_RECAPTCHA_FOR); + $recaptchaEnabled = $this->isReCaptchaEnabled(); $config = [ 'payment' => [ 'airwallex_payments' => [ 'mode' => $this->configuration->getMode(), - 'cc_auto_capture' => $this->configuration->isCaptureEnabled(), - 'recaptcha_enabled' => !!$recaptchaEnabled + 'cc_auto_capture' => $this->configuration->isCardCaptureEnabled(), + 'recaptcha_enabled' => !!$recaptchaEnabled, ] ] ]; if ($recaptchaEnabled) { - $this->reCaptchaBlock->setData([ - 'recaptcha_for' => self::AIRWALLEX_RECAPTCHA_FOR - ]); - $config['payment']['airwallex_payments']['recaptcha_settings'] - = $this->reCaptchaBlock->getCaptchaUiConfig(); + $config['payment']['airwallex_payments']['recaptcha_settings'] = $this->getReCaptchaConfig(); } return $config; } + + /** + * Get reCaptcha config + * + * @return array + */ + public function getReCaptchaConfig() + { + if (!$this->isReCaptchaEnabled()) { + return []; + } + + $this->reCaptchaBlock->setData([ + 'recaptcha_for' => self::AIRWALLEX_RECAPTCHA_FOR + ]); + + return $this->reCaptchaBlock->getCaptchaUiConfig(); + } + + /** + * Get is reCaptcha enabled + * + * @return bool + */ + public function isReCaptchaEnabled() + { + return $this->isCaptchaEnabled->isCaptchaEnabledFor(self::AIRWALLEX_RECAPTCHA_FOR); + } } diff --git a/Model/Webhook/Capture.php b/Model/Webhook/Capture.php index 1b88fe3..aa4e46c 100644 --- a/Model/Webhook/Capture.php +++ b/Model/Webhook/Capture.php @@ -82,16 +82,14 @@ public function execute(object $data): void throw new WebhookException(__('Payment Intent: ' . $paymentIntentId . ': Can\'t find Order')); } - $paid = $order->getBaseGrandTotal() - $order->getBaseTotalPaid(); - - if ($paid === 0.0) { + if ($order->getTotalPaid()) { return; } $amount = $data->captured_amount; $invoice = $this->invoiceService->prepareInvoice($order); - $invoice->setBaseSubtotal($amount); - $invoice->setBaseGrandTotal($amount); + $invoice->setSubtotal($amount); + $invoice->setGrandTotal($amount); $invoice->setTransactionId($paymentIntentId); $invoice->setRequestedCaptureCase(Invoice::CAPTURE_OFFLINE); $invoice->register(); diff --git a/Model/Webhook/Refund.php b/Model/Webhook/Refund.php index 9980bea..381d26b 100644 --- a/Model/Webhook/Refund.php +++ b/Model/Webhook/Refund.php @@ -110,18 +110,18 @@ private function createCreditMemo(Order $order, float $refundAmount, string $rea } $baseToOrderRate = $order->getBaseToOrderRate(); - $baseTotalNotRefunded = $order->getBaseGrandTotal() - $order->getBaseTotalRefunded(); + $totalNotRefunded = $order->getGrandTotal() - $order->getTotalRefunded(); $creditMemo = $this->creditmemoFactory->createByOrder($order); $creditMemo->setInvoice($invoice); - if ($baseTotalNotRefunded > $refundAmount) { - $baseDiff = $baseTotalNotRefunded - $refundAmount; - $creditMemo->setAdjustmentPositive($baseDiff); + if ($totalNotRefunded > $refundAmount) { + $diff = $totalNotRefunded - $refundAmount; + $creditMemo->setAdjustmentPositive($diff); } - $creditMemo->setBaseGrandTotal($refundAmount); - $creditMemo->setGrandTotal($refundAmount * $baseToOrderRate); + $creditMemo->setBaseGrandTotal($refundAmount / $baseToOrderRate); + $creditMemo->setGrandTotal($refundAmount); $this->creditmemoService->refund($creditMemo, true); $order->addCommentToStatusHistory(__('Order refunded through Airwallex, Reason: %1', $reason)); diff --git a/etc/adminhtml/di.xml b/etc/adminhtml/di.xml index 8d1cd2f..8a00484 100644 --- a/etc/adminhtml/di.xml +++ b/etc/adminhtml/di.xml @@ -31,6 +31,7 @@ Airwallex\Payments\Model\Adminhtml\Notifications\Dependencies + Airwallex\Payments\Model\Adminhtml\Notifications\ExpressDisabled diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index e736456..7ed3b3d 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -29,14 +29,5 @@ -
- - - - Magento\ReCaptchaAdminUi\Model\OptionSource\Type - - -
diff --git a/etc/adminhtml/system/card.xml b/etc/adminhtml/system/card.xml index 6956374..abab642 100644 --- a/etc/adminhtml/system/card.xml +++ b/etc/adminhtml/system/card.xml @@ -20,6 +20,7 @@ + Enable Credit Card Magento\Config\Model\Config\Source\Yesno payment/airwallex_payments_card/active @@ -29,7 +30,7 @@ payment/airwallex_payments_card/title - + Airwallex\Payments\Model\Config\Source\PaymentAction payment/airwallex_payments_card/airwallex_payment_action @@ -53,5 +54,6 @@ validate-number payment/airwallex_payments_card/sort_order + diff --git a/etc/adminhtml/system/express.xml b/etc/adminhtml/system/express.xml new file mode 100644 index 0000000..ac64397 --- /dev/null +++ b/etc/adminhtml/system/express.xml @@ -0,0 +1,49 @@ + + + + + 1 + + + + + + + + Magento\Config\Model\Config\Source\Yesno + payment/airwallex_payments_express/active + + + + Airwallex\Payments\Model\Config\Source\Express\DisplayArea + payment/airwallex_payments_express/display_area + + 1 + + + + + + + + payment/airwallex_payments_express/seller_name + + 1 + + + + + Airwallex\Payments\Model\Config\Source\PaymentAction + payment/airwallex_payments_express/airwallex_payment_action + + 1 + + + + + + diff --git a/etc/adminhtml/system/express/apple_pay.xml b/etc/adminhtml/system/express/apple_pay.xml new file mode 100644 index 0000000..4a34450 --- /dev/null +++ b/etc/adminhtml/system/express/apple_pay.xml @@ -0,0 +1,21 @@ + + + + + + + Airwallex\Payments\Model\Config\Source\Express\ApplePay\ButtonTheme + payment/airwallex_payments_express/apple_pay_button_theme + + + + Airwallex\Payments\Model\Config\Source\Express\ApplePay\ButtonType + payment/airwallex_payments_express/apple_pay_button_type + + + + validate-number + payment/airwallex_payments_express/apple_pay_sort_order + + + diff --git a/etc/adminhtml/system/express/google_pay.xml b/etc/adminhtml/system/express/google_pay.xml new file mode 100644 index 0000000..64ce419 --- /dev/null +++ b/etc/adminhtml/system/express/google_pay.xml @@ -0,0 +1,21 @@ + + + + + + + Airwallex\Payments\Model\Config\Source\Express\GooglePay\ButtonTheme + payment/airwallex_payments_express/google_pay_button_theme + + + + Airwallex\Payments\Model\Config\Source\Express\GooglePay\ButtonType + payment/airwallex_payments_express/google_pay_button_type + + + + + + + + diff --git a/etc/config.xml b/etc/config.xml index 05b4b0a..e029006 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -29,6 +29,27 @@ + + 0 + AirwallexPaymentsExpressGatewayFacade + Express Checkout (Airwallex) + 0 + product_page,minicart,cart_page,checkout_page + 50 + black + black + plain + buy + 1 + 1 + 1 + 1 + 1 + 1 + 0 + 1 + authorize_capture + 0 AirwallexPaymentsCardsGatewayFacade diff --git a/etc/di.xml b/etc/di.xml index 2ce31a8..d927aea 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -92,6 +92,49 @@ AirwallexPaymentCardValidatorPool + + + + AirwallexPaymentExpressConfig + + + + + + AirwallexPaymentExpressCountryValidator + + + + + + Airwallex\Payments\Model\Methods\ExpressCheckout::CODE + + + + + + AirwallexPaymentExpressConfig + + + + + + AirwallexPaymentExpressConfigValueHandler + + + + + + Magento\Framework\Event\ManagerInterface + AirwallexPaymentExpressValueHandlerPool + Magento\Payment\Gateway\Data\PaymentDataObjectFactory + Airwallex\Payments\Model\Methods\ExpressCheckout::CODE + Magento\Payment\Block\Form + Magento\Payment\Block\Info + Airwallex\Payments\Logger\Logger + AirwallexPaymentExpressValidatorPool + + diff --git a/etc/webapi.xml b/etc/webapi.xml index 02e50c8..7352e97 100644 --- a/etc/webapi.xml +++ b/etc/webapi.xml @@ -39,4 +39,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/view/base/web/css/admin.css b/view/base/web/css/admin.css index fc32fa3..086f486 100644 --- a/view/base/web/css/admin.css +++ b/view/base/web/css/admin.css @@ -14,9 +14,8 @@ */ .airwallex-admin-config .heading { - padding-left: 20rem; background: url('../img/airwallex_logo.svg') no-repeat 0 50% / 18rem auto; - height: 40px; + padding-left: 20rem; } .airwallex-admin-config .button-container { diff --git a/view/frontend/layout/catalog_product_view.xml b/view/frontend/layout/catalog_product_view.xml new file mode 100644 index 0000000..717ef76 --- /dev/null +++ b/view/frontend/layout/catalog_product_view.xml @@ -0,0 +1,19 @@ + + + + + + + product_page + + + + + + + product_page + + + + + diff --git a/view/frontend/layout/checkout_cart_index.xml b/view/frontend/layout/checkout_cart_index.xml new file mode 100644 index 0000000..e2b5908 --- /dev/null +++ b/view/frontend/layout/checkout_cart_index.xml @@ -0,0 +1,16 @@ + + + + diff --git a/view/frontend/web/css/airwallex_payment.css b/view/frontend/web/css/airwallex_payment.css index 6be62bc..795a175 100644 --- a/view/frontend/web/css/airwallex_payment.css +++ b/view/frontend/web/css/airwallex_payment.css @@ -47,3 +47,50 @@ #threeDsChallenge { z-index: 2147483647 !important; } + +.express-title { + font-weight: 300; + font-size: 2.6rem; + color: #333333; +} + +.minicart-awx-express { + margin: 0 10px 15px +} + +.aws-button-mask, .aws-button-mask-for-login { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 100; + opacity: 0; + display: none; +} + +.airwallex-express-checkout { + position: relative; +} + +#minicart-content-wrapper .airwallex-express-checkout { + margin: 0 10px 15px; +} + +.airwallex-express-checkout .express-title { + display: none; +} + +#co-payment-form .airwallex-express-checkout .express-title { + display: block; +} + +@media screen and (min-width: 768px) { + @media (min-width: 769px), print { + .box-tocart .airwallex-express-checkout { + margin-bottom: 15px; + width: 49%; + min-width: 240px; + } + } +} \ No newline at end of file diff --git a/view/frontend/web/js/view/payment/method-renderer/address/address-handler.js b/view/frontend/web/js/view/payment/method-renderer/address/address-handler.js new file mode 100644 index 0000000..4c5c771 --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/address/address-handler.js @@ -0,0 +1,197 @@ +define([ + 'mage/url', + 'mage/storage', +], function ( + urlBuilder, + storage, +) { + 'use strict'; + + return { + selectedMethod: {}, + regionId: "", + intentConfirmBillingAddressFromGoogle: {}, + intentConfirmBillingAddressFromOfficial: {}, + + postBillingAddress(payload, isLoggedIn, cartId) { + let url = 'rest/V1/carts/mine/billing-address'; + if (!isLoggedIn) { + url = 'rest/V1/guest-carts/' + cartId + '/billing-address'; + } + return storage.post( + urlBuilder.build(url), JSON.stringify(payload), undefined, 'application/json', {} + ); + }, + + postShippingInformation(payload, isLoggedIn, cartId) { + let url = 'rest/V1/carts/mine/shipping-information'; + if (!isLoggedIn) { + url = 'rest/V1/guest-carts/' + cartId + '/shipping-information'; + } + return storage.post( + urlBuilder.build(url), JSON.stringify(payload), undefined, 'application/json', {} + ); + }, + + getIntermediateShippingAddress(addr) { + return { + "region": addr.administrativeArea || '', + "country_id": addr.countryCode, + "postcode": addr.postalCode || '', + "city": addr.locality || '', + }; + }, + + getBillingAddressFromGoogle(addr) { + let names = addr.name.split(' '); + return { + countryId: addr.countryCode, + region: addr.administrativeArea, + regionId: 0, + street: [addr.address1 + ' ' + addr.address2 + ' ' + addr.address3], + telephone: addr.phoneNumber, + postcode: addr.postalCode, + city: addr.locality, + firstname: names[0], + lastname: names.length > 1 ? names[names.length - 1] : names[0], + }; + }, + + getBillingAddressFromApple(addr, phone) { + return { + countryId: addr.countryCode, + region: addr.administrativeArea, + regionId: 0, + street: addr.addressLines, + telephone: phone, + postcode: addr.postalCode, + city: addr.locality, + firstname: addr.givenName, + lastname: addr.familyName, + }; + }, + + constructAddressInformationFromGoogle(data) { + let names = data.shippingAddress.name.split(' ') || []; + let firstname = data.shippingAddress.name ? names[0] : ''; + let lastname = names.length > 1 ? names[names.length - 1] : firstname; + + return { + "addressInformation": { + "shipping_address": { + "countryId": data.shippingAddress.countryCode, + "regionId": this.regionId || 0, + "region": data.shippingAddress.administrativeArea, + "street": [data.shippingAddress.address1 + ' ' + data.shippingAddress.address2 + ' ' + data.shippingAddress.address3], + "telephone": data.shippingAddress.phoneNumber, + "postcode": data.shippingAddress.postalCode, + "city": data.shippingAddress.locality, + firstname, + lastname, + }, + "billing_address": this.getBillingAddressFromGoogle(data.paymentMethodData.info.billingAddress), + "shipping_method_code": this.selectedMethod ? this.selectedMethod.method_code : "", + "shipping_carrier_code": this.selectedMethod ? this.selectedMethod.carrier_code : "", + "extension_attributes": {} + } + }; + }, + + constructAddressInformationFromApple(data) { + return { + "addressInformation": { + "shipping_address": { + "countryId": data.shippingContact.countryCode, + "regionId": this.regionId || 0, + "region": data.shippingContact.administrativeArea, + "street": data.shippingContact.addressLines, + "telephone": data.shippingContact.phoneNumber, + "postcode": data.shippingContact.postalCode, + "city": data.shippingContact.locality, + "firstname": data.shippingContact.givenName, + "lastname": data.shippingContact.familyName, + }, + "billing_address": this.getBillingAddressFromApple(data.billingContact, data.shippingContact.phoneNumber), + "shipping_method_code": this.selectedMethod ? this.selectedMethod.method_code : "", + "shipping_carrier_code": this.selectedMethod ? this.selectedMethod.carrier_code : "", + "extension_attributes": {} + } + }; + }, + + setIntentConfirmBillingAddressFromGoogle(data) { + let addr = data.paymentMethodData.info.billingAddress; + let names = addr.name.split(' '); + this.intentConfirmBillingAddressFromGoogle = { + address: { + city: addr.locality, + country_code: addr.countryCode, + postcode: addr.postalCode, + state: addr.administrativeArea, + street: [addr.address1 + ' ' + addr.address2 + ' ' + addr.address3], + }, + first_name: names[0], + last_name: names.length > 1 ? names[names.length - 1] : names[0], + email: data.email, + telephone: addr.phoneNumber + }; + }, + + setIntentConfirmBillingAddressFromApple(addr, email) { + this.intentConfirmBillingAddressFromGoogle = { + address: { + city: addr.locality, + country_code: addr.countryCode, + postcode: addr.postalCode, + state: addr.administrativeArea, + street: addr.addressLines, + }, + first_name: addr.givenName, + last_name: addr.familyName, + email, + telephone: addr.phoneNumber + }; + }, + + setIntentConfirmBillingAddressFromOfficial(billingAddress) { + this.intentConfirmBillingAddressFromOfficial = { + address: { + city: billingAddress.city, + country_code: billingAddress.country_id, + postcode: billingAddress.postcode, + state: billingAddress.region, + street: Array.isArray(billingAddress.street) ? billingAddress.street.join(', ') : billingAddress.street + }, + first_name: billingAddress.firstname, + last_name: billingAddress.lastname, + email: billingAddress.email + }; + }, + + formatShippingMethodsToGoogle(methods, selectedMethod) { + const shippingOptions = methods.map(addr => { + return { + id: addr.method_code, + label: addr.method_title, + description: addr.carrier_title, + }; + }); + + return { + shippingOptions, + defaultSelectedOptionId: selectedMethod.method_code + }; + }, + + formatShippingMethodsToApple(methods) { + return methods.map(addr => { + return { + identifier: addr.method_code, + label: addr.method_title, + detail: addr.carrier_title, + amount: addr.amount + }; + }); + }, + }; +}); diff --git a/view/frontend/web/js/view/payment/method-renderer/card-method-recaptcha.js b/view/frontend/web/js/view/payment/method-renderer/card-method-recaptcha.js deleted file mode 100644 index d98cd7d..0000000 --- a/view/frontend/web/js/view/payment/method-renderer/card-method-recaptcha.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * This file is part of the Airwallex Payments module. - * - * DISCLAIMER - * - * Do not edit or add to this file if you wish to upgrade - * to newer versions in the future. - * - * @copyright Copyright (c) 2021 Magebit, - Ltd. (https://magebit.com/) - * @license GNU General Public License ("GPL") v3.0 - * - * For the full copyright and license information, - please view the LICENSE - * file that was distributed with this source code. - */ -define( - [ - 'Magento_ReCaptchaFrontendUi/js/reCaptcha', - 'jquery', - 'ko', - 'underscore', - 'Airwallex_Payments/js/webapiReCaptchaRegistry', - 'Magento_ReCaptchaFrontendUi/js/reCaptchaScriptLoader', - 'Magento_ReCaptchaFrontendUi/js/nonInlineReCaptchaRenderer' - ], - function (Component, $, ko, _, registry, reCaptchaLoader, nonInlineReCaptchaRenderer) { - 'use strict'; - - return Component.extend({ - defaults: { - reCaptchaId: 'airwallex-payments-card-recaptcha', - autoTrigger: false - }, - - /** - * recaptchaId: bool map - */ - _isInvisibleType: {}, - - parentFormId: 'airwallex-payments-card-form', - - /** - * Initialize reCAPTCHA after first rendering - */ - initCaptcha: function () { - let $parentForm, - $reCaptcha, - widgetId, - parameters; - - if (typeof this.settings === 'undefined' - && window.checkoutConfig?.payment?.airwallex_payments?.recaptcha_settings) { - this.settings = window.checkoutConfig.payment.airwallex_payments.recaptcha_settings; - } - - if (this.captchaInitialized || this.settings === void 0) { - return; - } - - this.captchaInitialized = true; - - $parentForm = $('#' + this.parentFormId); - $reCaptcha = $('#' + this.getReCaptchaId()); - - if (this.settings === undefined) { - return; - } - - parameters = _.extend( - { - 'callback': function (token) { // jscs:ignore jsDoc - this.reCaptchaCallback(token); - this.validateReCaptcha(true); - }.bind(this), - 'expired-callback': function () { - this.validateReCaptcha(false); - }.bind(this) - }, - this.settings.rendering - ); - - if (parameters.size === 'invisible' && parameters.badge !== 'inline') { - nonInlineReCaptchaRenderer.add($reCaptcha, parameters); - } - - // eslint-disable-next-line no-undef - widgetId = grecaptcha.render(this.getReCaptchaId(), parameters); - this.initParentForm($parentForm, widgetId); - }, - - /** - * Checking that reCAPTCHA is invisible type - * @returns {Boolean} - */ - getIsInvisibleRecaptcha: function () { - if (this.settings === - - void 0) { - return false; - } - - return this.settings.invisible; - }, - - /** - * Register this ReCaptcha. - * - * @param {Object} parentForm - * @param {String} widgetId - */ - initParentForm: function (parentForm, widgetId) { - let self = this, - trigger; - - registry._widgets[this.getReCaptchaId()] = widgetId; - - trigger = function () { - self.reCaptchaCallback(grecaptcha.getResponse(widgetId)); - }; - registry._isInvisibleType[this.getReCaptchaId()] = false; - - if (this.getIsInvisibleRecaptcha()) { - trigger = function () { - const response = grecaptcha.execute(widgetId); - if (typeof response === 'object' && typeof response.then === 'function') { - response.then(function (token) { - self.reCaptchaCallback(token); - }); - } else { - self.reCaptchaCallback(response); - } - }; - registry._isInvisibleType[this.getReCaptchaId()] = true; - } - - if (this.autoTrigger) { - //Validate ReCaptcha when initiated - trigger(); - registry.triggers[this.getReCaptchaId()] = new Function(); - } else { - registry.triggers[this.getReCaptchaId()] = trigger; - } - }, - - /** - * Provide the token to the registry. - * - * @param {String} token - */ - reCaptchaCallback: function (token) { - //Make the token retrievable in other UI components. - registry.tokens[this.getReCaptchaId()] = token; - - if (token !== null && typeof registry._listeners[this.getReCaptchaId()] !== 'undefined') { - registry._listeners[this.getReCaptchaId()](token); - } - }, - - getRegistry: function () { - return registry; - }, - - reset: function () { - delete registry.tokens[this.getReCaptchaId()]; - if (typeof registry._widgets[this.getReCaptchaId()] !== 'undefined' - && registry._widgets[this.getReCaptchaId()] !== null) { - grecaptcha.reset(registry._widgets[this.getReCaptchaId()]); - } - } - }); - } -); - diff --git a/view/frontend/web/js/view/payment/method-renderer/card-method.js b/view/frontend/web/js/view/payment/method-renderer/card-method.js index 4ba2676..de09dc3 100644 --- a/view/frontend/web/js/view/payment/method-renderer/card-method.js +++ b/view/frontend/web/js/view/payment/method-renderer/card-method.js @@ -31,7 +31,8 @@ define( 'Magento_Checkout/js/model/full-screen-loader', 'Magento_Checkout/js/model/url-builder', 'Magento_Customer/js/model/customer', - 'Airwallex_Payments/js/view/payment/method-renderer/card-method-recaptcha' + 'Magento_ReCaptchaWebapiUi/js/webapiReCaptchaRegistry' + ], function ( $, @@ -47,7 +48,7 @@ define( fullScreenLoader, urlBuilder, customer, - cardMethodRecaptcha + recaptchaRegistry ) { 'use strict'; @@ -58,7 +59,7 @@ define( cardElement: undefined, validationError: ko.observable(), isRecaptchaEnabled: !!window.checkoutConfig?.payment?.airwallex_payments?.recaptcha_enabled, - recaptcha: null, + recaptchaId: 'recaptcha-checkout-place-order', defaults: { template: 'Airwallex_Payments/payment/card-method' }, @@ -72,7 +73,7 @@ define( country_code: billingAddress.countryId, postcode: billingAddress.postcode, state: billingAddress.region, - street: billingAddress.street[0] + street: billingAddress.street.join(', ') }, first_name: billingAddress.firstname, last_name: billingAddress.lastname, @@ -88,8 +89,6 @@ define( } ); this.cardElement.mount(this.mountElement); - this.recaptcha = cardMethodRecaptcha(); - this.recaptcha.renderReCaptcha(); window.addEventListener( 'onReady', @@ -99,6 +98,13 @@ define( ); }, + getRecaptchaId() { + if ($('#recaptcha-checkout-place-order').length) { + return this.recaptchaId; + } + return $('.airwallex-card-container .g-recaptcha').attr('id') + }, + initiateOrderPlacement: async function () { const self = this; @@ -148,10 +154,14 @@ define( (new Promise(async function (resolve, reject) { try { - const xReCaptchaValue = await (new Promise(function (resolve) { - self.getRecaptchaToken(resolve); - })); - payload.xReCaptchaValue = xReCaptchaValue; + if (self.isRecaptchaEnabled) { + payload.xReCaptchaValue = await new Promise((resolve, reject) => { + recaptchaRegistry.addListener(self.getRecaptchaId(), (token) => { + resolve(token); + }); + recaptchaRegistry.triggers[self.getRecaptchaId()](); + }); + } const intentResponse = await storage.post( serviceUrl, JSON.stringify(payload), true, 'application/json', headers @@ -165,11 +175,8 @@ define( params.element = self.cardElement; payload.intent_id = intentResponse.intent_id; - payload.xReCaptchaValue = null; const airwallexResponse = await Airwallex.confirmPaymentIntent(params); - payload.paymentMethod.additional_data.amount = airwallexResponse.amount - payload.paymentMethod.additional_data.intent_status = airwallexResponse.status payload.paymentMethod.additional_data.intent_id = airwallexResponse.id const endResult = await storage.post( @@ -207,7 +214,6 @@ define( self.processPlaceOrderError.bind(self) ).finally( function () { - self.recaptcha.reset(); fullScreenLoader.stopLoader(); $('body').trigger('processStop'); _.each(placeOrderHooks.afterRequestListeners, function (listener) { @@ -241,28 +247,5 @@ define( this.validationError(response.message); } }, - - getRecaptchaToken: function (callback) { - if (!this.isRecaptchaEnabled) { - return callback(); - } - - const reCaptchaId = this.recaptcha.getReCaptchaId(), - registry = this.recaptcha.getRegistry(); - - if (registry.tokens.hasOwnProperty(reCaptchaId)) { - const response = registry.tokens[reCaptchaId]; - if (typeof response === 'object' && typeof response.then === 'function') { - response.then(function (token) { - callback(token); - }); - } else { - callback(response); - } - } else { - registry._listeners[reCaptchaId] = callback; - registry.triggers[reCaptchaId](); - } - } }); }); diff --git a/view/frontend/web/js/view/payment/method-renderer/express-checkout.js b/view/frontend/web/js/view/payment/method-renderer/express-checkout.js new file mode 100644 index 0000000..f7e1c0a --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/express-checkout.js @@ -0,0 +1,282 @@ +define( + [ + 'jquery', + 'ko', + 'mage/storage', + 'Magento_Customer/js/customer-data', + 'mage/url', + 'uiComponent', + 'Airwallex_Payments/js/view/payment/method-renderer/address/address-handler', + 'Airwallex_Payments/js/view/payment/method-renderer/express/utils', + 'Airwallex_Payments/js/view/payment/method-renderer/express/googlepay', + 'Airwallex_Payments/js/view/payment/method-renderer/express/applepay', + ], + + function ( + $, + ko, + storage, + customerData, + urlBuilder, + Component, + addressHandler, + utils, + googlepay, + applepay, + ) { + 'use strict'; + + return Component.extend({ + code: 'airwallex_payments_express', + defaults: { + paymentConfig: {}, + expressData: {}, + showMinicartSelector: '.showcart', + isShow: false, + buttonSort: ko.observableArray([]), + isShowRecaptcha: ko.observable(false), + guestEmail: "", + }, + + setGuestEmail(email) { + this.guestEmail = email; + }, + + expressDataObjects() { + return [this, utils, applepay, googlepay]; + }, + + methodsObjects() { + return [addressHandler, applepay, googlepay]; + + }, + + async fetchExpressData() { + let url = urlBuilder.build('rest/V1/airwallex/payments/express-data'); + if (utils.isProductPage()) { + url += "?is_product_page=1&product_id=" + $("input[name=product]").val(); + } + const resp = await storage.get(url, undefined, 'application/json', {}); + let obj = JSON.parse(resp); + this.updateExpressData(obj); + this.updatePaymentConfig(obj.settings); + }, + + async postAddress(address, methodId = "") { + let url = urlBuilder.build('rest/V1/airwallex/payments/post-address'); + let postOptions = utils.postOptions(address, url); + if (methodId) { + postOptions.data.append('methodId', methodId); + } + let resp = await $.ajax(postOptions); + + let obj = JSON.parse(resp); + this.updateExpressData(obj.quote_data); + this.updateMethods(obj.methods, obj.selected_method); + addressHandler.regionId = obj.region_id || 0; + return obj; + }, + + updateExpressData(expressData) { + this.expressDataObjects().forEach(o => { + Object.assign(o.expressData, expressData); + }); + utils.toggleMaskFormLogin(); + }, + + updatePaymentConfig(paymentConfig) { + this.expressDataObjects().forEach(o => { + o.paymentConfig = paymentConfig; + }); + }, + + updateMethods(methods, selectedMethod) { + this.methodsObjects().forEach(o => { + o.methods = methods; + o.selectedMethod = selectedMethod; + }); + }, + + initMinicartClickEvents() { + if (!$(this.showMinicartSelector).length) { + return; + } + + let recreatePays = async () => { + if (!$(this.showMinicartSelector).hasClass('active')) { + return; + } + + this.destroyElement(); + await this.fetchExpressData(); + if (this.from === 'minicart' && utils.isCartEmpty(this.expressData)) { + return; + } + this.createPays(); + }; + + let cartData = customerData.get('cart'); + cartData.subscribe(recreatePays, this); + + if (this.from !== 'minicart' || utils.isFromMinicartAndShouldNotShow(this.from)) { + return; + } + $(this.showMinicartSelector).on("click", recreatePays); + }, + + async initialize() { + this._super(); + + this.isShow = ko.observable(false); + + await this.fetchExpressData(); + + if (!this.paymentConfig.is_express_active || this.paymentConfig.display_area.indexOf(this.from) === -1) { + return; + } + + if (utils.isFromMinicartAndShouldNotShow(this.from)) { + return; + } + + googlepay.from = this.from; + applepay.from = this.from; + this.paymentConfig.express_button_sort.forEach(v => { + this.buttonSort.push(v); + }); + + Airwallex.init({ + env: this.paymentConfig.mode, + origin: window.location.origin, + }); + + this.isShow(true); + }, + + async loadPayment() { + utils.toggleMaskFormLogin(); + this.initMinicartClickEvents(); + utils.initProductPageFormClickEvents(); + this.initHashPaymentEvent(); + utils.initCheckoutPageExpressCheckoutClick(); + + if (this.from === 'minicart' && utils.isCartEmpty(this.expressData)) { + return; + } + this.createPays(); + }, + + initHashPaymentEvent() { + window.addEventListener('hashchange', async () => { + if (window.location.hash === '#payment') { + this.destroyElement(); + // we need update quote, because we choose shipping method last step + await this.fetchExpressData(); + this.createPays(); + } + }); + }, + + destroyElement() { + Airwallex.destroyElement('googlePayButton'); + Airwallex.destroyElement('applePayButton'); + }, + + createPays() { + googlepay.create(this); + // applepay.create(this); + }, + + async validateAddresses() { + let url = urlBuilder.build('rest/V1/airwallex/payments/validate-addresses'); + await storage.get(url, undefined, 'application/json', {}); + }, + + placeOrder(pay) { + $('body').trigger('processStart'); + const payload = { + cartId: utils.getCartId(), + paymentMethod: { + method: this.code + } + }; + + let serviceUrl = urlBuilder.build('rest/V1/airwallex/payments/guest-place-order'); + if (utils.isLoggedIn()) { + serviceUrl = urlBuilder.build('rest/V1/airwallex/payments/place-order'); + } + + payload.intent_id = null; + + (new Promise(async (resolve, reject) => { + try { + await this.validateAddresses(); + + if (this.paymentConfig.is_recaptcha_enabled) { + payload.xReCaptchaValue = await utils.recaptchaToken(); + } + + if (!utils.isLoggedIn()) { + payload.email = utils.isCheckoutPage() ? $(utils.guestEmailSelector).val() : this.guestEmail; + if (!payload.email) { + throw new Error('Email is required!'); + } + } + + const intentResponse = await storage.post( + serviceUrl, JSON.stringify(payload), true, 'application/json', {} + ); + + const params = {}; + params.id = intentResponse.intent_id; + params.client_secret = intentResponse.client_secret; + params.payment_method = {}; + params.payment_method.billing = addressHandler.intentConfirmBillingAddressFromGoogle; + if (utils.isCheckoutPage()) { + addressHandler.setIntentConfirmBillingAddressFromOfficial(this.expressData.billing_address); + params.payment_method.billing = addressHandler.intentConfirmBillingAddressFromOfficial; + } + + await eval(pay).confirmIntent(params); + + payload.intent_id = intentResponse.intent_id; + const endResult = await storage.post( + serviceUrl, JSON.stringify(payload), true, 'application/json', {} + ); + + resolve(endResult); + } catch (e) { + this.destroyElement(); + this.createPays(); + reject(e); + } + })).then(response => { + const clearData = { + 'selectedShippingAddress': null, + 'shippingAddressFromData': null, + 'newCustomerShippingAddress': null, + 'selectedShippingRate': null, + 'selectedPaymentMethod': null, + 'selectedBillingAddress': null, + 'billingAddressFromData': null, + 'newCustomerBillingAddress': null + }; + + if (response?.responseType !== 'error') { + customerData.set('checkout-data', clearData); + customerData.invalidate(['cart']); + customerData.reload(['cart'], true); + } + + window.location.replace(urlBuilder.build('checkout/onepage/success/')); + }).catch( + utils.error.bind(utils) + ).finally(() => { + setTimeout(() => { + $('body').trigger('processStop'); + }, 3000); + }); + }, + }); + } +); diff --git a/view/frontend/web/js/view/payment/method-renderer/express/applepay.js b/view/frontend/web/js/view/payment/method-renderer/express/applepay.js new file mode 100644 index 0000000..1af45c1 --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/express/applepay.js @@ -0,0 +1,169 @@ +define([ + 'jquery', + 'Airwallex_Payments/js/view/payment/method-renderer/express/utils', + 'Airwallex_Payments/js/view/payment/method-renderer/address/address-handler', + 'mage/url', +], function ( + $, + utils, + addressHandler, + url, +) { + 'use strict'; + + return { + applepay: null, + expressData: {}, + paymentConfig: {}, + from: '', + methods: [], + selectedMethod: {}, + intermediateShippingAddress: {}, + requiredShippingContactFields: [ + 'email', + 'name', + 'phone', + 'postalAddress', + ], + requiredBillingContactFields: [ + 'postalAddress', + ], + + create(that) { + this.applepay = Airwallex.createElement('applePayButton', this.getRequestOptions()); + this.applepay.mount('awx-apple-pay-' + this.from); + this.attachEvents(that); + utils.loadRecaptcha(that.isShowRecaptcha); + }, + + confirmIntent(params) { + return this.applepay.confirmIntent(params); + }, + + attachEvents(that) { + this.applepay.on('validateMerchant', async (event) => { + try { + const merchantSession = await $.ajax(utils.postOptions({ + validationUrl: event.detail.validationURL, + origin: window.location.host, + }, url.build('rest/V1/airwallex/payments/validate-merchant'))); + this.applepay.completeValidation(JSON.parse(merchantSession)); + } catch (e) { + utils.error(e); + } + }); + + this.applepay.on('shippingAddressChange', async (event) => { + await utils.addToCart(that); + + this.intermediateShippingAddress = addressHandler.getIntermediateShippingAddress(event.detail.shippingAddress); + try { + await that.postAddress(this.intermediateShippingAddress); + } catch (e) { + utils.error(e); + } + let options = this.getRequestOptions(); + if (utils.isRequireShippingOption()) { + options.shippingMethods = addressHandler.formatShippingMethodsToApple(this.methods, this.selectedMethod); + } + this.applepay.update(options); + }); + + this.applepay.on('shippingMethodChange', async (event) => { + try { + await that.postAddress(this.intermediateShippingAddress, event.detail.shippingMethod.identifier); + } catch (e) { + utils.error(e); + } + let options = this.getRequestOptions(); + options.shippingMethods = addressHandler.formatShippingMethodsToApple(this.methods, this.selectedMethod); + this.applepay.update(options); + }); + + this.applepay.on('authorized', async (event) => { + let shipping = event.detail.paymentData.shippingContact; + let billing = event.detail.paymentData.billingContact; + that.setGuestEmail(shipping.emailAddress); + if (utils.isRequireShippingAddress()) { + // this time Apple provide full shipping address, we should post to magento + let information = addressHandler.constructAddressInformationFromApple( + event.detail.paymentData + ); + await addressHandler.postShippingInformation(information, utils.isLoggedIn(), utils.getCartId()); + } else { + await addressHandler.postBillingAddress({ + 'cartId': utils.getCartId(), + 'address': addressHandler.getBillingAddressFromApple(billing, shipping.phoneNumber) + }, utils.isLoggedIn(), utils.getCartId()); + } + addressHandler.setIntentConfirmBillingAddressFromApple(billing, shipping.emailAddress); + that.placeOrder('applepay'); + }); + }, + + getRequestOptions() { + let paymentDataRequest = this.getOptions(); + + if (!utils.isRequireShippingOption() && !utils.isProductPage()) { + paymentDataRequest.requiredShippingContactFields = this.requiredShippingContactFields.filter(e => e !== 'postalAddress'); + } + + const transactionInfo = { + amount: { + value: utils.formatCurrency(this.expressData.grand_total), + currency: $('[property="product:price:currency"]').attr("content") || this.expressData.quote_currency_code, + }, + lineItems: this.getDisplayItems(), + }; + + return Object.assign(paymentDataRequest, transactionInfo); + }, + + getOptions() { + return { + mode: 'payment', + buttonColor: this.paymentConfig.express_style.apple_pay_button_theme, + buttonType: this.paymentConfig.express_style.apple_pay_button_type, + origin: window.location.origin, + totalPriceLabel: this.paymentConfig.express_seller_name || '', + countryCode: this.paymentConfig.country_code, + requiredBillingContactFields: this.requiredBillingContactFields, + requiredShippingContactFields: this.requiredShippingContactFields, + autoCapture: this.paymentConfig.is_express_capture_enabled, + }; + }, + + getDisplayItems() { + let res = []; + for (let key in this.expressData) { + if (this.expressData[key] === '0.0000' || !this.expressData[key]) { + continue; + } + if (key === 'shipping_amount') { + res.push({ + 'label': 'Shipping', + 'amount': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'tax_amount') { + res.push({ + 'label': 'Tax', + 'amount': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'subtotal') { + res.push({ + 'label': 'Subtotal', + 'amount': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'subtotal_with_discount') { + if (this.expressData[key] !== this.expressData['subtotal']) { + res.push({ + 'label': 'Discount', + 'amount': '-' + utils.getDiscount(this.expressData['subtotal'], this.expressData['subtotal_with_discount']).toString() + }); + } + } + } + return res; + }, + }; +}); diff --git a/view/frontend/web/js/view/payment/method-renderer/express/googlepay.js b/view/frontend/web/js/view/payment/method-renderer/express/googlepay.js new file mode 100644 index 0000000..4e5f083 --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/express/googlepay.js @@ -0,0 +1,159 @@ +define([ + 'jquery', + 'Airwallex_Payments/js/view/payment/method-renderer/express/utils', + 'Airwallex_Payments/js/view/payment/method-renderer/address/address-handler', +], function ( + $, + utils, + addressHandler, +) { + 'use strict'; + + return { + googlepay: null, + expressData: {}, + paymentConfig: {}, + from: '', + methods: [], + selectedMethod: {}, + + create(that) { + this.googlepay = Airwallex.createElement('googlePayButton', this.getRequestOptions()); + this.googlepay.mount('awx-google-pay-' + this.from); + this.attachEvents(that); + utils.loadRecaptcha(that.isShowRecaptcha); + }, + + confirmIntent(params) { + return this.googlepay.confirmIntent(params); + }, + + attachEvents(that) { + let updateQuoteByShipment = async (event) => { + await utils.addToCart(that); + + let addr = addressHandler.getIntermediateShippingAddress(event.detail.intermediatePaymentData.shippingAddress); + + try { + let methodId = ""; + if (event.detail.intermediatePaymentData.shippingOptionData) { + methodId = event.detail.intermediatePaymentData.shippingOptionData.id; + } + await that.postAddress(addr, methodId); + } catch (e) { + utils.error(e); + } + + let options = this.getRequestOptions(); + if (utils.isRequireShippingOption()) { + options.shippingOptionParameters = addressHandler.formatShippingMethodsToGoogle(this.methods, this.selectedMethod); + } + this.googlepay.update(options); + }; + + this.googlepay.on('shippingAddressChange', updateQuoteByShipment); + + this.googlepay.on('shippingMethodChange', updateQuoteByShipment); + + this.googlepay.on('authorized', async (event) => { + let data = event.detail.paymentData; + that.setGuestEmail(data.email); + if (utils.isRequireShippingAddress()) { + // this time google provide full shipping address, we should post to magento + let information = addressHandler.constructAddressInformationFromGoogle(data); + await addressHandler.postShippingInformation(information, utils.isLoggedIn(), utils.getCartId()); + } else { + await addressHandler.postBillingAddress({ + 'cartId': utils.getCartId(), + 'address': addressHandler.getBillingAddressFromGoogle(data.paymentMethodData.info.billingAddress) + }, utils.isLoggedIn(), utils.getCartId()); + } + addressHandler.setIntentConfirmBillingAddressFromGoogle(data); + that.placeOrder('googlepay'); + }); + }, + + getRequestOptions() { + let paymentDataRequest = this.getOptions(); + paymentDataRequest.callbackIntents = ['PAYMENT_AUTHORIZATION']; + if (utils.isRequireShippingAddress()) { + paymentDataRequest.callbackIntents.push('SHIPPING_ADDRESS'); + paymentDataRequest.shippingAddressRequired = true; + paymentDataRequest.shippingAddressParameters = { + phoneNumberRequired: this.paymentConfig.is_express_phone_required, + }; + } + + if (utils.isRequireShippingOption()) { + paymentDataRequest.callbackIntents.push('SHIPPING_OPTION'); + paymentDataRequest.shippingOptionRequired = true; + } + + const transactionInfo = { + amount: { + value: utils.formatCurrency(this.expressData.grand_total), + currency: $('[property="product:price:currency"]').attr("content") || this.expressData.quote_currency_code, + }, + countryCode: this.paymentConfig.country_code, + displayItems: this.getDisplayItems(), + }; + + return Object.assign(paymentDataRequest, transactionInfo); + }, + + getOptions() { + return { + mode: 'payment', + buttonColor: this.paymentConfig.express_style.google_pay_button_theme, + buttonType: this.paymentConfig.express_style.google_pay_button_type, + emailRequired: true, + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + phoneNumberRequired: this.paymentConfig.is_express_phone_required + }, + merchantInfo: { + merchantName: this.paymentConfig.express_seller_name || '', + }, + autoCapture: this.paymentConfig.is_express_capture_enabled, + }; + }, + + getDisplayItems() { + let res = []; + for (let key in this.expressData) { + if (this.expressData[key] === '0.0000' || !this.expressData[key]) { + continue; + } + if (key === 'shipping_amount') { + res.push({ + 'label': 'Shipping', + 'type': 'LINE_ITEM', + 'price': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'tax_amount') { + res.push({ + 'label': 'Tax', + 'type': 'TAX', + 'price': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'subtotal') { + res.push({ + 'label': 'Subtotal', + 'type': 'SUBTOTAL', + 'price': utils.formatCurrency(this.expressData[key]) + }); + } else if (key === 'subtotal_with_discount') { + if (this.expressData[key] !== this.expressData['subtotal']) { + res.push({ + 'label': 'Discount', + 'type': 'LINE_ITEM', + 'price': '-' + utils.getDiscount(this.expressData['subtotal'], this.expressData['subtotal_with_discount']).toString() + }); + } + } + } + return res; + }, + }; +}); diff --git a/view/frontend/web/js/view/payment/method-renderer/express/utils.js b/view/frontend/web/js/view/payment/method-renderer/express/utils.js new file mode 100644 index 0000000..3a86b61 --- /dev/null +++ b/view/frontend/web/js/view/payment/method-renderer/express/utils.js @@ -0,0 +1,278 @@ +define([ + 'mage/url', + 'jquery', + 'Magento_Customer/js/model/authentication-popup', + 'Magento_Ui/js/modal/modal', + 'Magento_ReCaptchaWebapiUi/js/webapiReCaptcha', + 'Magento_ReCaptchaWebapiUi/js/webapiReCaptchaRegistry', + 'Magento_Customer/js/customer-data', + 'Magento_Ui/js/modal/alert', +], function ( + urlBuilder, + $, + popup, + modal, + webapiReCaptcha, + webapiRecaptchaRegistry, + customerData, + alert, +) { + 'use strict'; + + return { + productFormSelector: "#product_addtocart_form", + guestEmailSelector: "#customer-email", + cartPageIdentitySelector: '.cart-summary', + checkoutPageIdentitySelector: '#co-payment-form', + buttonMaskSelector: '.aws-button-mask', + buttonMaskSelectorForLogin: '.aws-button-mask-for-login', + expressData: {}, + paymentConfig: {}, + recaptchaSelector: '.airwallex-recaptcha', + recaptchaId: 'recaptcha-checkout-place-order', + + getDiscount(subtotal, subtotal_with_discount) { + let diff = subtotal - subtotal_with_discount; + return diff.toFixed(2); + }, + + formatCurrency(v) { + return parseFloat(v).toFixed(2); + }, + + isCartEmpty() { + return !parseInt(this.expressData.items_qty); + }, + + isProductPage() { + return !!$(this.productFormSelector).length; + }, + + isCartPage() { + return !!$(this.cartPageIdentitySelector).length; + }, + + isCheckoutPage() { + return !!$(this.checkoutPageIdentitySelector).length; + }, + + checkProductForm() { + let formSelector = $(this.productFormSelector); + if (formSelector.length === 0 || !formSelector.validate) { + return true; + } + return $(formSelector).validate().checkForm(); + }, + + validateProductOptions() { + if (this.checkProductForm()) { + $(this.productFormSelector).valid(); + $(this.buttonMaskSelector).hide(); + } else { + $(this.buttonMaskSelector).show(); + } + }, + + showLoginForm(e) { + e.preventDefault(); + popup.showModal(); + if (popup.modalWindow) { + popup.showModal(); + } else { + alert({ + content: $.mage.__('Guest checkout is disabled.') + }); + } + }, + + initCheckoutPageExpressCheckoutClick() { + if (this.isCheckoutPage() && !this.isLoggedIn() && this.expressData.is_virtual) { + this.checkGuestEmailInput(); + $(this.guestEmailSelector).on('input', () => { + this.checkGuestEmailInput(); + }); + $(this.buttonMaskSelector).on('click', (e) => { + e.stopPropagation(); + $($(this.guestEmailSelector).closest('form')).valid(); + this.checkGuestEmailInput(); + }); + } + }, + + checkGuestEmailInput() { + if ($(this.guestEmailSelector).closest('form').validate().checkForm()) { + $(this.buttonMaskSelector).hide(); + } else { + $(this.buttonMaskSelector).show(); + } + }, + + initProductPageFormClickEvents() { + if (this.isProductPage() && this.isSetActiveInProductPage()) { + this.validateProductOptions(); + $(this.productFormSelector).on("click", () => { + this.validateProductOptions(); + }); + $(this.buttonMaskSelector).on('click', (e) => { + e.stopPropagation(); + $(this.productFormSelector).valid(); + this.validateProductOptions(); + }); + $.each($(this.productFormSelector)[0].elements, (index, element) => { + $(element).on('change', () => { + this.validateProductOptions(); + }); + }); + } + }, + + loadRecaptcha(isShowRecaptcha) { + if (!$(this.recaptchaSelector).length) { + return; + } + + if (this.paymentConfig.is_recaptcha_enabled && !$('#recaptcha-checkout-place-order').length) { + window.isShowAwxGrecaptcha = true; + isShowRecaptcha(true); + let re = webapiReCaptcha(); + re.reCaptchaId = this.recaptchaId; + re.settings = this.paymentConfig.recaptcha_settings; + re.renderReCaptcha(); + $(this.recaptchaSelector).css({ + 'visibility': 'hidden', + 'position': 'absolute' + }); + } + }, + + async recaptchaToken() { + return await new Promise((resolve) => { + webapiRecaptchaRegistry.addListener(this.recaptchaId, (token) => { + resolve(token); + }); + webapiRecaptchaRegistry.triggers[this.recaptchaId](); + }); + }, + + toggleMaskFormLogin() { + if (this.isLoggedIn()) { + return; + } + if (this.isCheckoutPage()) { + return; + } + if (!$(this.buttonMaskSelectorForLogin).length) { + return; + } + $(this.buttonMaskSelectorForLogin).off('click').on('click', this.showLoginForm); + if ((this.isProductPage() && this.expressData.product_is_virtual) || this.expressData.is_virtual) { + $(this.buttonMaskSelectorForLogin).show(); + } else { + $(this.buttonMaskSelectorForLogin).hide(); + } + }, + + isSetActiveInProductPage() { + return this.paymentConfig.display_area.indexOf('product_page') !== -1; + }, + + isSetActiveInCartPage() { + return this.paymentConfig.display_area.indexOf('cart_page') !== -1; + }, + + isFromMinicartAndShouldNotShow(from) { + if (from !== 'minicart') { + return false; + } + if (this.isProductPage() && this.isSetActiveInProductPage()) { + return true; + } + return this.isCartPage() && this.isSetActiveInCartPage(); + }, + + isRequireShippingOption() { + if (this.isProductPage()) { + if (this.isCartEmpty()) { + return !this.expressData.product_is_virtual; + } + return !this.expressData.is_virtual || !this.expressData.product_is_virtual; + } + return this.isRequireShippingAddress(); + }, + + isRequireShippingAddress() { + if (this.isProductPage()) { + return true; + } + if (this.isCheckoutPage()) { + return false; + } + return !this.expressData.is_virtual; + }, + + postOptions(data, url) { + let formData = new FormData(); + if (Array.isArray(data)) { + $.each(data, function (index, field) { + formData.append(field.name, field.value); + }); + } else { + for (let k in data) { + formData.append(k, data[k]); + } + } + + return { + url, + data: formData, + processData: false, + contentType: false, + type: 'POST', + }; + }, + + addToCartOptions() { + let arr = $(this.productFormSelector).serializeArray(); + let url = urlBuilder.build('rest/V1/airwallex/payments/add-to-cart'); + return this.postOptions(arr, url); + }, + + async addToCart(that) { + if (this.isProductPage() && this.isSetActiveInProductPage()) { + try { + let res = await $.ajax(this.addToCartOptions()); + that.updateExpressData(JSON.parse(res)); + } catch (res) { + this.error(res); + } + customerData.invalidate(['cart']); + customerData.reload(['cart'], true); + } + }, + + getCartId() { + return this.isLoggedIn() ? this.expressData.cart_id : this.expressData.mask_cart_id; + }, + + isLoggedIn() { + return !!this.expressData.customer_id; + }, + + error(response) { + let modalSelector = $('#awx-modal'); + modal({title: 'Error'}, modalSelector); + + $('body').trigger('processStop'); + let errorMessage = $.mage.__(response.message); + if (response.responseText) { + errorMessage = $.mage.__(response.responseText); + } + if (response.responseJSON) { + errorMessage = $.mage.__(response.responseJSON.message); + } + + $("#awx-modal .modal-body-content").html(errorMessage); + modalSelector.modal('openModal'); + }, + }; +}); diff --git a/view/frontend/web/js/webapiReCaptchaRegistry.js b/view/frontend/web/js/webapiReCaptchaRegistry.js deleted file mode 100644 index 7b85b38..0000000 --- a/view/frontend/web/js/webapiReCaptchaRegistry.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * This file is part of the Airwallex Payments module. - * - * DISCLAIMER - * - * Do not edit or add to this file if you wish to upgrade - * to newer versions in the future. - * - * @copyright Copyright (c) 2021 Magebit, - Ltd. (https://magebit.com/) - * @license GNU General Public License ("GPL") v3.0 - * - * For the full copyright and license information, - please view the LICENSE - * file that was distributed with this source code. - */ -define([], function () { - 'use strict'; - - return { - /** - * recaptchaId: token map. - * - * Tokens for already verified recaptcha. - */ - tokens: {}, - - /** - * recaptchaId: triggerFn map. - * - * Call a trigger to initiate a recaptcha verification. - */ - triggers: {}, - - /** - * recaptchaId: callback map - */ - _listeners: {}, - - /** - * recaptchaId: bool map - */ - _isInvisibleType: {}, - - _widgets: {}, - - /** - * Add a listener to when the ReCaptcha finishes verification - * @param {String} id - ReCaptchaId - * @param {Function} func - Will be called back with the token - */ - addListener: function (id, func) { - if (this.tokens.hasOwnProperty(id)) { - func(this.tokens[id]); - } else { - this._listeners[id] = func; - } - }, - - /** - * Remove a listener - * - * @param id - */ - removeListener: function (id) { - this._listeners[id] = undefined; - } - }; -}); diff --git a/view/frontend/web/template/payment/card-method.html b/view/frontend/web/template/payment/card-method.html index fc1cdb2..380b2c5 100644 --- a/view/frontend/web/template/payment/card-method.html +++ b/view/frontend/web/template/payment/card-method.html @@ -14,7 +14,7 @@ * file that was distributed with this source code. */ --> -
+
-
-

diff --git a/view/frontend/web/template/payment/express-checkout.html b/view/frontend/web/template/payment/express-checkout.html new file mode 100644 index 0000000..be17c99 --- /dev/null +++ b/view/frontend/web/template/payment/express-checkout.html @@ -0,0 +1,26 @@ +
+
+ +
+ +
+ + +
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+