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 @@
+