diff --git a/config/routes.yaml b/config/routes.yaml index 94fe129b1..2d273ef0a 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -8,6 +8,10 @@ sylius_mollie_shop: requirements: _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ +sylius_mollie_api_shop: + prefix: "/%sylius.security.api_shop_route%" + resource: "routes/api_shop.yaml" + mollie_refund_integration: resource: "integration/refund-plugin/routes.php" prefix: /%sylius_admin.path_name% diff --git a/config/routes/api_shop.yaml b/config/routes/api_shop.yaml new file mode 100644 index 000000000..b069877be --- /dev/null +++ b/config/routes/api_shop.yaml @@ -0,0 +1,17 @@ +sylius_mollie_api_shop_order_mollie_methods: + path: orders/{tokenValue}/mollie-methods + methods: ['GET'] + defaults: + _controller: sylius_mollie.api.controller.get_mollie_methods + +sylius_mollie_api_shop_order_mollie_select_method: + path: orders/{tokenValue}/mollie-methods + methods: ['POST'] + defaults: + _controller: sylius_mollie.api.controller.select_mollie_method + +sylius_mollie_api_shop_order_mollie_payment_status: + path: orders/{tokenValue}/mollie-status + methods: ['GET'] + defaults: + _controller: sylius_mollie.api.controller.get_mollie_status diff --git a/config/services.xml b/config/services.xml index bd196177b..7798be7bf 100644 --- a/config/services.xml +++ b/config/services.xml @@ -23,15 +23,6 @@ - - - - - - %kernel.bundles% diff --git a/config/services/api.xml b/config/services/api.xml new file mode 100644 index 000000000..c6015d8ad --- /dev/null +++ b/config/services/api.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %sylius.security.api_shop_route% + + + + + diff --git a/config/services/repository.xml b/config/services/repository.xml index 8cdd4031c..034983e12 100644 --- a/config/services/repository.xml +++ b/config/services/repository.xml @@ -26,5 +26,10 @@ + + + + + diff --git a/src/Api/Controller/GetMollieMethodsAction.php b/src/Api/Controller/GetMollieMethodsAction.php new file mode 100644 index 000000000..2388a2acd --- /dev/null +++ b/src/Api/Controller/GetMollieMethodsAction.php @@ -0,0 +1,111 @@ +orderByTokenForAvailableMethodsQuery->getOrder($tokenValue); + if (null === $order) { + throw new NotFoundHttpException(sprintf('Order with token "%s" not found', $tokenValue)); + } + + $payment = $order->getLastPayment(); + if (!$payment instanceof PaymentInterface) { + throw new NotFoundHttpException('No payment found'); + } + + $paymentMethod = $payment->getMethod(); + if (!$paymentMethod instanceof PaymentMethodInterface) { + throw new NotFoundHttpException('No payment method found'); + } + + $gatewayConfig = $paymentMethod->getGatewayConfig(); + if (!$gatewayConfig instanceof GatewayConfigInterface) { + throw new NotFoundHttpException('No gateway config found'); + } + if (!$this->mollieGatewayFactoryChecker->isMollieGateway($gatewayConfig)) { + throw new BadRequestException('The payment method is not using Mollie'); + } + + $availableMethods = $this->molliePaymentsMethodResolver->resolve(); + + $data = $availableMethods['data']; + $images = $availableMethods['image']; + $paymentFees = $availableMethods['paymentFee']; + $result = []; + foreach ($data as $id => $label) { + $result[] = [ + 'id' => $id, + 'label' => $label, + 'image' => $this->normalizeImage($images[$id] ?? null), + 'paymentFee' => $this->normalizePaymentFee($paymentFees[$id] ?? null), + ]; + } + + return new JsonResponse($result); + } + + /** + * @return array{ + * type: string|null, + * fixedAmount: float|null, + * percentage: float|null, + * surchargeLimit: float|null, + * }|null + */ + private function normalizePaymentFee(mixed $fee): ?array + { + if ($fee instanceof PaymentSurchargeFeeInterface) { + return [ + 'type' => $fee->getType(), + 'fixedAmount' => $fee->getFixedAmount(), + 'percentage' => $fee->getPercentage(), + 'surchargeLimit' => $fee->getSurchargeLimit(), + ]; + } + + return null; + } + + private function normalizeImage(?string $image): ?string + { + if (null === $image || str_starts_with($image, 'https://') && str_contains($image, 'mollie.com')) { + return $image; + } + + return $this->imagineCacheManager->getBrowserPath($image, 'sylius_original'); + } +} diff --git a/src/Api/Controller/GetMollieStatusAction.php b/src/Api/Controller/GetMollieStatusAction.php new file mode 100644 index 000000000..53d4a72a1 --- /dev/null +++ b/src/Api/Controller/GetMollieStatusAction.php @@ -0,0 +1,97 @@ +orderRepository->findOneByTokenValue($tokenValue); + if (!$order instanceof OrderInterface) { + throw new NotFoundHttpException(sprintf('Order with token "%s" not found', $tokenValue)); + } + + $payment = $order->getLastPayment(); + if (null === $payment) { + throw new NotFoundHttpException('No payment found for this order'); + } + + $details = $payment->getDetails(); + $molliePaymentId = $details['payment_mollie_id'] ?? null; + if (null === $molliePaymentId) { + $this->logger->addNegativeLog(sprintf( + 'No Mollie payment ID found in payment details of order with token: %s', + $tokenValue, + )); + + throw new NotFoundHttpException('No payment found for this order'); + } + + try { + $mollieApiClient = $this->apiKeyResolver->getClientWithKey($order); + $molliePayment = $mollieApiClient->payments->get($molliePaymentId); + $mollieStatus = $molliePayment->status; + } catch (\Exception $exception) { + $this->logger->addNegativeLog(sprintf('Error fetching Mollie payment status: %s', $exception->getMessage())); + + return new JsonResponse([ + 'error' => 'Could not retrieve Mollie payment status', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $transition = $this->mapMolliePaymentStatusToTransition($mollieStatus); + + if (null !== $transition && $this->stateMachine->can($payment, PaymentTransitions::GRAPH, $transition)) { + $this->stateMachine->apply($payment, PaymentTransitions::GRAPH, $transition); + $this->entityManager->flush(); + } + + return new JsonResponse([ + 'paymentState' => $payment->getState(), + ]); + } + + private function mapMolliePaymentStatusToTransition(string $status): ?string + { + return match ($status) { + MolliePaymentStatus::STATUS_PENDING, MolliePaymentStatus::STATUS_OPEN => PaymentTransitions::TRANSITION_PROCESS, + MolliePaymentStatus::STATUS_AUTHORIZED => PaymentTransitions::TRANSITION_AUTHORIZE, + MolliePaymentStatus::STATUS_PAID => PaymentTransitions::TRANSITION_COMPLETE, + MolliePaymentStatus::STATUS_CANCELED => PaymentTransitions::TRANSITION_CANCEL, + MolliePaymentStatus::STATUS_EXPIRED, MolliePaymentStatus::STATUS_FAILED => PaymentTransitions::TRANSITION_FAIL, + default => null, + }; + } +} diff --git a/src/Api/Controller/SelectMollieMethodAction.php b/src/Api/Controller/SelectMollieMethodAction.php new file mode 100644 index 000000000..ef1463e76 --- /dev/null +++ b/src/Api/Controller/SelectMollieMethodAction.php @@ -0,0 +1,291 @@ +orderRepository->findOneByTokenValue($tokenValue); + if (!$order instanceof OrderInterface) { + throw new NotFoundHttpException(sprintf('Order with token "%s" not found', $tokenValue)); + } + + $data = json_decode($request->getContent(), true); + + $methodId = $data['methodId'] ?? null; + $backUrl = $data['backUrl'] ?? null; + if (empty($methodId) || empty($backUrl)) { + throw new BadRequestHttpException('The `methodId` and `backUrl` are required'); + } + + $payment = $order->getLastPayment(); + if (!$payment instanceof PaymentInterface) { + throw new NotFoundHttpException('No payment found'); + } + + $paymentMethod = $payment->getMethod(); + if (!$paymentMethod instanceof PaymentMethodInterface) { + throw new NotFoundHttpException('No payment method found'); + } + + $gatewayConfig = $paymentMethod->getGatewayConfig(); + if (!$gatewayConfig instanceof GatewayConfigInterface) { + throw new NotFoundHttpException('No gateway config found'); + } + + if (!$this->mollieGatewayFactoryChecker->isMollieGateway($gatewayConfig)) { + throw new BadRequestException('The payment method is not using Mollie'); + } + + $mollieApiClient = $this->apiClientKeyResolver->getClientWithKey($order); + + $customerMollieId = null; + $isSubscription = $gatewayConfig->getFactoryName() === MollieSubscriptionGatewayFactory::FACTORY_NAME; + + if ($isSubscription) { + $customerMollieId = $this->findOrCreateMollieCustomer($mollieApiClient, $order); + } + + $paymentData = $this->createPaymentData( + $order, + $payment, + $gatewayConfig, + $methodId, + $isSubscription, + $backUrl, + $customerMollieId, + ); + + try { + $molliePayment = $mollieApiClient->payments->create($paymentData); + + $checkoutUrl = $molliePayment->_links->checkout->href ?? null; + if (!$checkoutUrl) { + $this->logger->addNegativeLog(sprintf('No checkout URL returned from Mollie for order %s', $order->getNumber())); + + return new JsonResponse( + ['error' => 'No checkout URL returned from Mollie'], + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + } catch (ApiException $exception) { + $this->logger->addNegativeLog(sprintf( + 'Creating payment in Mollie failed for order %s with: %s', + $order->getNumber(), + $exception->getMessage(), + )); + + return new JsonResponse( + ['error' => 'Creating payment in Mollie failed.'], + Response::HTTP_INTERNAL_SERVER_ERROR, + ); + } + + $details = [ + 'molliePaymentMethods' => $methodId, + 'payment_mollie_id' => $molliePayment->id, + 'cartToken' => null, + 'saveCardInfo' => '0', + 'useSavedCards' => '0', + 'webhookUrl' => $paymentData['webhookUrl'], + 'backurl' => $paymentData['redirectUrl'], + ]; + + if ($isSubscription) { + $details['customer_mollie_id'] = $customerMollieId; + } + + $payment->setDetails($details); + + if ($isSubscription) { + $this->createInternalSubscriptions($order, $paymentData, $customerMollieId); + } + + $this->entityManager->flush(); + + return new JsonResponse([ + 'methodId' => $methodId, + 'checkoutUrl' => $checkoutUrl, + ]); + } + + /** + * @return array + */ + private function createPaymentData( + OrderInterface $order, + PaymentInterface $payment, + GatewayConfigInterface $gatewayConfig, + string $methodId, + bool $isSubscription, + string $backUrl, + ?string $customerMollieId, + ): array { + $divisor = $this->divisorProvider->getDivisor(); + + $methodConfig = $this->mollieGatewayConfigRepository->findOneActiveByGatewayNameAndMethod( + $gatewayConfig->getGatewayName(), + $methodId, + ); + if (null === $methodConfig) { + throw new BadRequestHttpException(sprintf('Mollie method "%s" cannot be selected', $methodId)); + } + + $webhookUrl = $this->router->generate( + 'sylius_mollie_shop_payment_webhook', + ['_locale' => $order->getLocaleCode() ?? 'en_US'], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $paymentData = [ + 'method' => $methodId, + 'amount' => [ + 'currency' => $payment->getCurrencyCode(), + 'value' => $this->intToStringConverter->convertIntToString($payment->getAmount(), $divisor), + ], + 'description' => $this->paymentDescriptionProvider->getPaymentDescription($payment, $methodConfig, $order), + 'redirectUrl' => $backUrl, + 'webhookUrl' => $webhookUrl, + 'metadata' => [ + 'order_id' => $order->getId(), + 'customer_id' => $order->getCustomer()?->getId(), + 'molliePaymentMethods' => $methodId, + ], + ]; + + $converted = $this->orderConverter->convert($order, $paymentData, $divisor, $methodConfig); + $paymentData['billingAddress'] = $converted['billingAddress']; + $paymentData['shippingAddress'] = $converted['shippingAddress']; + $paymentData['lines'] = array_map( + static fn (array $line): array => [ + 'description' => $line['name'] ?? '', + 'type' => $line['type'] ?? 'physical', + 'quantity' => $line['quantity'], + 'unitPrice' => $line['unitPrice'], + 'totalAmount' => $line['totalAmount'], + 'vatRate' => $line['vatRate'], + 'vatAmount' => $line['vatAmount'], + ], + $converted['lines'], + ); + + $locale = $this->paymentLocaleResolver->resolveFromOrder($order); + + if ($locale !== null) { + $paymentData['locale'] = $locale; + } + + if ($isSubscription) { + $paymentData['customerId'] = $customerMollieId; + $paymentData['sequenceType'] = 'first'; + $paymentData['metadata']['sequenceType'] = 'first'; + } + + return $paymentData; + } + + private function findOrCreateMollieCustomer(MollieApiClient $mollieApiClient, OrderInterface $order): string + { + $email = $order->getCustomer()?->getEmail(); + + /** @var ?MollieCustomer $customer */ + $customer = $this->mollieCustomerRepository->findOneBy(['email' => $email]); + if (null === $customer) { + $customer = new MollieCustomer(); + $customer->setEmail($email); + } + + if (null === $customer->getProfileId()) { + $customerMollie = $mollieApiClient->customers->create([ + 'name' => $order->getCustomer()?->getFullName(), + 'email' => $email, + ]); + $customer->setProfileId($customerMollie->id); + + $this->mollieCustomerRepository->add($customer); + } + + return $customer->getProfileId(); + } + + /** + * @param array $paymentData + */ + private function createInternalSubscriptions( + MollieOrderInterface $order, + array $paymentData, + ?string $customerMollieId, + ): void { + foreach ($order->getRecurringItems() as $item) { + $subscription = $this->subscriptionFactory->createFromFirstOrderWithOrderItemAndPaymentConfiguration( + $order, + $item, + $paymentData, + ); + $subscription->getSubscriptionConfiguration()->setCustomerId($customerMollieId); + $this->subscriptionRepository->add($subscription); + } + } +} diff --git a/src/Api/OpenApi/MollieDocumentationModifier.php b/src/Api/OpenApi/MollieDocumentationModifier.php new file mode 100644 index 000000000..1b212d58d --- /dev/null +++ b/src/Api/OpenApi/MollieDocumentationModifier.php @@ -0,0 +1,179 @@ +getPaths(); + $schemas = $docs->getComponents()->getSchemas(); + + $schemas = $this->addSchemas($schemas); + $this->addPaths($paths); + + return $docs + ->withPaths($paths) + ->withComponents($docs->getComponents()->withSchemas($schemas)) + ; + } + + private function addPaths(Paths $paths): void + { + $tokenParameter = new Parameter( + name: 'tokenValue', + in: 'path', + description: 'The token of the order', + required: true, + schema: ['type' => 'string'], + ); + + $methodsPath = sprintf('%s/orders/{tokenValue}/mollie-methods', $this->shopApiRoute); + $paths->addPath($methodsPath, new PathItem( + ref: 'MollieMethods', + get: new Operation( + operationId: 'sylius_mollie_api_shop_order_mollie_methods', + tags: ['Mollie'], + responses: [ + Response::HTTP_OK => new ResponseModel( + description: 'List of available Mollie payment methods', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => [ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/MollieMethod'], + ], + ], + ]), + ), + ], + summary: 'Get available Mollie payment methods for an order', + parameters: [$tokenParameter], + ), + post: new Operation( + operationId: 'sylius_mollie_api_shop_order_mollie_select_method', + tags: ['Mollie'], + responses: [ + Response::HTTP_OK => new ResponseModel( + description: 'Mollie payment created successfully', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/MollieSelectMethodResponse'], + ], + ]), + ), + ], + summary: 'Select a Mollie payment method and create a payment', + parameters: [$tokenParameter], + requestBody: new RequestBody( + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/MollieSelectMethodRequest'], + ], + ]), + required: true, + ), + ), + )); + + $statusPath = sprintf('%s/orders/{tokenValue}/mollie-status', $this->shopApiRoute); + $paths->addPath($statusPath, new PathItem( + ref: 'MolliePaymentStatus', + get: new Operation( + operationId: 'sylius_mollie_api_shop_order_mollie_payment_status', + tags: ['Mollie'], + responses: [ + Response::HTTP_OK => new ResponseModel( + description: 'Current payment status', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/MolliePaymentStatus'], + ], + ]), + ), + ], + summary: 'Get Mollie payment status and update Sylius payment state', + parameters: [$tokenParameter], + ), + )); + } + + /** + * @param array|\ArrayObject $schemas + * + * @return array|\ArrayObject + */ + private function addSchemas(array|\ArrayObject $schemas): array|\ArrayObject + { + $schemas['MollieMethod'] = [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'example' => 'klarna'], + 'label' => ['type' => 'string', 'example' => 'Klarna'], + 'image' => ['type' => 'string', 'nullable' => true, 'example' => 'https://www.mollie.com/external/icons/payment-methods/klarna.svg'], + 'paymentFee' => [ + 'type' => 'object', + 'nullable' => true, + 'properties' => [ + 'type' => ['type' => 'string', 'nullable' => true], + 'fixedAmount' => ['type' => 'number', 'nullable' => true], + 'percentage' => ['type' => 'number', 'nullable' => true], + 'surchargeLimit' => ['type' => 'number', 'nullable' => true], + ], + ], + ], + ]; + + $schemas['MollieSelectMethodRequest'] = [ + 'type' => 'object', + 'required' => ['methodId', 'backUrl'], + 'properties' => [ + 'methodId' => ['type' => 'string', 'example' => 'ideal'], + 'backUrl' => ['type' => 'string', 'example' => 'https://example.com/return-here-after-payment'], + ], + ]; + + $schemas['MollieSelectMethodResponse'] = [ + 'type' => 'object', + 'properties' => [ + 'methodId' => ['type' => 'string'], + 'checkoutUrl' => ['type' => 'string'], + ], + ]; + + $schemas['MolliePaymentStatus'] = [ + 'type' => 'object', + 'properties' => [ + 'paymentState' => ['type' => 'string'], + ], + ]; + + return $schemas; + } +} diff --git a/src/Repository/Query/OrderByTokenForAvailableMethodsQuery.php b/src/Repository/Query/OrderByTokenForAvailableMethodsQuery.php new file mode 100644 index 000000000..209cf9285 --- /dev/null +++ b/src/Repository/Query/OrderByTokenForAvailableMethodsQuery.php @@ -0,0 +1,42 @@ +orderRepository->createQueryBuilder('o') + ->andWhere('o.tokenValue = :tokenValue') + ->andWhere('o.state IN (:states)') + ->setParameter('tokenValue', $tokenValue) + ->setParameter('states', [ + OrderInterface::STATE_NEW, + OrderInterface::STATE_CART, + ]) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/src/Repository/Query/OrderByTokenForAvailableMethodsQueryInterface.php b/src/Repository/Query/OrderByTokenForAvailableMethodsQueryInterface.php new file mode 100644 index 000000000..2359a0188 --- /dev/null +++ b/src/Repository/Query/OrderByTokenForAvailableMethodsQueryInterface.php @@ -0,0 +1,21 @@ +