diff --git a/Block/Checkout/Success/AdditionalPaymentInformation.php b/Block/Checkout/Success/AdditionalPaymentInformation.php index 10a47fbd..b61d2ac9 100644 --- a/Block/Checkout/Success/AdditionalPaymentInformation.php +++ b/Block/Checkout/Success/AdditionalPaymentInformation.php @@ -7,6 +7,8 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\View\Element\Template; use Magento\Framework\View\Element\Template\Context; +use Unzer\PAPI\Model\Command\AbstractCommand; +use Unzer\PAPI\Model\Config; use Unzer\PAPI\Model\Method\Base; /** @@ -26,18 +28,25 @@ class AdditionalPaymentInformation extends Template */ protected ?Session $_checkoutSession = null; + /** + * @var Config + */ + protected Config $_config; + /** * AdditionalPaymentInformation constructor. * * @param Context $context * @param Session $checkoutSession + * @param Config $config * @param array $data */ - public function __construct(Context $context, Session $checkoutSession, array $data = []) + public function __construct(Context $context, Session $checkoutSession, Config $config, array $data = []) { parent::__construct($context, $data); $this->_checkoutSession = $checkoutSession; + $this->_config = $config; } /** @@ -60,4 +69,40 @@ public function getAdditionalPaymentInformation(): ?string return $methodInstance->getAdditionalPaymentInformation($order); } + + /** + * Returns the Unzer Payment ID of the placed order, only in sandbox (test) mode. + * + * @return string|null + * @throws LocalizedException + */ + public function getUnzerPaymentId(): ?string + { + $order = $this->_checkoutSession->getLastRealOrder(); + $payment = $order->getPayment(); + + if ($payment === null) { + return null; + } + + $methodInstance = $payment->getMethodInstance(); + + if (!$methodInstance instanceof Base) { + return null; + } + + $storeId = $order->getStoreId() !== null ? (string)$order->getStoreId() : null; + + if (!$this->_config->isSandboxMode($storeId, $methodInstance)) { + return null; + } + + $paymentId = $payment->getAdditionalInformation(AbstractCommand::KEY_PAYMENT_ID); + + if (!is_string($paymentId) || $paymentId === '') { + return null; + } + + return $paymentId; + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a9873c..a99fb414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [4.1.0](https://github.com/unzerdev/magento2/compare/4.0.6..4.1.0) +### Changed +* Add compatibility with Magento 2.4.9 and PHP 8.5 +* Refactor the order status logic for Direct Bank Transfer +* Update metadata payloads +* Fix B2B customer creation for non-UPL payment methods +* Add state mapping for billing and shipping addresses +* Fix Unzer customer resolution for saved COF payments +* Enhance UI Components v2 + * Update the company name after editing billing address + * Fetch merchant configuration using the public key + * Improve whenDefined usage + ## [4.0.6](https://github.com/unzerdev/magento2/compare/4.0.5..4.0.6) ### Changed * Handle rounding differences in basket calculations using 4-decimal precision diff --git a/Helper/Order.php b/Helper/Order.php index 4e8a4fd8..9575a5ad 100644 --- a/Helper/Order.php +++ b/Helper/Order.php @@ -18,10 +18,14 @@ use Magento\Store\Model\ScopeInterface; use Magento\Tax\Model\Config as MagentoTaxConfig; use Unzer\PAPI\Block\System\Config\Form\Field\BirthDateFactory; +use Unzer\PAPI\Model\PluginResolver; use Unzer\PAPI\Model\Config; use Unzer\PAPI\Model\Source\CreateThreatMetrixId; use Unzer\PAPI\Model\Source\Customer as CustomerResource; use UnzerSDK\Constants\BasketItemTypes; +use UnzerSDK\Constants\CompanyCommercialSectorItems; +use UnzerSDK\Constants\CompanyRegistrationTypes; +use UnzerSDK\Constants\CompanyTypes; use UnzerSDK\Constants\Salutations; use UnzerSDK\Constants\ShippingTypes; use UnzerSDK\Exceptions\UnzerApiException; @@ -91,6 +95,12 @@ class Order */ private ResolverInterface $localeResolver; + /** + * @var PluginResolver + */ + private PluginResolver $pluginResolver; + + /** * Constructor * @@ -103,6 +113,7 @@ class Order * @param BirthDateFactory $birthDateFactory * @param CreateThreatMetrixId $createThreatMetrixId * @param ResolverInterface $localeResolver + * @param PluginResolver $pluginResolver */ public function __construct( Config $moduleConfig, @@ -113,7 +124,8 @@ public function __construct( BasketItemFactory $basketItemFactory, BirthDateFactory $birthDateFactory, CreateThreatMetrixId $createThreatMetrixId, - ResolverInterface $localeResolver + ResolverInterface $localeResolver, + PluginResolver $pluginResolver ) { $this->_moduleConfig = $moduleConfig; $this->_moduleList = $moduleList; @@ -124,6 +136,7 @@ public function __construct( $this->birthDateFactory = $birthDateFactory; $this->createThreatMetrixId = $createThreatMetrixId; $this->localeResolver = $localeResolver; + $this->pluginResolver = $pluginResolver; } /** @@ -306,10 +319,17 @@ public function createMetadataForOrder(OrderModel $order): Metadata { $metaData = new Metadata(); + $module = $this->pluginResolver->resolve( + (int) $order->getStoreId() + ); + $metaData->setShopType('Magento 2') ->setShopVersion($this->_productMetadata->getVersion()) - ->addMetadata('pluginType', 'unzerdev/magento2') - ->addMetadata('pluginVersion', $this->_moduleList->getOne('Unzer_PAPI')['setup_version']); + ->addMetadata('pluginType', $module['type']); + + if (isset($module['version'])) { + $metaData->addMetadata('pluginVersion', $module['version']); + } return $metaData; } @@ -447,19 +467,8 @@ public function createCustomerFromOrder( $this->updateGatewayAddressFromMagento($customer->getShippingAddress(), $shippingAddress, $shippingType); } - if ($customerType && $customerType !== 'b2c') { - $companyInfo = new CompanyInfo(); - $companyInfo->setCompanyType($customerType); - $companyInfo->setRegistrationType('not_registered'); - $companyInfo->setFunction('OWNER'); - $owner = new CompanyOwner(); - $owner->setFirstname($customer->getFirstname()); - $owner->setLastname($customer->getLastname()); - $birthDate && $owner->setBirthdate($birthDate); - $companyInfo->setOwner($owner); - - $customer->setCompanyInfo($companyInfo); - } + $company = $billingAddress !== null ? $billingAddress->getCompany() : null; + $this->applyCompanyInfo($customer, $company, $customerType, $birthDate); return $createResource ? $client->createOrUpdateCustomer($customer) : $customer; } @@ -504,13 +513,52 @@ private function updateGatewayAddressFromMagento( $gatewayAddress->setName($magentoAddress->getFirstname() . ' ' . $magentoAddress->getLastname()); $gatewayAddress->setCity($magentoAddress->getCity()); $gatewayAddress->setCountry($magentoAddress->getCountryId()); + $state = $magentoAddress->getRegion(); + if (!empty($state)) { + $gatewayAddress->setState($state); + } $gatewayAddress->setStreet($street); $gatewayAddress->setZip($magentoAddress->getPostcode()); + $gatewayAddress->setCompany($magentoAddress->getCompany() ?: null); if ($magentoAddress->getAddressType() === Quote\Address::ADDRESS_TYPE_SHIPPING) { $gatewayAddress->setShippingType($shippingType); } } + /** + * @param Customer $customer + * @param string|null $company + * @param string|null $customerType + * @param string|null $birthDate + */ + private function applyCompanyInfo( + Customer $customer, + ?string $company, + ?string $customerType = null, + ?string $birthDate = null + ): void { + $isExplicitB2b = !empty($customerType) && $customerType !== 'b2c'; + + if (empty($company) && !$isExplicitB2b) { + $customer->setCompanyInfo(null); + return; + } + + $companyInfo = new CompanyInfo(); + $companyInfo->setCompanyType(CompanyTypes::OTHER); + $companyInfo->setRegistrationType(CompanyRegistrationTypes::REGISTRATION_TYPE_NOT_REGISTERED); + $companyInfo->setFunction('OWNER'); + $companyInfo->setCommercialSector(CompanyCommercialSectorItems::OTHER); + + $owner = new CompanyOwner(); + $owner->setFirstname($customer->getFirstname()); + $owner->setLastname($customer->getLastname()); + $birthDate && $owner->setBirthdate($birthDate); + $companyInfo->setOwner($owner); + + $customer->setCompanyInfo($companyInfo); + } + /** * Get Shipping Type * @@ -577,9 +625,12 @@ public function updateGatewayCustomerFromOrder(OrderModel $order, Customer $gate ?? Salutations::UNKNOWN ); - $gatewayCustomer->setCompany($billingAddress->getCompany()); + $company = $billingAddress->getCompany(); + $gatewayCustomer->setCompany($company); $gatewayCustomer->setEmail($billingAddress->getEmail()); + $this->applyCompanyInfo($gatewayCustomer, $company, null, $this->getBirthdateFromPayment($order->getPayment())); + $this->updateGatewayAddressFromMagento( $gatewayCustomer->getBillingAddress(), $billingAddress @@ -617,7 +668,10 @@ public function validateGatewayCustomerAgainstOrder(OrderModel $order, Customer // Magento's getCompany() always returns a string, but the Unzer Customer Address does not, so we must make // sure that both have the same type. - $companyValid = ($order->getBillingAddress()->getCompany() ?? '') === ($gatewayCustomer->getCompany() ?? ''); + $company = $order->getBillingAddress()->getCompany() ?? ''; + $companyValid = $company === ($gatewayCustomer->getCompany() ?? ''); + + $companyInfoValid = ($company !== '') === ($gatewayCustomer->getCompanyInfo() !== null); $emailValid = $order->getCustomerEmail() === $gatewayCustomer->getEmail(); $billingAddressValid = $this->validateGatewayAddressAgainstOrderAddress( @@ -634,7 +688,12 @@ public function validateGatewayCustomerAgainstOrder(OrderModel $order, Customer ); } - return $nameValid && $companyValid && $billingAddressValid && $shippingAddressValid && $emailValid; + return $nameValid + && $companyValid + && $companyInfoValid + && $billingAddressValid + && $shippingAddressValid + && $emailValid; } /** @@ -654,7 +713,8 @@ private function validateGatewayAddressAgainstOrderAddress( return $gatewayAddress->getCity() === $magentoAddress->getCity() && $gatewayAddress->getCountry() === $magentoAddress->getCountryId() && $gatewayAddress->getStreet() === $street - && $gatewayAddress->getZip() === $magentoAddress->getPostcode(); + && $gatewayAddress->getZip() === $magentoAddress->getPostcode() + && ($gatewayAddress->getCompany() ?? '') === ($magentoAddress->getCompany() ?? ''); } /** diff --git a/Helper/Payment.php b/Helper/Payment.php index ff1550ab..7b1b45de 100644 --- a/Helper/Payment.php +++ b/Helper/Payment.php @@ -13,43 +13,30 @@ use Magento\Framework\Lock\LockManagerInterface; use Magento\Payment\Gateway\Data\PaymentDataObjectFactoryInterface; use Magento\Sales\Api\Data\OrderInterface; -use Magento\Sales\Api\Data\OrderPaymentInterface; -use Magento\Sales\Api\Data\TransactionInterface; -use Magento\Sales\Api\InvoiceRepositoryInterface; -use Magento\Sales\Api\OrderPaymentRepositoryInterface; -use Magento\Sales\Api\TransactionRepositoryInterface; -use Magento\Sales\Model\Order; -use Magento\Sales\Model\Order\Email\Sender\InvoiceSender; -use Magento\Sales\Model\Order\Email\Sender\OrderSender; use Magento\Sales\Model\OrderRepository; -use Unzer\PAPI\Model\Command\TransactionSynchronizer; use Unzer\PAPI\Model\Method\Base; +use Unzer\PAPI\Model\Payment\Status\OrderStateApplier; +use Unzer\PAPI\Model\Payment\Status\Processor\ProcessorPool; use Unzer\PAPI\Model\Vault\VaultDetailsHandlerManager; use UnzerSDK\Constants\PaymentState; use UnzerSDK\Exceptions\UnzerApiException; use UnzerSDK\Resources\Payment as PaymentResource; -use UnzerSDK\Resources\PaymentTypes\BasePaymentType; use UnzerSDK\Resources\TransactionTypes\AbstractTransactionType; /** - * Helper for cancellation state management + * Thin coordinator on top of the Unzer payment-status processor pool. + * + * Holds the cross-cutting concerns that aren't specific to a single + * Unzer state — the per-order lock, vault-token persistence, and the + * public {@see setOrderState} façade still consumed by + * Controller\Payment\Redirect — then delegates the actual state-by-state + * mapping to a {@see ProcessorPool}-resolved processor. * * @link https://docs.unzer.com/ */ class Payment { public const STATUS_READY_TO_CAPTURE = 'unzer_ready_to_capture'; - private const METHOD_PREPAYMENT = 'unzer_prepayment'; - - /** - * @var InvoiceRepositoryInterface - */ - private InvoiceRepositoryInterface $_invoiceRepository; - - /** - * @var InvoiceSender - */ - private InvoiceSender $_invoiceSender; /** * @var LockManagerInterface @@ -62,29 +49,14 @@ class Payment private OrderRepository $_orderRepository; /** - * @var OrderSender - */ - private OrderSender $_orderSender; - - /** - * @var Order\OrderStateResolverInterface - */ - private Order\OrderStateResolverInterface $_orderStateResolver; - - /** - * @var Order\StatusResolver + * @var ProcessorPool */ - private Order\StatusResolver $_orderStatusResolver; + private ProcessorPool $processorPool; /** - * @var OrderPaymentRepositoryInterface + * @var OrderStateApplier */ - private OrderPaymentRepositoryInterface $_paymentRepository; - - /** - * @var TransactionRepositoryInterface - */ - private TransactionRepositoryInterface $_transactionRepository; + private OrderStateApplier $orderStateApplier; /** * @var VaultDetailsHandlerManager @@ -97,56 +69,33 @@ class Payment private PaymentDataObjectFactoryInterface $paymentDataObjectFactory; /** - * @var TransactionSynchronizer - */ - private TransactionSynchronizer $transactionSynchronizer; - - /** - * Constructor - * - * @param InvoiceRepositoryInterface $invoiceRepository - * @param InvoiceSender $invoiceSender * @param LockManagerInterface $lockManager * @param OrderRepository $orderRepository - * @param OrderSender $orderSender - * @param Order\OrderStateResolverInterface $orderStateResolver - * @param Order\StatusResolver $orderStatusResolver - * @param OrderPaymentRepositoryInterface $paymentRepository - * @param TransactionRepositoryInterface $transactionRepository + * @param ProcessorPool $processorPool + * @param OrderStateApplier $orderStateApplier * @param VaultDetailsHandlerManager $vaultDetailsHandlerManager * @param PaymentDataObjectFactoryInterface $paymentDataObjectFactory - * @param TransactionSynchronizer $transactionSynchronizer */ public function __construct( - InvoiceRepositoryInterface $invoiceRepository, - InvoiceSender $invoiceSender, LockManagerInterface $lockManager, OrderRepository $orderRepository, - OrderSender $orderSender, - Order\OrderStateResolverInterface $orderStateResolver, - Order\StatusResolver $orderStatusResolver, - OrderPaymentRepositoryInterface $paymentRepository, - TransactionRepositoryInterface $transactionRepository, + ProcessorPool $processorPool, + OrderStateApplier $orderStateApplier, VaultDetailsHandlerManager $vaultDetailsHandlerManager, - PaymentDataObjectFactoryInterface $paymentDataObjectFactory, - TransactionSynchronizer $transactionSynchronizer + PaymentDataObjectFactoryInterface $paymentDataObjectFactory ) { - $this->_invoiceRepository = $invoiceRepository; - $this->_invoiceSender = $invoiceSender; $this->_lockManager = $lockManager; $this->_orderRepository = $orderRepository; - $this->_orderSender = $orderSender; - $this->_orderStateResolver = $orderStateResolver; - $this->_orderStatusResolver = $orderStatusResolver; - $this->_paymentRepository = $paymentRepository; - $this->_transactionRepository = $transactionRepository; + $this->processorPool = $processorPool; + $this->orderStateApplier = $orderStateApplier; $this->vaultDetailsHandlerManager = $vaultDetailsHandlerManager; $this->paymentDataObjectFactory = $paymentDataObjectFactory; - $this->transactionSynchronizer = $transactionSynchronizer; } /** - * Process state + * Apply the Unzer payment state to the given Magento order under a + * per-order lock. Vault details run once up-front, then the + * method-specific processor takes over the state-by-state mapping. * * @param OrderInterface $order * @param PaymentResource $payment @@ -172,274 +121,38 @@ public function processState(OrderInterface $order, PaymentResource $payment): v try { $this->processVaultDetails($order, $payment); - switch ($payment->getState()) { - case PaymentState::STATE_CANCELED: - $this->processCanceledState($order, $payment); - break; - case PaymentState::STATE_COMPLETED: - $this->processCompletedState($order, $payment); - break; - case PaymentState::STATE_CHARGEBACK: - $this->processChargebackState($order, $payment); - break; - case PaymentState::STATE_PARTLY: - $this->processPartlyState($order, $payment); - break; - case PaymentState::STATE_PAYMENT_REVIEW: - $this->processPaymentReviewState($order); - break; - case PaymentState::STATE_PENDING: - $this->processPendingState($order, $payment); - break; - } + $processor = $this->processorPool->get($order->getPayment()->getMethod()); + $processor->process($order, $payment); } finally { $this->_lockManager->unlock($lockName); } } /** - * Process canceled state + * Resolve, persist, and email the order's state/status. Public for + * Controller\Payment\Redirect, which sets the pre-redirect state + * directly before sending the customer to the provider URL. * * @param OrderInterface $order - * @param PaymentResource $payment - * - * @return void - * - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - * @throws UnzerApiException - */ - private function processCanceledState(OrderInterface $order, PaymentResource $payment): void - { - $this->transactionSynchronizer->applyCancellationOnMagento($order, $payment); - - // Orders in payment_review can't be cancelled so we must manually - // change the status so that we can cancel the Order. - if ($order->isPaymentReview()) { - $order->setState(Order::STATE_PROCESSING); - } - - // if the payment was voided, we do not want to cancel the whole order and invoice - if (!$this->isOrderVoided($order)) { - - /** @var Order\Invoice[] $invoices */ - $invoices = $order->getInvoiceCollection()->getItems(); - - foreach ($invoices as $invoice) { - $invoice->cancel(); - $this->_invoiceRepository->save($invoice); - } - - if ($order->canCancel()) { - $order->cancel(); - $this->_orderRepository->save($order); - } - } - - if ($payment->getAmount()->getTotal() && $payment->getAmount()->getTotal() === $payment->getAmount()->getCanceled()) { - $this->setOrderState($order, Order::STATE_CLOSED, Order::STATE_CLOSED); - $this->_orderRepository->save($order); - } - } - - /** - * Is order voided - * - * @param OrderInterface $order - * - * @return bool - * @throws InputException - */ - private function isOrderVoided(OrderInterface $order): bool - { - $voidedPaymentTransaction = $this->_transactionRepository->getByTransactionType( - TransactionInterface::TYPE_VOID, - $order->getPayment()->getId() - ); - - return (bool)$voidedPaymentTransaction; - } - - /** - * Process complete state - * - * @param OrderInterface $order - * @param PaymentResource $payment + * @param string|null $state + * @param string|null $status * * @return void - * * @throws AlreadyExistsException * @throws InputException * @throws LocalizedException * @throws NoSuchEntityException - * @throws UnzerApiException - */ - private function processCompletedState(OrderInterface $order, PaymentResource $payment): void - { - $this->transactionSynchronizer->applyCaptureOnMagento($order, $payment); - - $orderPayment = $order->getPayment(); - - $transactionId = $order->getPayment()->getLastTransId(); - - /** @var Order\Invoice $invoice */ - $invoice = $order->getInvoiceCollection()->getItemByColumnValue('transaction_id', $transactionId); - - if ($invoice !== null && (int)$invoice->getState() === Order\Invoice::STATE_OPEN) { - $invoice->pay(); - - $order = $invoice->getOrder(); - $orderPayment = $order->getPayment(); - - $this->_invoiceRepository->save($invoice); - $this->_orderRepository->save($order); - $this->_paymentRepository->save($orderPayment); - } - - /** @var Order\Payment\Transaction $paymentTransaction */ - $paymentTransaction = $this->_transactionRepository->getByTransactionId( - $transactionId, - $orderPayment->getId(), - $order->getId() - ); - - if ($paymentTransaction && !$paymentTransaction->getIsClosed()) { - $paymentTransaction->setIsClosed(true); - - $this->_transactionRepository->save($paymentTransaction); - - $parentPaymentTransaction = $paymentTransaction->getParentTransaction(); - if (!empty($parentPaymentTransaction) && - !$parentPaymentTransaction->getIsClosed() - ) { - $parentPaymentTransaction->setIsClosed(true); - $this->_transactionRepository->save($parentPaymentTransaction); - } - } - - // Need to set to processing, otherwise the state resolver will not complete the order, when we are - // currently in payment review (e.g. with invoice). - $order->setState(Order::STATE_PROCESSING); - - $this->setOrderState($order); - } - - /** - * Process chargeback state - * - * @param OrderInterface $order - * @param PaymentResource $payment - * - * @return void * @throws Exception */ - private function processChargebackState(OrderInterface $order, PaymentResource $payment): void - { - $this->transactionSynchronizer->applyChargebackOnMagento($order, $payment); - if ($order->getState() !== Order::STATE_CANCELED && - $order->getState() !== Order::STATE_CLOSED) { - $this->setOrderState($order, Order::STATE_PAYMENT_REVIEW, Order::STATUS_FRAUD); - } - } - - /** - * Process partly state - * - * @param OrderInterface $order - * @param PaymentResource $payment - * - * @return void - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - * @throws UnzerApiException - */ - private function processPartlyState(OrderInterface $order, PaymentResource $payment): void - { - $this->transactionSynchronizer->applyCancellationOnMagento($order, $payment); - $this->transactionSynchronizer->applyCaptureOnMagento($order, $payment); - - if($order->getPayment()->getMethod() === self::METHOD_PREPAYMENT) { - $this->setOrderState($order, Order::STATE_PENDING_PAYMENT); - - return; - } - - $this->setOrderState($order); - } - - /** - * Process payment review state - * - * @param OrderInterface $order - * - * @return void - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - */ - private function processPaymentReviewState(OrderInterface $order): void - { - $this->setOrderState($order, Order::STATE_PAYMENT_REVIEW); - } - - /** - * Process pending state - * - * @param OrderInterface $order - * @param PaymentResource $payment - * - * @return void - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - * @throws UnzerApiException - */ - private function processPendingState(OrderInterface $order, PaymentResource $payment): void - { - $authorization = $payment->getAuthorization(); - - if ($authorization !== null && $authorization->isSuccess() && $order->getState() !== Order::STATE_PROCESSING) { - $this->setOrderState($order, Order::STATE_PROCESSING, self::STATUS_READY_TO_CAPTURE); - } elseif ($payment->getPaymentType() instanceof BasePaymentType - && $payment->getPaymentType()->isInvoiceType() - ) { - $this->setInvoiceTypeState($order); - } - } - - /** - * Set Invoice Type State - * - * @param OrderInterface $order - * - * @return void - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - */ - private function setInvoiceTypeState(OrderInterface $order): void + public function setOrderState(OrderInterface $order, ?string $state = null, ?string $status = null): void { - // canShip returns false when the order is currently in payment_review state so we must temporarily change - // the state for canShip to return the desired value. - $order->setState(Order::STATE_PROCESSING); - - // The order has not been shipped yet. - if ($order->canShip()) { - $this->setOrderState($order, Order::STATE_PROCESSING); - } else { - $this->setOrderState($order, Order::STATE_PAYMENT_REVIEW); - } + $this->orderStateApplier->setOrderState($order, $state, $status); } /** - * Process Vault Details + * Persist a vault token for the just-completed payment when the + * method opts into "save on success". Runs ahead of state routing + * since the token can be reused even for non-completed states. * * @param OrderInterface $order * @param PaymentResource $payment @@ -480,76 +193,4 @@ private function processVaultDetails(OrderInterface $order, PaymentResource $pay $this->vaultDetailsHandlerManager->getHandlerByCode($paymentMethodCode) ->handle($paymentDataObject, $transactionType); } - - /** - * Set Order State - * - * @param OrderInterface $order - * @param string|null $state - * @param string|null $status - * - * @return void - * @throws AlreadyExistsException - * @throws InputException - * @throws LocalizedException - * @throws NoSuchEntityException - * @throws Exception - */ - public function setOrderState(OrderInterface $order, ?string $state = null, ?string $status = null): void - { - if ($state === null) { - $state = $this->_orderStateResolver->getStateForOrder($order, [ - Order\OrderStateResolverInterface::IN_PROGRESS, - ]); - } - - if ($status === null) { - $status = $this->_orderStatusResolver->getOrderStatusByState($order, $state); - } - - $order->setState($state); - $order->setStatus($status); - - if ($order->hasDataChanges()) { - $this->_orderRepository->save($order); - } - - // email already sent? - if ($order->getEmailSent()) { - return; - } - - if (in_array($state, [Order::STATE_NEW, Order::STATE_CANCELED, Order::STATE_PENDING_PAYMENT], true)) { - return; - } - - $this->sendEmails($order); - } - - /** - * Send Emails - * - * @param OrderInterface $order - * - * @return void - * @throws LocalizedException - * @throws Exception - */ - protected function sendEmails(OrderInterface $order): void - { - // send order emails now, since we skipped them in Unzer\PAPI\Model\Command\Order - // which is only used for canOrder methods - if ($order->getPayment() instanceof OrderPaymentInterface - && $order->getPayment()->getMethodInstance()->canOrder() - ) { - $this->_orderSender->send($order); - - foreach ($order->getInvoiceCollection() as $invoice) { - /** @var Order\Invoice $invoice */ - if (!$invoice->getEmailSent()) { - $this->_invoiceSender->send($invoice); - } - } - } - } } diff --git a/Model/Command/AbstractCommand.php b/Model/Command/AbstractCommand.php index 213aa24d..d89ee6ae 100644 --- a/Model/Command/AbstractCommand.php +++ b/Model/Command/AbstractCommand.php @@ -133,6 +133,10 @@ protected function _getCustomerId(InfoInterface $payment, SalesOrder $order): ?s /** @var string|null $customerId */ $customerType = (string)$payment->getAdditionalInformation(BaseDataAssignObserver::KEY_CUSTOMER_TYPE); + if ($this->hasVaultPaymentToken($payment)) { + $customerId = ''; + } + $customer = $this->getCustomer( $customerId, $order->getStore()->getCode(), @@ -191,6 +195,24 @@ protected function getCustomer( } } + /** + * Whether the given payment is processed with a stored vault payment token (saved card / COF flow). + * + * @param InfoInterface $payment + * + * @return bool + */ + private function hasVaultPaymentToken(InfoInterface $payment): bool + { + if (!$payment instanceof OrderPayment) { + return false; + } + + $extensionAttributes = $payment->getExtensionAttributes(); + + return $extensionAttributes !== null && $extensionAttributes->getVaultPaymentToken() !== null; + } + /** * Returns the resource ID for given current payment or quote. Creates or update customer on Api side if needed. * diff --git a/Model/Command/TransactionSynchronizer.php b/Model/Command/TransactionSynchronizer.php index c4a3a18d..59aaa556 100644 --- a/Model/Command/TransactionSynchronizer.php +++ b/Model/Command/TransactionSynchronizer.php @@ -43,7 +43,7 @@ public function applyCaptureOnMagento(OrderInterface $order, UnzerPayment $unzer { $payment = $this->getOrderPayment($order); $charges = $unzer->getCharges(); - $capture = $charges[array_key_last($charges)] ?? null; + $capture = $charges === [] ? null : $charges[array_key_last($charges)]; if (!$payment || !$capture) { return; @@ -87,8 +87,7 @@ public function applyCancellationOnMagento(OrderInterface $order, UnzerPayment $ { $payment = $this->getOrderPayment($order); $cancellations = $unzer->getCancellations(); - $cancellation = $cancellations[array_key_last($cancellations)] ?? null; - + $cancellation = $cancellations === [] ? null : $cancellations[array_key_last($cancellations)]; if (!$payment || !$cancellation) { return; } @@ -155,7 +154,7 @@ public function applyChargebackOnMagento(OrderInterface $order, UnzerPayment $un { $payment = $this->getOrderPayment($order); $chargebacks = $unzer->getChargebacks(); - $chargeback = $chargebacks[array_key_last($chargebacks)] ?? null; + $chargeback = $chargebacks === [] ? null : $chargebacks[array_key_last($chargebacks)]; if (!$payment || !$chargeback) { return; diff --git a/Model/Config.php b/Model/Config.php index 56e34c48..aee35496 100644 --- a/Model/Config.php +++ b/Model/Config.php @@ -3,14 +3,19 @@ namespace Unzer\PAPI\Model; +use Magento\Framework\App\CacheInterface; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Locale\Resolver; +use Magento\Framework\Serialize\SerializerInterface; use Magento\Payment\Model\CcConfig; use Magento\Payment\Model\MethodInterface; use Magento\Store\Model\ScopeInterface; use Magento\Framework\HTTP\PhpEnvironment\Request; use Unzer\PAPI\Model\Logger\DebugHandler; use Unzer\PAPI\Model\Method\OverrideApiCredentialInterface; +use UnzerSDK\Adapter\HttpAdapterInterface; +use UnzerSDK\Apis\ApiRequest; +use UnzerSDK\Resources\Keypair; use UnzerSDK\Unzer; /** @@ -29,6 +34,13 @@ class Config extends \Magento\Payment\Gateway\Config\Config public const CREATE_VAULT_TOKEN_ON_SUCCESS = 'create_vault_token_on_success'; + public const MERCHANT_CONFIG_CACHE_PREFIX = 'unzer_merchant_config_'; + + /** + * Lifetime of the cached merchant config in seconds. + */ + private const MERCHANT_CONFIG_CACHE_LIFETIME = 86400; + public const METHOD_BASE = 'unzer'; public const METHOD_CARDS = 'unzer_cards'; public const METHOD_CARDS_VAULT = 'unzer_cards_vault'; @@ -80,6 +92,16 @@ class Config extends \Magento\Payment\Gateway\Config\Config */ private CcConfig $ccConfig; + /** + * @var CacheInterface + */ + private CacheInterface $_cache; + + /** + * @var SerializerInterface + */ + private SerializerInterface $_serializer; + /** * Config constructor. * @@ -88,6 +110,8 @@ class Config extends \Magento\Payment\Gateway\Config\Config * @param DebugHandler $debugHandler * @param CcConfig $ccConfig * @param Request $request + * @param CacheInterface $cache + * @param SerializerInterface $serializer * @param string|null $methodCode * @param string $pathPattern */ @@ -97,6 +121,8 @@ public function __construct( DebugHandler $debugHandler, CcConfig $ccConfig, Request $request, + CacheInterface $cache, + SerializerInterface $serializer, ?string $methodCode = null, string $pathPattern = self::DEFAULT_PATH_PATTERN ) { @@ -107,6 +133,8 @@ public function __construct( $this->_scopeConfig = $scopeConfig; $this->ccConfig = $ccConfig; $this->_request = $request; + $this->_cache = $cache; + $this->_serializer = $serializer; } /** @@ -168,6 +196,20 @@ public function getPrivateKey(?string $storeId = null, ?MethodInterface $payment ); } + /** + * Returns whether the configured keypair belongs to the sandbox (test) environment. + * + * Unzer sandbox keys are prefixed with "s-", production keys with "p-". + * + * @param string|null $storeId + * @param MethodInterface|null $paymentMethodInstance + * @return bool + */ + public function isSandboxMode(?string $storeId = null, ?MethodInterface $paymentMethodInstance = null): bool + { + return str_starts_with((string)$this->getPublicKey($storeId, $paymentMethodInstance), 's-'); + } + /** * Returns an API client using the configured private key. * @@ -177,8 +219,80 @@ public function getPrivateKey(?string $storeId = null, ?MethodInterface $payment */ public function getUnzerClient(?string $storeId = null, ?MethodInterface $paymentMethodInstance = null): Unzer { - $client = new Unzer( + return $this->buildUnzerClient( $this->getPrivateKey($storeId, $paymentMethodInstance), + $storeId + ); + } + + /** + * @param string|null $storeId + * @param MethodInterface|null $paymentMethodInstance + * + * @return array|null + */ + public function getMerchantConfig( + ?string $storeId = null, + ?MethodInterface $paymentMethodInstance = null + ): ?array { + $publicKey = $this->getPublicKey($storeId, $paymentMethodInstance); + + if (empty($publicKey)) { + return null; + } + + $cacheKey = self::MERCHANT_CONFIG_CACHE_PREFIX . hash('sha256', $publicKey); + $cached = $this->_cache->load($cacheKey); + + if ($cached) { + return $this->_serializer->unserialize($cached); + } + + try { + $client = $this->buildUnzerClient($publicKey, $storeId); + $keypair = (new Keypair())->setParentResource($client)->setDetailed(true); + + $request = new ApiRequest( + $keypair->getUri(), + $keypair, + HttpAdapterInterface::REQUEST_GET, + $keypair->getUnzerObject(), + $keypair->getApiVersion() + ); + + $rawResponse = $client->getHttpService()->sendRequest($request); + + $config = json_decode($rawResponse, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $e) { + $this->_debugHandler->log('Failed to fetch Unzer merchant config: ' . $e->getMessage()); + return null; + } + + if (!is_array($config)) { + return null; + } + + $this->_cache->save( + $this->_serializer->serialize($config), + $cacheKey, + [], + self::MERCHANT_CONFIG_CACHE_LIFETIME + ); + + return $config; + } + + + /** + * @param string|null $key + * @param string|null $storeId + * + * @return Unzer + */ + private function buildUnzerClient(?string $key, ?string $storeId = null): Unzer + { + $client = new Unzer( + (string)$key, $this->_localeResolver->getLocale() ); diff --git a/Model/Config/Provider.php b/Model/Config/Provider.php index e0b29170..3cca2b18 100644 --- a/Model/Config/Provider.php +++ b/Model/Config/Provider.php @@ -101,10 +101,13 @@ public function getConfig(): array /** @var Customer $baseCustomer */ $baseCustomer = $quote ? $this->fetchUnzerCustomer($quote) : null; + $storeId = $quote?->getStore()->getCode(); + $methodConfigs = [ Config::METHOD_BASE => [ 'publicKey' => $this->_moduleConfig->getPublicKey(), - 'locale' => str_replace('_', '-', $this->scopeConfig->getValue('general/locale/code', 'store')) + 'locale' => str_replace('_', '-', $this->scopeConfig->getValue('general/locale/code', 'store')), + 'merchantConfig' => $this->_moduleConfig->getMerchantConfig($storeId) ], ]; @@ -125,6 +128,10 @@ public function getConfig(): array $methodConfig = $this->applyCustomerConfig($methodConfig, $customer); } + if ($model->hasMethodValidOverrideKeys($storeId)) { + $methodConfig['merchantConfig'] = $this->_moduleConfig->getMerchantConfig($storeId, $model); + } + $methodConfigs[$model->getCode()] = $methodConfig; } diff --git a/Model/Payment/Status/OrderStateApplier.php b/Model/Payment/Status/OrderStateApplier.php new file mode 100644 index 00000000..168c0518 --- /dev/null +++ b/Model/Payment/Status/OrderStateApplier.php @@ -0,0 +1,150 @@ +orderRepository = $orderRepository; + $this->orderStateResolver = $orderStateResolver; + $this->orderStatusResolver = $orderStatusResolver; + $this->orderSender = $orderSender; + $this->invoiceSender = $invoiceSender; + } + + /** + * Apply state/status to the order, persist if changed, and send the + * confirmation emails unless the state is one Magento treats as not + * yet customer-facing. + * + * @param OrderInterface $order + * @param string|null $state + * @param string|null $status + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws Exception + */ + public function setOrderState(OrderInterface $order, ?string $state = null, ?string $status = null): void + { + if ($state === null) { + $state = $this->orderStateResolver->getStateForOrder($order, [ + Order\OrderStateResolverInterface::IN_PROGRESS, + ]); + } + + if ($status === null) { + $status = $this->orderStatusResolver->getOrderStatusByState($order, $state); + } + + $order->setState($state); + $order->setStatus($status); + + if ($order->hasDataChanges()) { + $this->orderRepository->save($order); + } + + if ($order->getEmailSent()) { + return; + } + + if (in_array($state, [Order::STATE_NEW, Order::STATE_CANCELED, Order::STATE_PENDING_PAYMENT], true)) { + return; + } + + $this->sendEmails($order); + } + + /** + * Send the order confirmation email plus an email for every invoice + * that hasn't been emailed yet. Only fires for methods that use the + * Order command (canOrder()), matching the pre-extraction behavior. + * + * @param OrderInterface $order + * + * @return void + * @throws LocalizedException + * @throws Exception + */ + private function sendEmails(OrderInterface $order): void + { + $payment = $order->getPayment(); + if (!$payment instanceof OrderPaymentInterface || !$payment->getMethodInstance()->canOrder()) { + return; + } + + $this->orderSender->send($order); + + foreach ($order->getInvoiceCollection() as $invoice) { + /** @var Order\Invoice $invoice */ + if (!$invoice->getEmailSent()) { + $this->invoiceSender->send($invoice); + } + } + } +} diff --git a/Model/Payment/Status/Processor/AbstractProcessor.php b/Model/Payment/Status/Processor/AbstractProcessor.php new file mode 100644 index 00000000..4f84fdf8 --- /dev/null +++ b/Model/Payment/Status/Processor/AbstractProcessor.php @@ -0,0 +1,388 @@ +_transactionSynchronizer = $transactionSynchronizer; + $this->_invoiceRepository = $invoiceRepository; + $this->_orderRepository = $orderRepository; + $this->_paymentRepository = $paymentRepository; + $this->_transactionRepository = $transactionRepository; + $this->_orderStateApplier = $orderStateApplier; + } + + /** + * @inheritDoc + * + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws UnzerApiException + * @throws Exception + */ + public function process(OrderInterface $order, PaymentResource $payment): void + { + switch ($payment->getState()) { + case PaymentState::STATE_CANCELED: + $this->processCanceled($order, $payment); + break; + case PaymentState::STATE_COMPLETED: + $this->processCompleted($order, $payment); + break; + case PaymentState::STATE_CHARGEBACK: + $this->processChargeback($order, $payment); + break; + case PaymentState::STATE_PARTLY: + $this->processPartly($order, $payment); + break; + case PaymentState::STATE_PAYMENT_REVIEW: + $this->processPaymentReview($order); + break; + case PaymentState::STATE_PENDING: + $this->processPending($order, $payment); + break; + } + } + + /** + * Default behavior for Unzer state CANCELED: cancel open invoices, + * cancel the order if Magento still allows it, and close the order + * when the full amount has been canceled. + * + * @param OrderInterface $order + * @param PaymentResource $payment + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws UnzerApiException + */ + protected function processCanceled(OrderInterface $order, PaymentResource $payment): void + { + $this->_transactionSynchronizer->applyCancellationOnMagento($order, $payment); + + // Orders in payment_review can't be cancelled, so we must manually + // change the state so that we can cancel the order. + if ($order->isPaymentReview()) { + $order->setState(Order::STATE_PROCESSING); + } + + // If the payment was voided, we do not want to cancel the whole order and invoice. + if (!$this->isOrderVoided($order)) { + /** @var Order\Invoice[] $invoices */ + $invoices = $order->getInvoiceCollection()->getItems(); + + foreach ($invoices as $invoice) { + $invoice->cancel(); + $this->_invoiceRepository->save($invoice); + } + + if ($order->canCancel()) { + $order->cancel(); + $this->_orderRepository->save($order); + } + } + + $amount = $payment->getAmount(); + if ($amount->getTotal() && $amount->getTotal() === $amount->getCanceled()) { + $this->_orderStateApplier->setOrderState($order, Order::STATE_CLOSED, Order::STATE_CLOSED); + $this->_orderRepository->save($order); + } + } + + /** + * Default behavior for Unzer state COMPLETED: register the capture, + * pay any open invoice matched by transaction id, close the payment + * transaction (and its parent), and resolve to STATE_PROCESSING. + * + * @param OrderInterface $order + * @param PaymentResource $payment + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws UnzerApiException + */ + protected function processCompleted(OrderInterface $order, PaymentResource $payment): void + { + $this->_transactionSynchronizer->applyCaptureOnMagento($order, $payment); + + $orderPayment = $order->getPayment(); + $transactionId = $orderPayment->getLastTransId(); + + /** @var Order\Invoice|null $invoice */ + $invoice = $order->getInvoiceCollection()->getItemByColumnValue('transaction_id', $transactionId); + + if ($invoice !== null && (int)$invoice->getState() === Order\Invoice::STATE_OPEN) { + $invoice->pay(); + + $order = $invoice->getOrder(); + $orderPayment = $order->getPayment(); + + $this->_invoiceRepository->save($invoice); + $this->_orderRepository->save($order); + $this->_paymentRepository->save($orderPayment); + } + + /** @var Order\Payment\Transaction|false $paymentTransaction */ + $paymentTransaction = $this->_transactionRepository->getByTransactionId( + $transactionId, + $orderPayment->getId(), + $order->getId() + ); + + if ($paymentTransaction && !$paymentTransaction->getIsClosed()) { + $paymentTransaction->setIsClosed(true); + $this->_transactionRepository->save($paymentTransaction); + + $parentTxnId = $paymentTransaction->getParentTxnId(); + if ($parentTxnId !== '') { + try { + /** @var Order\Payment\Transaction|false $parentPaymentTransaction */ + $parentPaymentTransaction = $this->_transactionRepository->getByTransactionId( + $parentTxnId, + $orderPayment->getId(), + $order->getId() + ); + if ($parentPaymentTransaction && !$parentPaymentTransaction->getIsClosed()) { + $parentPaymentTransaction->setIsClosed(true); + $this->_transactionRepository->save($parentPaymentTransaction); + } + } catch (InputException $e) { + // No parent row stored for this txn_id — non-fatal. + } + } + } + + // Pin to processing so the state resolver doesn't leave the order + // in payment_review (which can happen for invoice-type methods). + $order->setState(Order::STATE_PROCESSING); + + $this->_orderStateApplier->setOrderState($order); + } + + /** + * Default behavior for Unzer state CHARGEBACK: register the refund + * notification and flag the order for fraud review unless it has + * already been canceled/closed. + * + * @param OrderInterface $order + * @param PaymentResource $payment + * + * @return void + * @throws Exception + */ + protected function processChargeback(OrderInterface $order, PaymentResource $payment): void + { + $this->_transactionSynchronizer->applyChargebackOnMagento($order, $payment); + + if ($order->getState() !== Order::STATE_CANCELED && $order->getState() !== Order::STATE_CLOSED) { + $this->_orderStateApplier->setOrderState($order, Order::STATE_PAYMENT_REVIEW, Order::STATUS_FRAUD); + } + } + + /** + * Default behavior for Unzer state PARTLY: synchronize any partial + * cancellation and capture transactions, then resolve the order + * state via the state resolver. Method subclasses override this for + * payment-method-specific status mapping (see PrepaymentProcessor). + * + * @param OrderInterface $order + * @param PaymentResource $payment + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws UnzerApiException + */ + protected function processPartly(OrderInterface $order, PaymentResource $payment): void + { + $this->_transactionSynchronizer->applyCancellationOnMagento($order, $payment); + $this->_transactionSynchronizer->applyCaptureOnMagento($order, $payment); + + $this->_orderStateApplier->setOrderState($order); + } + + /** + * Default behavior for Unzer state PAYMENT_REVIEW. + * + * @param OrderInterface $order + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + */ + protected function processPaymentReview(OrderInterface $order): void + { + $this->_orderStateApplier->setOrderState($order, Order::STATE_PAYMENT_REVIEW); + } + + /** + * Default behavior for Unzer state PENDING: when an authorization + * already succeeded, jump straight to processing/ready-to-capture; + * for invoice-type methods that haven't shipped, fall back to the + * canShip-aware invoice-type state. + * + * @param OrderInterface $order + * @param PaymentResource $payment + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + * @throws UnzerApiException + */ + protected function processPending(OrderInterface $order, PaymentResource $payment): void + { + $authorization = $payment->getAuthorization(); + + if ($authorization !== null && $authorization->isSuccess() && $order->getState() !== Order::STATE_PROCESSING) { + $this->_orderStateApplier->setOrderState( + $order, + Order::STATE_PROCESSING, + PaymentHelper::STATUS_READY_TO_CAPTURE + ); + return; + } + + $paymentType = $payment->getPaymentType(); + if ($paymentType instanceof BasePaymentType && $paymentType->isInvoiceType()) { + $this->setInvoiceTypeState($order); + } + } + + /** + * canShip returns false while the order is in payment_review, so we + * temporarily flip to processing to ask the right question; the + * answer then drives whether the final state is processing or back + * to payment_review. + * + * @param OrderInterface $order + * + * @return void + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + */ + protected function setInvoiceTypeState(OrderInterface $order): void + { + $order->setState(Order::STATE_PROCESSING); + + if ($order->canShip()) { + $this->_orderStateApplier->setOrderState($order, Order::STATE_PROCESSING); + } else { + $this->_orderStateApplier->setOrderState($order, Order::STATE_PAYMENT_REVIEW); + } + } + + /** + * Whether a void transaction has already been recorded against the + * order's payment. Used to avoid double-cancelling on the canceled + * state path. + * + * @param OrderInterface $order + * + * @return bool + * @throws InputException + */ + protected function isOrderVoided(OrderInterface $order): bool + { + $voidedPaymentTransaction = $this->_transactionRepository->getByTransactionType( + TransactionInterface::TYPE_VOID, + $order->getPayment()->getId() + ); + + return (bool)$voidedPaymentTransaction; + } +} diff --git a/Model/Payment/Status/Processor/DefaultProcessor.php b/Model/Payment/Status/Processor/DefaultProcessor.php new file mode 100644 index 00000000..29fe7ba6 --- /dev/null +++ b/Model/Payment/Status/Processor/DefaultProcessor.php @@ -0,0 +1,17 @@ +isLatestChargePending($payment)) { + $this->_orderStateApplier->setOrderState($order, Order::STATE_NEW, self::STATUS_PENDING); + + return; + } + + parent::processCompleted($order, $payment); + } + + /** + * Open Banking is neither an invoice-type nor an authorize-first + * method, so the inherited processPending would no-op and leave the + * transient STATE_PENDING_PAYMENT set by Controller/Payment/Redirect. + * Force STATE_NEW / pending instead so the customer-visible status + * matches the actual waiting-for-settlement reality. + * + * @inheritDoc + * + * @throws AlreadyExistsException + * @throws InputException + * @throws LocalizedException + * @throws NoSuchEntityException + */ + protected function processPending(OrderInterface $order, PaymentResource $payment): void + { + $this->_orderStateApplier->setOrderState($order, Order::STATE_NEW, self::STATUS_PENDING); + } + + /** + * Selects the most recent charge with the same rule as + * TransactionSynchronizer::applyCaptureOnMagento (Model/Command/TransactionSynchronizer.php:46), + * so both stay consistent. Absence of any charge is treated as + * "still pending" — the payment exists but settlement hasn't begun. + * + * @param PaymentResource $payment + * + * @return bool + */ + private function isLatestChargePending(PaymentResource $payment): bool + { + $charges = $payment->getCharges(); + if (empty($charges)) { + return true; + } + + $latestCharge = $charges[array_key_last($charges)]; + + return $latestCharge->isPending(); + } +} diff --git a/Model/Payment/Status/Processor/PrepaymentProcessor.php b/Model/Payment/Status/Processor/PrepaymentProcessor.php new file mode 100644 index 00000000..533c3d18 --- /dev/null +++ b/Model/Payment/Status/Processor/PrepaymentProcessor.php @@ -0,0 +1,44 @@ +_transactionSynchronizer->applyCancellationOnMagento($order, $payment); + $this->_transactionSynchronizer->applyCaptureOnMagento($order, $payment); + + $this->_orderStateApplier->setOrderState($order, Order::STATE_PENDING_PAYMENT); + } +} diff --git a/Model/Payment/Status/Processor/ProcessorInterface.php b/Model/Payment/Status/Processor/ProcessorInterface.php new file mode 100644 index 00000000..302cb07f --- /dev/null +++ b/Model/Payment/Status/Processor/ProcessorInterface.php @@ -0,0 +1,27 @@ + $processor) { + if (!$processor instanceof ProcessorInterface) { + throw new NotFoundException( + new Phrase( + 'Processor for "%1" must implement %2.', + [$code, ProcessorInterface::class] + ) + ); + } + } + + $this->processors = $processors; + } + + /** + * Resolve the processor for the given payment-method code, falling + * back to the default when no method-specific processor is wired. + * + * @param string $methodCode Magento payment-method code (e.g. unzer_open_banking) + * + * @return ProcessorInterface + */ + public function get(string $methodCode): ProcessorInterface + { + return $this->processors[$methodCode] ?? $this->processors[self::DEFAULT_KEY]; + } +} diff --git a/Model/PluginResolver.php b/Model/PluginResolver.php new file mode 100644 index 00000000..bd72cbc8 --- /dev/null +++ b/Model/PluginResolver.php @@ -0,0 +1,106 @@ +isHyvaThemeActive($storeId)) { + return [ + 'type' => 'unzerdev/magento2', + 'version' => $this->getModuleVersion(self::UNZER_MODULE), + ]; + } + + if ($this->isReactCheckoutEnabled($storeId)) { + return [ + 'type' => 'unzerdev/magento2-hyva-react-checkout', + ]; + } + + return [ + 'type' => 'unzerdev/magento2-hyva-checkout', + 'version' => $this->getModuleVersion(self::HYVA_CHECKOUT_MODULE), + ]; + } + + /** + * @param int $storeId + * + * @return bool + */ + private function isHyvaThemeActive(int $storeId): bool + { + $themeId = $this->design->getConfigurationDesignTheme( + Area::AREA_FRONTEND, + ['store' => $storeId] + ); + + if (!$themeId) { + return false; + } + + $theme = $this->themeCollectionFactory->create()->getItemById((int)$themeId); + + while ($theme) { + if (str_starts_with((string)$theme->getCode(), self::HYVA_THEME_PREFIX)) { + return true; + } + $theme = $theme->getParentTheme(); + } + + return false; + } + + /** + * @param int $storeId + * + * @return bool + */ + private function isReactCheckoutEnabled(int $storeId): bool + { + return (bool)$this->scopeConfig->getValue( + self::REACT_CHECKOUT_FLAG, + ScopeInterface::SCOPE_STORE, + $storeId + ); + } + + /** + * @param string $moduleName + * + * @return string|null + */ + private function getModuleVersion(string $moduleName): ?string + { + $module = $this->moduleList->getOne($moduleName); + return $module['setup_version'] ?? null; + } +} diff --git a/composer.json b/composer.json index 7f712cb4..a763a13b 100644 --- a/composer.json +++ b/composer.json @@ -2,11 +2,11 @@ "name": "unzerdev/magento2", "description": "This extension for Magento 2 provides a direct integration of the Unzer payment types to your Magento 2 shop via the Unzer Payment API (PAPI).", "type": "magento2-module", - "version": "4.0.6", + "version": "4.1.0", "license": "Apache-2.0", "require": { - "php": "~7.4.0|~8.1.0|~8.2.0|~8.3.0|~8.4.0", - "unzerdev/php-sdk": "^3.13.1", + "php": "~8.1.0|~8.2.0|~8.3.0|~8.4.0|~8.5.0", + "unzerdev/php-sdk": "^4.0.1", "ext-json": "*", "magento/framework": "*", "magento/module-backend": "*", @@ -21,7 +21,7 @@ "magento/module-vault": "*" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.15", + "friendsofphp/php-cs-fixer": "^3.0", "phpunit/phpunit": "^9.6" }, "support": { diff --git a/etc/di.xml b/etc/di.xml index e041a281..8db4fd14 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -1458,4 +1458,21 @@ UnzerAuthorizeAndCaptureCommandPool + + + + + + Unzer\PAPI\Model\Payment\Status\Processor\DefaultProcessor + Unzer\PAPI\Model\Payment\Status\Processor\OpenBankingProcessor + Unzer\PAPI\Model\Payment\Status\Processor\PrepaymentProcessor + + + + + + + UnzerPaymentStatusProcessorPool + + diff --git a/etc/module.xml b/etc/module.xml index bc71d1ec..d8284548 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,7 +1,7 @@ - + diff --git a/view/frontend/templates/success/additional_payment_information.phtml b/view/frontend/templates/success/additional_payment_information.phtml index 0a019cad..9fb5abc8 100644 --- a/view/frontend/templates/success/additional_payment_information.phtml +++ b/view/frontend/templates/success/additional_payment_information.phtml @@ -9,6 +9,13 @@ * @var $block \Unzer\PAPI\Block\Checkout\Success\AdditionalPaymentInformation */ +/** @var string|null $unzerPaymentId */ +$unzerPaymentId = $block->getUnzerPaymentId(); +if ($unzerPaymentId !== null): + ?> +

escapeHtml(__('Your Unzer payment # is: %1.', $unzerPaymentId)) ?>

+ +getAdditionalPaymentInformation(); if (!empty($additionalInformation)): diff --git a/view/frontend/web/js/view/payment/method-renderer/applepayv2.js b/view/frontend/web/js/view/payment/method-renderer/applepayv2.js index 950f6d06..27eea32f 100644 --- a/view/frontend/web/js/view/payment/method-renderer/applepayv2.js +++ b/view/frontend/web/js/view/payment/method-renderer/applepayv2.js @@ -83,7 +83,7 @@ define( self = this; Promise.all([ - customElements.whenDefined(this.paymentCode) + customElements.whenDefined('unzer-payment') ]).then(() => { placeOrderAction(self.getData(), self.messageContainer) .done(function () { diff --git a/view/frontend/web/js/view/payment/method-renderer/basev2.js b/view/frontend/web/js/view/payment/method-renderer/basev2.js index 7c00f83a..8f3b3bf6 100644 --- a/view/frontend/web/js/view/payment/method-renderer/basev2.js +++ b/view/frontend/web/js/view/payment/method-renderer/basev2.js @@ -40,6 +40,7 @@ define( customerType: null, threatMetrixId: null, lastGrandTotal: null, + lastBillingAddressKey: null, defaults: { config: null, @@ -89,6 +90,34 @@ define( } }); + quote.billingAddress.subscribe(function (newAddress) { + if (!self.customerNeeded || !newAddress) { + return; + } + + if (!quote.paymentMethod() || quote.paymentMethod().method !== self.getCode()) { + return; + } + + let newKey = JSON.stringify({ + company: newAddress.company || '', + prefix: newAddress.prefix || '', + firstname: newAddress.firstname || '', + lastname: newAddress.lastname || '', + street: newAddress.street || '', + postcode: newAddress.postcode || '', + city: newAddress.city || '', + countryId: newAddress.countryId || '' + }); + + if (newKey === self.lastBillingAddressKey) { + return; + } + self.lastBillingAddressKey = newKey; + + self.waitForSetBasketData(); + }); + return this; }, @@ -100,6 +129,12 @@ define( const componentContainer = $('#unzer-component-' + this.getCode()); componentContainer.empty(); const unzerPayment = this.createUnzerPaymentElement(); + + const merchantConfig = this.getMerchantConfig(); + if (merchantConfig) { + unzerPayment.attr('skipFetchingMerchantConfig', 'true'); + } + const specificPaymentElement = this.createSpecificPaymentElement(); unzerPayment.append(specificPaymentElement); const unzerCheckout = this.createUnzerCheckoutPaymentElement(); @@ -107,6 +142,10 @@ define( componentContainer.append(unzerPayment); componentContainer.append(unzerCheckout); + if (merchantConfig) { + this.waitForSetMerchantConfigData(merchantConfig); + } + if (this.customerNeeded) { this.waitForSetBasketData(); } @@ -170,6 +209,30 @@ define( return window.checkoutConfig.payment.unzer.publicKey; }, + getMerchantConfig: function () { + return this._getMethodConfig('merchantConfig') + || window.checkoutConfig.payment.unzer.merchantConfig + || null; + }, + + waitForSetMerchantConfigData: function (config, maxRetries = 10, interval = 500) { + const unzerPayment = document.getElementById('unzer-payment-' + this.getCode()); + + if (!unzerPayment || typeof unzerPayment.setMerchantConfigData !== 'function') { + if (maxRetries > 0) { + setTimeout( + () => this.waitForSetMerchantConfigData(config, maxRetries - 1, interval), + interval + ); + } else { + console.error('setMerchantConfigData is not available after multiple retries.'); + } + return; + } + + unzerPayment.setMerchantConfigData(config); + }, + /** * Triggered when this payment method is unselected */ @@ -340,7 +403,7 @@ define( self = this; Promise.all([ - customElements.whenDefined(this.paymentCode) + customElements.whenDefined('unzer-payment') ]).then(() => { const unzerCheckoutElementId = 'unzer-checkout-' + this.getCode(); const unzerCheckout = document.getElementById(unzerCheckoutElementId); diff --git a/view/frontend/web/js/view/payment/method-renderer/cards.js b/view/frontend/web/js/view/payment/method-renderer/cards.js index 5412bb97..69e1ff2f 100644 --- a/view/frontend/web/js/view/payment/method-renderer/cards.js +++ b/view/frontend/web/js/view/payment/method-renderer/cards.js @@ -89,7 +89,7 @@ define( self = this; Promise.all([ - customElements.whenDefined('unzer-card') + customElements.whenDefined('unzer-payment') ]).then(() => { const unzerCheckout = document.getElementById('unzer-checkout-unzer_cards'); unzerCheckout.onPaymentSubmit = response => { diff --git a/view/frontend/web/js/view/payment/method-renderer/googlepay.js b/view/frontend/web/js/view/payment/method-renderer/googlepay.js index 357ded9e..8d800546 100644 --- a/view/frontend/web/js/view/payment/method-renderer/googlepay.js +++ b/view/frontend/web/js/view/payment/method-renderer/googlepay.js @@ -84,7 +84,7 @@ define( self = this; Promise.all([ - customElements.whenDefined(this.paymentCode) + customElements.whenDefined('unzer-payment') ]).then(() => { placeOrderAction(self.getData(), self.messageContainer) .done(function () {