diff --git a/controllers/front/payment.php b/controllers/front/payment.php index 042842b53..62119cd9b 100644 --- a/controllers/front/payment.php +++ b/controllers/front/payment.php @@ -34,7 +34,7 @@ */ class MolliePaymentModuleFrontController extends ModuleFrontController { - const FILE_NAME = 'payment'; + private const FILE_NAME = 'payment'; /** @var bool */ public $ssl = true; diff --git a/controllers/front/return.php b/controllers/front/return.php index e9b8de776..3d8fcc59b 100644 --- a/controllers/front/return.php +++ b/controllers/front/return.php @@ -32,7 +32,7 @@ class MollieReturnModuleFrontController extends AbstractMollieController /** @var Mollie */ public $module; - const FILE_NAME = 'return'; + private const FILE_NAME = 'return'; /** @var bool */ public $ssl = true; diff --git a/controllers/front/subscriptionUpdateWebhook.php b/controllers/front/subscriptionUpdateWebhook.php index 3d62c9706..704434d0a 100644 --- a/controllers/front/subscriptionUpdateWebhook.php +++ b/controllers/front/subscriptionUpdateWebhook.php @@ -10,8 +10,12 @@ * @codingStandardsIgnoreStart */ +use Mollie\Adapter\ToolsAdapter; use Mollie\Controller\AbstractMollieController; use Mollie\Errors\Http\HttpStatusCode; +use Mollie\Handler\ErrorHandler\ErrorHandler; +use Mollie\Infrastructure\Response\JsonResponse; +use Mollie\Logger\PrestaLoggerInterface; use Mollie\Subscription\Handler\SubscriptionPaymentMethodUpdateHandler; if (!defined('_PS_VERSION_')) { @@ -20,6 +24,8 @@ class MollieSubscriptionUpdateWebhookModuleFrontController extends AbstractMollieController { + private const FILE_NAME = 'subscriptionUpdateWebhook'; + /** @var Mollie */ public $module; /** @var bool */ @@ -40,29 +46,89 @@ protected function displayMaintenancePage() public function initContent() { - if (Configuration::get(Mollie\Config\Config::MOLLIE_DEBUG_LOG)) { - PrestaShopLogger::addLog('Mollie incoming subscription webhook: ' . Tools::file_get_contents('php://input')); - } + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); - exit($this->executeWebhook()); - } + /** @var ErrorHandler $errorHandler */ + $errorHandler = $this->module->getService(ErrorHandler::class); - protected function executeWebhook() - { - $transactionId = Tools::getValue('id'); - $subscriptionId = Tools::getValue('subscription_id'); + /** @var ToolsAdapter $tools */ + $tools = $this->module->getService(ToolsAdapter::class); + + $logger->info(sprintf('%s - Controller called', self::FILE_NAME)); + + if (!$this->module->getApiClient()) { + $logger->error(sprintf('Unauthorized in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Unauthorized', self::FILE_NAME), + HttpStatusCode::HTTP_UNAUTHORIZED + )); + } + + $transactionId = (string) $tools->getValue('id'); if (!$transactionId) { - $this->respond('failed', HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY, 'Missing transaction id'); + $logger->error(sprintf('Missing transaction id in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Missing transaction id', self::FILE_NAME), + HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY + )); } + + $subscriptionId = (string) $tools->getValue('subscription_id'); + if (!$subscriptionId) { - $this->respond('failed', HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY, 'Missing subscription id'); + $logger->error(sprintf('Missing subscription id in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Missing subscription id', self::FILE_NAME), + HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY + )); + } + + $lockResult = $this->applyLock(sprintf( + '%s-%s-%s', + self::FILE_NAME, + $transactionId, + $subscriptionId + )); + + if (!$lockResult->isSuccessful()) { + $logger->error(sprintf('Resource conflict in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Resource conflict', self::FILE_NAME), + HttpStatusCode::HTTP_CONFLICT + )); } /** @var SubscriptionPaymentMethodUpdateHandler $subscriptionPaymentMethodUpdateHandler */ $subscriptionPaymentMethodUpdateHandler = $this->module->getService(SubscriptionPaymentMethodUpdateHandler::class); - $subscriptionPaymentMethodUpdateHandler->handle($transactionId, $subscriptionId); - return 'OK'; + try { + $subscriptionPaymentMethodUpdateHandler->handle($transactionId, $subscriptionId); + } catch (\Throwable $exception) { + $logger->error('Failed to handle subscription update', [ + 'Exception message' => $exception->getMessage(), + 'Exception code' => $exception->getCode(), + ]); + + $errorHandler->handle($exception, null, false); + + $this->releaseLock(); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Failed to handle subscription update', self::FILE_NAME), + $exception->getCode() + )); + } + + $this->releaseLock(); + + $logger->info(sprintf('%s - Controller action ended', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::success([])); } } diff --git a/controllers/front/subscriptionWebhook.php b/controllers/front/subscriptionWebhook.php index 433e6b290..1c524a507 100644 --- a/controllers/front/subscriptionWebhook.php +++ b/controllers/front/subscriptionWebhook.php @@ -10,9 +10,11 @@ * @codingStandardsIgnoreStart */ +use Mollie\Adapter\ToolsAdapter; use Mollie\Controller\AbstractMollieController; use Mollie\Errors\Http\HttpStatusCode; use Mollie\Handler\ErrorHandler\ErrorHandler; +use Mollie\Infrastructure\Response\JsonResponse; use Mollie\Logger\PrestaLoggerInterface; use Mollie\Subscription\Handler\RecurringOrderHandler; @@ -22,6 +24,8 @@ class MollieSubscriptionWebhookModuleFrontController extends AbstractMollieController { + private const FILE_NAME = 'subscriptionWebhook'; + /** @var Mollie */ public $module; /** @var bool */ @@ -42,29 +46,54 @@ protected function displayMaintenancePage() public function initContent() { - if (Configuration::get(Mollie\Config\Config::MOLLIE_DEBUG_LOG)) { - PrestaShopLogger::addLog('Mollie incoming subscription webhook: ' . Tools::file_get_contents('php://input')); - } + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); - exit($this->executeWebhook()); - } + /** @var ErrorHandler $errorHandler */ + $errorHandler = $this->module->getService(ErrorHandler::class); - protected function executeWebhook() - { - $transactionId = Tools::getValue('id'); + /** @var ToolsAdapter $tools */ + $tools = $this->module->getService(ToolsAdapter::class); + + $logger->info(sprintf('%s - Controller called', self::FILE_NAME)); + + if (!$this->module->getApiClient()) { + $logger->error(sprintf('Unauthorized in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Unauthorized', self::FILE_NAME), + HttpStatusCode::HTTP_UNAUTHORIZED + )); + } + + $transactionId = (string) $tools->getValue('id'); if (!$transactionId) { - $this->respond('failed', HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY, 'Missing transaction id'); + $logger->error(sprintf('Missing transaction id in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Missing transaction id', self::FILE_NAME), + HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY + )); } - /** @var RecurringOrderHandler $recurringOrderHandler */ - $recurringOrderHandler = $this->module->getService(RecurringOrderHandler::class); + $lockResult = $this->applyLock(sprintf( + '%s-%s', + self::FILE_NAME, + $transactionId + )); - /** @var ErrorHandler $errorHandler */ - $errorHandler = $this->module->getService(ErrorHandler::class); + if (!$lockResult->isSuccessful()) { + $logger->error(sprintf('Resource conflict in %s', self::FILE_NAME)); - /** @var PrestaLoggerInterface $logger */ - $logger = $this->module->getService(PrestaLoggerInterface::class); + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Resource conflict', self::FILE_NAME), + HttpStatusCode::HTTP_CONFLICT + )); + } + + /** @var RecurringOrderHandler $recurringOrderHandler */ + $recurringOrderHandler = $this->module->getService(RecurringOrderHandler::class); try { $recurringOrderHandler->handle($transactionId); @@ -76,9 +105,18 @@ protected function executeWebhook() $errorHandler->handle($exception, null, false); - $this->respond('failed', HttpStatusCode::HTTP_BAD_REQUEST); + $this->releaseLock(); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Failed to handle recurring order', self::FILE_NAME), + $exception->getCode() + )); } - $this->respond('OK'); + $this->releaseLock(); + + $logger->info(sprintf('%s - Controller action ended', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::success([])); } } diff --git a/controllers/front/webhook.php b/controllers/front/webhook.php index 699298883..fa2710aa3 100644 --- a/controllers/front/webhook.php +++ b/controllers/front/webhook.php @@ -10,11 +10,12 @@ * @codingStandardsIgnoreStart */ -use Mollie\Api\Exceptions\ApiException; -use Mollie\Config\Config; +use Mollie\Adapter\ToolsAdapter; use Mollie\Controller\AbstractMollieController; use Mollie\Errors\Http\HttpStatusCode; use Mollie\Handler\ErrorHandler\ErrorHandler; +use Mollie\Infrastructure\Response\JsonResponse; +use Mollie\Logger\PrestaLoggerInterface; use Mollie\Service\TransactionService; use Mollie\Utility\TransactionUtility; @@ -24,6 +25,8 @@ class MollieWebhookModuleFrontController extends AbstractMollieController { + private const FILE_NAME = 'webhook'; + /** @var Mollie */ public $module; /** @var bool */ @@ -42,82 +45,117 @@ protected function displayMaintenancePage() { } - /** - * @throws ApiException - * @throws PrestaShopDatabaseException - * @throws PrestaShopException - */ - public function initContent() + public function initContent(): void { - if ((int) Configuration::get(Config::MOLLIE_DEBUG_LOG) === Config::DEBUG_LOG_ALL) { - PrestaShopLogger::addLog('Mollie incoming webhook: ' . Tools::file_get_contents('php://input')); + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); + + /** @var ErrorHandler $errorHandler */ + $errorHandler = $this->module->getService(ErrorHandler::class); + + /** @var ToolsAdapter $tools */ + $tools = $this->module->getService(ToolsAdapter::class); + + $logger->info(sprintf('%s - Controller called', self::FILE_NAME)); + + if (!$this->module->getApiClient()) { + $logger->error(sprintf('Unauthorized in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Unauthorized', self::FILE_NAME), + HttpStatusCode::HTTP_UNAUTHORIZED + )); + } + + $transactionId = (string) $tools->getValue('id'); + + if (!$transactionId) { + $logger->error(sprintf('Missing transaction id %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Missing transaction id', self::FILE_NAME), + HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY + )); + } + + $lockResult = $this->applyLock(sprintf( + '%s-%s', + self::FILE_NAME, + $transactionId + )); + + if (!$lockResult->isSuccessful()) { + $logger->error(sprintf('Resource conflict in %s', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Resource conflict', self::FILE_NAME), + HttpStatusCode::HTTP_CONFLICT + )); } try { - exit($this->executeWebhook()); + $this->executeWebhook($transactionId); } catch (\Throwable $exception) { - PrestaShopLogger::addLog('Error occurred: ' . $exception->getMessage(), 3, null, 'Mollie'); + $logger->error('Failed to handle webhook', [ + 'Exception message' => $exception->getMessage(), + 'Exception code' => $exception->getCode(), + ]); + + $errorHandler->handle($exception, $exception->getCode(), false); + + $this->releaseLock(); + + $this->ajaxResponse(JsonResponse::error( + $this->module->l('Failed to handle webhook', self::FILE_NAME), + $exception->getCode() + )); } + + $this->releaseLock(); + + $logger->info(sprintf('%s - Controller action ended', self::FILE_NAME)); + + $this->ajaxResponse(JsonResponse::success([])); } /** - * @return string - * - * @throws ApiException - * @throws PrestaShopDatabaseException - * @throws PrestaShopException + * @throws Throwable */ - protected function executeWebhook() + protected function executeWebhook(string $transactionId): void { /** @var TransactionService $transactionService */ $transactionService = $this->module->getService(TransactionService::class); - /** @var ErrorHandler $errorHandler */ - $errorHandler = $this->module->getService(ErrorHandler::class); + if (TransactionUtility::isOrderTransaction($transactionId)) { + $transaction = $this->module->getApiClient()->orders->get($transactionId, ['embed' => 'payments']); + } else { + $transaction = $this->module->getApiClient()->payments->get($transactionId); - $transactionId = Tools::getValue('id'); - if (!$transactionId) { - $this->respond('failed', HttpStatusCode::HTTP_UNPROCESSABLE_ENTITY, 'Missing transaction id'); + if ($transaction->orderId) { + $transaction = $this->module->getApiClient()->orders->get($transaction->orderId, ['embed' => 'payments']); + } } - if (!$this->module->getApiClient()) { - $this->respond('failed', HttpStatusCode::HTTP_UNAUTHORIZED, 'API key is missing or incorrect'); - } + $cartId = $transaction->metadata->cart_id ?? 0; - try { - if (TransactionUtility::isOrderTransaction($transactionId)) { - $transaction = $this->module->getApiClient()->orders->get($transactionId, ['embed' => 'payments']); - } else { - $transaction = $this->module->getApiClient()->payments->get($transactionId); - if ($transaction->orderId) { - $transaction = $this->module->getApiClient()->orders->get($transaction->orderId, ['embed' => 'payments']); - } - } - $metaData = $transaction->metadata; - $cartId = $metaData->cart_id ?? 0; - $this->setContext($cartId); - $payment = $transactionService->processTransaction($transaction); - } catch (\Throwable $e) { - $errorHandler->handle($e, $e->getCode(), false); - $this->respond('failed', $e->getCode(), $e->getMessage()); - } + if (!$cartId) { + // TODO webhook structure will change, no need to create custom exception for one time usage - /* @phpstan-ignore-next-line */ - if (is_string($payment)) { - return $payment; + throw new \Exception(sprintf('Missing Cart ID. Transaction ID: [%s]', $transactionId), HttpStatusCode::HTTP_NOT_FOUND); } - return 'OK'; + $this->setContext($cartId); + + $transactionService->processTransaction($transaction); } - private function setContext(int $cartId) + private function setContext(int $cartId): void { - if (!$cartId) { - return; - } $cart = new Cart($cartId); + $this->context->currency = new Currency($cart->id_currency); $this->context->customer = new Customer($cart->id_customer); + $this->context->cart = $cart; } } diff --git a/src/Controller/AbstractMollieController.php b/src/Controller/AbstractMollieController.php index 2b2a98909..11f144842 100644 --- a/src/Controller/AbstractMollieController.php +++ b/src/Controller/AbstractMollieController.php @@ -13,9 +13,28 @@ use Mollie\Errors\Error; use Mollie\Errors\Http\HttpStatusCode; +use Mollie\Infrastructure\Adapter\Lock; +use Mollie\Infrastructure\Response\JsonResponse; +use Mollie\Infrastructure\Response\Response; +use Mollie\Logger\PrestaLoggerInterface; class AbstractMollieController extends \ModuleFrontControllerCore { + private const FILE_NAME = 'AbstractMollieController'; + + /** @var Lock */ + private $lock; + + /** @var \Mollie */ + public $module; + + public function __construct() + { + parent::__construct(); + + $this->lock = $this->module->getService(Lock::class); + } + protected function respond($status, $statusCode = HttpStatusCode::HTTP_OK, $message = ''): void { http_response_code($statusCode); @@ -31,8 +50,92 @@ protected function respond($status, $statusCode = HttpStatusCode::HTTP_OK, $mess protected function ajaxRender($value = null, $controller = null, $method = null): void { + // TODO remove this later parent::ajaxRender($value, $controller, $method); exit; } + + protected function ajaxResponse($value, $controller = null, $method = null): void + { + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); + + if ($value instanceof JsonResponse) { + if ($value->getStatusCode() === JsonResponse::HTTP_INTERNAL_SERVER_ERROR) { + $logger->error('Failed to return valid response', [ + 'context' => [ + 'response' => $value->getContent(), + ], + ]); + } + + http_response_code($value->getStatusCode()); + + $value = $value->getContent(); + } + + try { + $this->ajaxRender($value, $controller, $method); + } catch (\Throwable $exception) { + $logger->error('Could not return ajax response', [ + 'response' => json_encode($value ?: []), + 'Exception message' => $exception->getMessage(), + 'Exception code' => $exception->getCode(), + ]); + } + + exit; + } + + protected function applyLock(string $resource): Response + { + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); + + try { + $this->lock->create($resource); + + if (!$this->lock->acquire()) { + $logger->error('Lock resource conflict', [ + 'resource' => $resource, + ]); + + return Response::respond( + $this->module->l('Resource conflict', self::FILE_NAME), + Response::HTTP_CONFLICT + ); + } + } catch (\Throwable $exception) { + $logger->error('Failed to lock process', [ + 'Exception message' => $exception->getMessage(), + 'Exception code' => $exception->getCode(), + ]); + + return Response::respond( + $this->module->l('Internal error', self::FILE_NAME), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } + + return Response::respond( + '', + Response::HTTP_OK + ); + } + + protected function releaseLock(): void + { + /** @var PrestaLoggerInterface $logger */ + $logger = $this->module->getService(PrestaLoggerInterface::class); + + try { + $this->lock->release(); + } catch (\Throwable $exception) { + $logger->error('Failed to release process', [ + 'Exception message' => $exception->getMessage(), + 'Exception code' => $exception->getCode(), + ]); + } + } } diff --git a/src/Infrastructure/Response/JsonResponse.php b/src/Infrastructure/Response/JsonResponse.php new file mode 100644 index 000000000..f568dcebf --- /dev/null +++ b/src/Infrastructure/Response/JsonResponse.php @@ -0,0 +1,41 @@ + true, + 'errors' => [], + 'data' => $data, + ], $status); + } + + /** + * @param string|array $error + */ + public static function error($error, int $status = 400): self + { + if (!is_array($error)) { + $error = [$error]; + } + + return new self([ + 'success' => false, + 'errors' => $error, + 'data' => [], + ], $status); + } +} diff --git a/src/Infrastructure/Response/Response.php b/src/Infrastructure/Response/Response.php new file mode 100644 index 000000000..a97cbbfdb --- /dev/null +++ b/src/Infrastructure/Response/Response.php @@ -0,0 +1,21 @@ +configuration = $configuration; + } + public function emergency($message, array $context = []) { throw new NotImplementedException('not implemented method'); @@ -33,11 +45,15 @@ public function critical($message, array $context = []) public function error($message, array $context = []) { + if ((int) $this->configuration->get(Config::MOLLIE_DEBUG_LOG) === Config::DEBUG_LOG_NONE) { + return; + } + $uniqueMessage = sprintf('Log ID (%s) | %s', uniqid('', true), $message); \PrestaShopLogger::addLog( $this->getMessageWithContext($uniqueMessage, $context), - 2 + 3 ); } @@ -53,6 +69,10 @@ public function notice($message, array $context = []) public function info($message, array $context = []) { + if ((int) $this->configuration->get(Config::MOLLIE_DEBUG_LOG) !== Config::DEBUG_LOG_ALL) { + return; + } + $uniqueMessage = sprintf('Log ID (%s) | %s', uniqid('', true), $message); \PrestaShopLogger::addLog( diff --git a/tests/Unit/Factory/SubscriptionDataTest.php b/tests/Unit/Factory/SubscriptionDataTest.php index edbe8e30c..88bec26bf 100644 --- a/tests/Unit/Factory/SubscriptionDataTest.php +++ b/tests/Unit/Factory/SubscriptionDataTest.php @@ -5,7 +5,7 @@ namespace Mollie\Tests\Unit\Factory; use Mollie; -use Mollie\Adapter\Link; +use Mollie\Adapter\Context; use Mollie\Repository\MolCustomerRepository; use Mollie\Repository\PaymentMethodRepository; use Mollie\Subscription\Constants\IntervalConstant; @@ -13,6 +13,7 @@ use Mollie\Subscription\DTO\Object\Amount; use Mollie\Subscription\DTO\Object\Interval; use Mollie\Subscription\Factory\CreateSubscriptionDataFactory; +use Mollie\Subscription\Provider\SubscriptionCarrierDeliveryPriceProvider; use Mollie\Subscription\Provider\SubscriptionDescriptionProvider; use Mollie\Subscription\Provider\SubscriptionIntervalProvider; use Mollie\Subscription\Repository\CombinationRepository; @@ -58,6 +59,12 @@ public function testBuildSubscriptionData(string $customerId, float $totalAmount ] ); + $context = $this->createMock(Context::class); + $context->expects($this->once())->method('getModuleLink')->willReturn('example-link'); + + $subscriptionCarrierDeliveryPriceProvider = $this->createMock(SubscriptionCarrierDeliveryPriceProvider::class); + $subscriptionCarrierDeliveryPriceProvider->expects($this->once())->method('getPrice')->willReturn(10.00); + $subscriptionDataFactory = new CreateSubscriptionDataFactory( $customerRepositoryMock, $subscriptionIntervalProviderMock, @@ -65,8 +72,9 @@ public function testBuildSubscriptionData(string $customerId, float $totalAmount $currencyAdapterMock, new CombinationRepository(), $paymentMethodRepositoryMock, - new Link(), - new Mollie() + new Mollie(), + $context, + $subscriptionCarrierDeliveryPriceProvider ); $customerMock = $this->createMock('Customer'); @@ -95,7 +103,7 @@ public function subscriptionDataProvider() { $subscriptionDto = new SubscriptionDataDTO( 'testCustomerId', - new Amount(19.99, 'EUR'), + new Amount(29.99, 'EUR'), new Interval(1, IntervalConstant::DAY), 'subscription-' . self::TEST_ORDER_REFERENCE ); @@ -113,16 +121,12 @@ public function subscriptionDataProvider() ] ); - $link = new Link(); - $subscriptionDto->setWebhookUrl($link->getModuleLink( - 'mollie', - 'subscriptionWebhook' - )); + $subscriptionDto->setWebhookUrl('example-link'); return [ 'first example' => [ 'customer id' => 'testCustomerId', - 'total paid amount' => 19.99, + 'total paid amount' => 29.99, 'description' => 'subscription-' . self::TEST_ORDER_REFERENCE, 'expected result' => $subscriptionDto, ],