Compare commits

...

5 Commits

Author SHA1 Message Date
andrewrowanwallee 05c83b1ac6 Release 7.3.4 2026-05-28 17:34:20 +02:00
andrewrowanwallee 93f24a2cac Release 7.3.3 2026-04-29 11:43:34 +02:00
andrewrowanwallee dbd0b6808c Release 7.3.2 2026-02-24 11:48:14 +01:00
andrewrowanwallee 3f3bf866dd Release 7.3.1 2026-02-09 14:17:02 +01:00
andrewrowanwallee 4fbae35058 Release 7.3.0 2026-01-27 10:24:15 +01:00
66 changed files with 3344 additions and 1319 deletions
+32 -2
View File
@@ -1,6 +1,36 @@
# 7.3.4
- Shopware 6.7.10.0 compatible
- Fix for cart being lost when changing payment methods
- Fixed for incorrectly reference function (getState())
- Fix cache not being cleaned after payment errors
- Fix missing shipping costs
- Minor fix for Customer Id null inconsistency
# 7.3.3
- Shopware 6.7.9.0 compatible
- Fix for only showing 25 sales channels in selector
- Fixed bug with discount occasionally blocking payment methods
# 7.3.2
- Fix for partial refunds using standard refund option
- Fixed issue with undefined array key
- Fixed issue with recurring payments
- Fixed concrete class reference; used interface instead
# 7.3.1
- Shopware 6.7.7.0 compatibility
- Fix for missing payment icons
- Fixed issue with 'Entire Set' coupons returning error due to totals mismatch
- Fixed issue with orphaned order_transactions causing an admin UI failure
- Fixed error thrown when customers who aren't logged in attempt to download an invoice
# 7.3.0
- Headless storefront support
# 7.2.0 # 7.2.0
- Datenbanktabelle umbenannt, um Namenskonflikte mit älteren Plugins zu vermeiden. - Renamed database table to avoid a naming conflict with legacy plugins
- Problem mit fehlgeschlagenen Rückerstattungen bei Zahlungen mit Rechnungen behoben. - Fixed issue with refunds failing for payments using Invoice
- Fix to respect sort order of payment methods
# 7.1.6 # 7.1.6
- Improved rounding handling to force 2 decimal places - Improved rounding handling to force 2 decimal places
+28
View File
@@ -1,3 +1,31 @@
# 7.3.4
- Kompatibel mit Shopware 6.7.10.0
- Problem behoben, bei dem der Warenkorb beim Ändern der Zahlungsmethode verloren ging
- Fehlerhafte Referenzierung der Funktion getState() behoben
- Problem behoben, bei dem der Cache nach Zahlungsfehlern nicht geleert wurde
- Fehlende Versandkosten behoben
- Kleinere Korrektur eines Fehlers mit der Kunden-ID (null)
# 7.3.3
- Kompatibel mit Shopware 6.7.9.0
- Problem behoben, dass im Selektor nur 25 Vertriebskanäle angezeigt wurden
- Fehler behoben, der gelegentlich dazu führte, dass Rabatte Zahlungsmethoden blockierten
# 7.3.2
- Problem mit Teilrückerstattungen über die Standardrückerstattungsoption behoben
- Problem mit undefiniertem Array-Schlüssel behoben
- Problem mit einziehenden Zahlungen behoben
- Konkrete Klasse durch Verwendung einer Schnittstelle korrigiert
# 7.3.1
- Kompatibilität mit Shopware 6.7.7.0
- Fehlende Zahlungssymbole behoben
- Problem mit „Gesamtes Set“-Gutscheinen aufgrund von Summenabweichungen behoben
- Problem mit verwaisten Bestelltransaktionen behoben, die zu einem Fehler in der Admin-Oberfläche führten
- Ein Fehler wurde behoben, der auftrat, wenn nicht angemeldete Kunden versuchten, eine Rechnung herunterzuladen.
# 7.3.0
- Headless Storefront unterstützung
# 7.2.0 # 7.2.0
- Datenbanktabelle umbenannt, um Namenskonflikte mit älteren Plugins zu vermeiden. - Datenbanktabelle umbenannt, um Namenskonflikte mit älteren Plugins zu vermeiden.
- Problem mit fehlgeschlagenen Rückerstattungen bei Zahlungen mit Rechnungen behoben. - Problem mit fehlgeschlagenen Rückerstattungen bei Zahlungen mit Rechnungen behoben.
+4 -4
View File
@@ -13,10 +13,10 @@ Please note that this plugin is for versions 6.5, 6.6 or 6.7. For the 6.4 plugin
## Documentation ## Documentation
- For English documentation click [here](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.2.0/docs/en/documentation.html) - For English documentation click [here](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.3.4/docs/en/documentation.html)
- Für die deutsche Dokumentation klicken Sie [hier](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.2.0/docs/de/documentation.html) - Für die deutsche Dokumentation klicken Sie [hier](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.3.4/docs/de/documentation.html)
- Pour la documentation Française, cliquez [ici](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.2.0/docs/fr/documentation.html) - Pour la documentation Française, cliquez [ici](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.3.4/docs/fr/documentation.html)
- Per la documentazione in tedesco, clicca [qui](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.2.0/docs/it/documentation.html) - Per la documentazione in tedesco, clicca [qui](https://docs.plugin-documentation.vr-payment.de/vr-payment/shopware-6/7.3.4/docs/it/documentation.html)
## Installation ## Installation
+1 -1
View File
@@ -59,5 +59,5 @@
"vrpayment/sdk": "^4.0.0" "vrpayment/sdk": "^4.0.0"
}, },
"type": "shopware-platform-plugin", "type": "shopware-platform-plugin",
"version": "7.2.0" "version": "7.3.4"
} }
+1 -1
View File
@@ -23,7 +23,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href="https://github.com/vr-payment/shopware-6/releases/tag/7.2.0/"> <a href="https://github.com/vr-payment/shopware-6/releases/tag/7.3.4/">
Source Source
</a> </a>
</li> </li>
+1 -1
View File
@@ -23,7 +23,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href="https://github.com/vr-payment/shopware-6/releases/tag/7.2.0/"> <a href="https://github.com/vr-payment/shopware-6/releases/tag/7.3.4/">
Source Source
</a> </a>
</li> </li>
+1 -1
View File
@@ -23,7 +23,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href="https://github.com/vr-payment/shopware-6/releases/tag/7.2.0/"> <a href="https://github.com/vr-payment/shopware-6/releases/tag/7.3.4/">
Source Source
</a> </a>
</li> </li>
+1 -1
View File
@@ -23,7 +23,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href="https://github.com/vr-payment/shopware-6/releases/tag/7.2.0/"> <a href="https://github.com/vr-payment/shopware-6/releases/tag/7.3.4/">
Source Source
</a> </a>
</li> </li>
@@ -14,7 +14,8 @@ use Symfony\Component\{
use VRPaymentPayment\Core\{ use VRPaymentPayment\Core\{
Api\Refund\Service\RefundService, Api\Refund\Service\RefundService,
Api\Transaction\Service\TransactionService, Api\Transaction\Service\TransactionService,
Settings\Service\SettingsService Settings\Service\SettingsService,
Util\Exception\RefundNotSupportedException
}; };
/** /**
@@ -105,7 +106,12 @@ class RefundController extends AbstractController
return new Response('refundExceedsQuantity', Response::HTTP_BAD_REQUEST); return new Response('refundExceedsQuantity', Response::HTTP_BAD_REQUEST);
} }
$refund = $this->refundService->create($transaction, $context, $lineItemId, $quantity); try {
$refund = $this->refundService->create($transaction, $context, $lineItemId, $quantity);
} catch (RefundNotSupportedException $exception) {
$this->logger->info('Payment method does not support online refunds for transaction: ' . $transactionId);
return new Response('methodDoesNotSupportRefund', Response::HTTP_BAD_REQUEST);
}
if ($refund === null) { if ($refund === null) {
return new Response('Refund was not created. Please check the refund amound or if the item was not refunded before', Response::HTTP_BAD_REQUEST); return new Response('Refund was not created. Please check the refund amound or if the item was not refunded before', Response::HTTP_BAD_REQUEST);
@@ -148,7 +154,12 @@ class RefundController extends AbstractController
return new Response('refundExceedsAmount', Response::HTTP_BAD_REQUEST); return new Response('refundExceedsAmount', Response::HTTP_BAD_REQUEST);
} }
$refund = $this->refundService->createRefundByAmount($transaction, $refundableAmount, $context); try {
$refund = $this->refundService->createRefundByAmount($transaction, $refundableAmount, $context);
} catch (RefundNotSupportedException $exception) {
$this->logger->info('Payment method does not support online refunds for transaction: ' . $transactionId);
return new Response('methodDoesNotSupportRefund', Response::HTTP_BAD_REQUEST);
}
if ($refund === null) { if ($refund === null) {
return new Response('refundExceedsAmount', Response::HTTP_BAD_REQUEST); return new Response('refundExceedsAmount', Response::HTTP_BAD_REQUEST);
@@ -179,7 +190,13 @@ class RefundController extends AbstractController
$apiClient = $settings->getApiClient(); $apiClient = $settings->getApiClient();
$transaction = $apiClient->getTransactionService()->read($settings->getSpaceId(), $transactionId); $transaction = $apiClient->getTransactionService()->read($settings->getSpaceId(), $transactionId);
$this->refundService->createPartialRefund($transaction, $context, $lineItemId, $refundableAmount);
try {
$refund = $this->refundService->createPartialRefund($transaction, $context, $lineItemId, $refundableAmount);
} catch (RefundNotSupportedException $exception) {
$this->logger->info('Payment method does not support online refunds for transaction: ' . $transactionId);
return new Response('methodDoesNotSupportRefund', Response::HTTP_BAD_REQUEST);
}
return new Response(null, Response::HTTP_NO_CONTENT); return new Response(null, Response::HTTP_NO_CONTENT);
} }
+21 -1
View File
@@ -17,13 +17,15 @@ use VRPayment\Sdk\{
Model\EntityQueryFilter, Model\EntityQueryFilter,
Model\EntityQueryFilterType, Model\EntityQueryFilterType,
Model\EntityQuery, Model\EntityQuery,
ApiException
}; };
use VRPaymentPayment\Core\{ use VRPaymentPayment\Core\{
Api\Refund\Entity\RefundEntity, Api\Refund\Entity\RefundEntity,
Api\Transaction\Entity\TransactionEntity, Api\Transaction\Entity\TransactionEntity,
Api\Transaction\Entity\TransactionEntityDefinition, Api\Transaction\Entity\TransactionEntityDefinition,
Settings\Service\SettingsService, Settings\Service\SettingsService,
Util\Payload\RefundPayload Util\Payload\RefundPayload,
Util\Exception\RefundNotSupportedException
}; };
/** /**
@@ -103,6 +105,12 @@ class RefundService
$this->upsert($refund, $context); $this->upsert($refund, $context);
return $refund; return $refund;
} }
} catch (ApiException $exception) {
$message = $exception->getMessage();
$this->logger->critical($message);
if ($exception->getCode() === 442 && str_contains($message, 'does not support online refunds')) {
throw new RefundNotSupportedException($message, 0, $exception);
}
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical($exception->getMessage()); $this->logger->critical($exception->getMessage());
} }
@@ -138,6 +146,12 @@ class RefundService
$this->upsert($refund, $context); $this->upsert($refund, $context);
return $refund; return $refund;
} }
} catch (ApiException $exception) {
$message = $exception->getMessage();
$this->logger->critical($message);
if ($exception->getCode() === 442 && str_contains($message, 'does not support online refunds')) {
throw new RefundNotSupportedException($message, 0, $exception);
}
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical($exception->getMessage()); $this->logger->critical($exception->getMessage());
} }
@@ -174,6 +188,12 @@ class RefundService
$this->upsert($refund, $context); $this->upsert($refund, $context);
return $refund; return $refund;
} }
} catch (ApiException $exception) {
$message = $exception->getMessage();
$this->logger->critical($message);
if ($exception->getCode() === 442 && str_contains($message, 'does not support online refunds')) {
throw new RefundNotSupportedException($message, 0, $exception);
}
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical($exception->getMessage()); $this->logger->critical($exception->getMessage());
} }
@@ -1,7 +1,10 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Api\Transaction\Service; namespace VRPaymentPayment\Core\Api\Transaction\Service;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shopware\Core\{ use Shopware\Core\{
@@ -29,6 +32,7 @@ use VRPayment\Sdk\Model\{
LineItemAttributeCreate, LineItemAttributeCreate,
LineItemCreate, LineItemCreate,
LineItemType, LineItemType,
TaxCreate,
TokenizationMode, TokenizationMode,
Transaction, Transaction,
TransactionCreate, TransactionCreate,
@@ -79,6 +83,12 @@ class TransactionService
*/ */
private $settingsService; private $settingsService;
/**
* Cache for storing pending transaction IDs across headless requests.
* @var CacheItemPoolInterface
*/
private CacheItemPoolInterface $cache;
const CARD_HOLDER_KEY = '1456765000789'; const CARD_HOLDER_KEY = '1456765000789';
const PSEUDO_CODE_KEY = '1485172176673'; const PSEUDO_CODE_KEY = '1485172176673';
const CARD_VALIDITY_KEY = '1456765711187'; const CARD_VALIDITY_KEY = '1456765711187';
@@ -91,16 +101,18 @@ class TransactionService
* @param \Psr\Container\ContainerInterface $container * @param \Psr\Container\ContainerInterface $container
* @param \VRPaymentPayment\Core\Util\LocaleCodeProvider $localeCodeProvider * @param \VRPaymentPayment\Core\Util\LocaleCodeProvider $localeCodeProvider
* @param \VRPaymentPayment\Core\Settings\Service\SettingsService $settingsService * @param \VRPaymentPayment\Core\Settings\Service\SettingsService $settingsService
* @param CacheItemPoolInterface $cache Cache for headless transaction persistence
*/ */
public function __construct( public function __construct(
ContainerInterface $container, ContainerInterface $container,
LocaleCodeProvider $localeCodeProvider, LocaleCodeProvider $localeCodeProvider,
SettingsService $settingsService SettingsService $settingsService,
) CacheItemPoolInterface $cache
{ ) {
$this->container = $container; $this->container = $container;
$this->localeCodeProvider = $localeCodeProvider; $this->localeCodeProvider = $localeCodeProvider;
$this->settingsService = $settingsService; $this->settingsService = $settingsService;
$this->cache = $cache;
} }
/** /**
@@ -132,8 +144,7 @@ class TransactionService
public function create( public function create(
PaymentTransactionStruct $transaction, PaymentTransactionStruct $transaction,
SalesChannelContext $salesChannelContext SalesChannelContext $salesChannelContext
): string ): string {
{
$criteria = new Criteria([$transaction->getOrderTransactionId()]); $criteria = new Criteria([$transaction->getOrderTransactionId()]);
$criteria->addAssociation('order'); $criteria->addAssociation('order');
$orderTransaction = $this->container->get('order_transaction.repository')->search($criteria, $salesChannelContext->getContext())->first(); $orderTransaction = $this->container->get('order_transaction.repository')->search($criteria, $salesChannelContext->getContext())->first();
@@ -142,13 +153,28 @@ class TransactionService
$settings = $this->settingsService->getSettings($salesChannelId); $settings = $this->settingsService->getSettings($salesChannelId);
$apiClient = $settings->getApiClient(); $apiClient = $settings->getApiClient();
$transactionId = $_SESSION['transactionId'] ?? null; // Get transaction ID from cache (headless) or session (storefront).
$transactionId = $this->getTransactionIdFromContext($salesChannelContext);
$pendingTransaction = null;
// Try to read the pending transaction if we have an ID stored.
if ($transactionId !== null) { if ($transactionId !== null) {
$pendingTransaction = $this->read($_SESSION['transactionId'], $salesChannelId); try {
$pendingTransaction = $this->read($transactionId, $salesChannelId);
// Verify it's still in PENDING state - otherwise we can't reuse it.
if ($pendingTransaction != null && $pendingTransaction->getState() !== TransactionState::PENDING) {
$pendingTransaction = null;
}
} catch (\Exception $e) {
// Transaction may have been deleted, expired, or is invalid - we'll create a new one.
$this->logger?->debug('Could not read pending transaction, will create new one: ' . $e->getMessage());
$pendingTransaction = null;
}
} }
if ($transactionId === null || $pendingTransaction === null || $pendingTransaction->getState() !== TransactionState::PENDING) { // Create a new transaction if we don't have a valid pending one.
unset($_SESSION['transactionId']); if ($pendingTransaction === null) {
$this->clearTransactionIdFromContext($salesChannelContext);
$pendingTransactionId = $this->createPendingTransaction($salesChannelContext); $pendingTransactionId = $this->createPendingTransaction($salesChannelContext);
$pendingTransaction = $this->read($pendingTransactionId, $salesChannelId); $pendingTransaction = $this->read($pendingTransactionId, $salesChannelId);
} }
@@ -161,6 +187,7 @@ class TransactionService
$transaction $transaction
)); ));
$transactionPayloadClass->setLogger($this->logger); $transactionPayloadClass->setLogger($this->logger);
$transactionPayloadClass->setTransactionId($pendingTransaction->getId());
$transactionPayload = $transactionPayloadClass->get($pendingTransaction->getVersion()); $transactionPayload = $transactionPayloadClass->get($pendingTransaction->getVersion());
$createdTransaction = $apiClient->getTransactionService() $createdTransaction = $apiClient->getTransactionService()
@@ -170,7 +197,8 @@ class TransactionService
$transaction, $transaction,
$salesChannelContext->getContext(), $salesChannelContext->getContext(),
$createdTransaction->getId(), $createdTransaction->getId(),
$settings->getSpaceId() $settings->getSpaceId(),
$salesChannelContext->getToken()
); );
$redirectUrl = $this->container->get('router')->generate( $redirectUrl = $this->container->get('router')->generate(
@@ -179,6 +207,16 @@ class TransactionService
UrlGeneratorInterface::ABSOLUTE_URL UrlGeneratorInterface::ABSOLUTE_URL
); );
// If the request comes from the Store API (headless), we should not redirect to a Storefront Twig page.
// Instead, we return the returnUrl so the headless client can handle the next steps (e.g. rendering the iframe).
$request = $this->container->get('request_stack')->getCurrentRequest();
if ($request) {
$routeScope = $request->attributes->get('_route_scope', []);
if (in_array('store-api', $routeScope, true)) {
$redirectUrl = $transaction->getReturnUrl();
}
}
if ($settings->getIntegration() == Integration::PAYMENT_PAGE) { if ($settings->getIntegration() == Integration::PAYMENT_PAGE) {
$redirectUrl = $apiClient->getTransactionPaymentPageService() $redirectUrl = $apiClient->getTransactionPaymentPageService()
->paymentPageUrl($settings->getSpaceId(), $createdTransaction->getId()); ->paymentPageUrl($settings->getSpaceId(), $createdTransaction->getId());
@@ -216,7 +254,8 @@ class TransactionService
* *
* @return void * @return void
*/ */
public function createRecurringTransaction(TransactionCreate $sdkTransactionCreate, string $spaceId = ""): Transaction { public function createRecurringTransaction(TransactionCreate $sdkTransactionCreate, string $spaceId = ""): Transaction
{
$settings = $this->settingsService->getSettings(); $settings = $this->settingsService->getSettings();
if (empty($spaceId)) { if (empty($spaceId)) {
$spaceId = $settings->getSpaceId(); $spaceId = $settings->getSpaceId();
@@ -245,15 +284,21 @@ class TransactionService
PaymentTransactionStruct $transaction, PaymentTransactionStruct $transaction,
Context $context, Context $context,
int $vrpaymentTransactionId, int $vrpaymentTransactionId,
int $spaceId int $spaceId,
): void ?string $token = null
{ ): void {
$customFields = [
TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_TRANSACTION_ID => $vrpaymentTransactionId,
TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_SPACE_ID => $spaceId,
];
if ($token) {
$customFields[TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_TOKEN] = $token;
}
$data = [ $data = [
'id' => $transaction->getOrderTransactionId(), 'id' => $transaction->getOrderTransactionId(),
'customFields' => [ 'customFields' => $customFields,
TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_TRANSACTION_ID => $vrpaymentTransactionId,
TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_SPACE_ID => $spaceId,
],
]; ];
$this->container->get('order_transaction.repository')->update([$data], $context); $this->container->get('order_transaction.repository')->update([$data], $context);
} }
@@ -269,10 +314,9 @@ class TransactionService
public function upsert( public function upsert(
Transaction $transaction, Transaction $transaction,
Context $context, Context $context,
string $paymentMethodId = null, ?string $paymentMethodId = null,
string $salesChannelId = null ?string $salesChannelId = null
): void ): void {
{
try { try {
$transactionId = $transaction->getId(); $transactionId = $transaction->getId();
@@ -285,6 +329,13 @@ class TransactionService
$orderId = $transactionMetaData[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID]; $orderId = $transactionMetaData[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID];
$orderTransactionId = $transactionMetaData[TransactionPayload::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID]; $orderTransactionId = $transactionMetaData[TransactionPayload::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID];
if (!$paymentMethodId) {
$criteria = new Criteria([$orderTransactionId]);
$criteria->addAssociation('order');
$orderTransaction = $this->container->get('order_transaction.repository')->search($criteria, $context)->first();
$paymentMethodId = $orderTransaction->getPaymentMethodId();
}
$dataParamValue = json_decode(strval($transaction), true); $dataParamValue = json_decode(strval($transaction), true);
$brandName = ''; $brandName = '';
if (isset($dataParamValue['paymentConnectorConfiguration'])) { if (isset($dataParamValue['paymentConnectorConfiguration'])) {
@@ -351,7 +402,6 @@ class TransactionService
$data = array_filter($data); $data = array_filter($data);
$this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository')->upsert([$data], $context); $this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository')->upsert([$data], $context);
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage()); $this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage());
} }
@@ -402,7 +452,6 @@ class TransactionService
} catch (\Exception $e) { } catch (\Exception $e) {
throw CartException::orderNotFound($orderId); throw CartException::orderNotFound($orderId);
} }
} }
/** /**
@@ -450,7 +499,8 @@ class TransactionService
return $this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository') return $this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository')
->search( ->search(
(new Criteria())->addFilter(new EqualsFilter('transactionId', $transactionId)) (new Criteria())->addFilter(new EqualsFilter('transactionId', $transactionId))
->addAssociations(['refunds']), $context ->addAssociations(['refunds']),
$context
) )
->first(); ->first();
} }
@@ -468,7 +518,8 @@ class TransactionService
return $this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository') return $this->container->get(TransactionEntityDefinition::ENTITY_NAME . '.repository')
->search( ->search(
(new Criteria())->addFilter(new EqualsFilter('orderTransactionId', $orderTransactionId)) (new Criteria())->addFilter(new EqualsFilter('orderTransactionId', $orderTransactionId))
->addAssociations(['refunds']), $context ->addAssociations(['refunds']),
$context
) )
->first(); ->first();
} }
@@ -485,7 +536,8 @@ class TransactionService
{ {
return $this->container->get(RefundEntityDefinition::ENTITY_NAME . '.repository') return $this->container->get(RefundEntityDefinition::ENTITY_NAME . '.repository')
->search( ->search(
(new Criteria())->addFilter(new EqualsFilter('transactionId', $transactionId)), $context (new Criteria())->addFilter(new EqualsFilter('transactionId', $transactionId)),
$context
) )
->getEntities(); ->getEntities();
} }
@@ -528,17 +580,23 @@ class TransactionService
public function createPendingTransaction(SalesChannelContext $salesChannelContext, $event = null): int public function createPendingTransaction(SalesChannelContext $salesChannelContext, $event = null): int
{ {
$expiredTransaction = true; $expiredTransaction = true;
$transactionId = $_SESSION['transactionId'] ?? null; // Get transaction ID from cache (headless) or session (storefront).
$transactionId = $this->getTransactionIdFromContext($salesChannelContext);
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId()); $settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
if (!$settings) { if (!$settings) {
throw new \Exception('Space settings not configured'); throw new \Exception('Space settings not configured');
} }
if ($transactionId) { if ($transactionId) {
$transactionService = $settings->getApiClient()->getTransactionService(); try {
$pendingTransaction = $transactionService->read($settings->getSpaceId(), $transactionId); $transactionService = $settings->getApiClient()->getTransactionService();
if ($pendingTransaction->getState() === TransactionState::PENDING) { $pendingTransaction = $transactionService->read($settings->getSpaceId(), $transactionId);
$expiredTransaction = false; if ($pendingTransaction->getState() === TransactionState::PENDING) {
$expiredTransaction = false;
}
} catch (\Exception $e) {
// Transaction may have been deleted, expired, or is invalid - treat as expired.
$expiredTransaction = true;
} }
} }
@@ -546,25 +604,20 @@ class TransactionService
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId()); $settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
$customer = $salesChannelContext->getCustomer(); $customer = $salesChannelContext->getCustomer();
$lineItems = []; if ($customer === null) {
if ($event) { throw new \Exception('Customer is required to create a transaction');
if ($event instanceof CheckoutConfirmPageLoadedEvent) {
$cartLineItems = $event->getPage()->getCart()->getLineItems()->getElements();
foreach ($cartLineItems as $cartLineItem) {
if ($cartLineItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
continue;
}
$lineItems[] = $this->createTempLineItem($cartLineItem);
}
} elseif ($event instanceof AccountEditOrderPageLoadedEvent) {
$order = $event->getPage()->getOrder();
foreach ($order->getLineItems() as $orderLineItem) {
$lineItems[] = $this->createTempLineItem($orderLineItem);
}
}
} }
$lineItems = $this->extractLineItems(
$event,
$salesChannelContext,
);
$customerId = ""; /*
* For guest checkouts, the customer ID is set to null rather than an empty string.
* This ensures consistency with TransactionPayload which also uses null for guests,
* preventing the Portal from treating the difference as an update/change in customer details.
*/
$customerId = null;
if ($customer->getGuest() === false) { if ($customer->getGuest() === false) {
$customerId = $customer->getCustomerNumber(); $customerId = $customer->getCustomerNumber();
} }
@@ -580,11 +633,14 @@ class TransactionService
throw new \Exception('Space settings not configured'); throw new \Exception('Space settings not configured');
} }
$language = $this->localeCodeProvider->getLocaleCodeFromContext($salesChannelContext->getContext());
$transactionPayload = (new TransactionCreate()) $transactionPayload = (new TransactionCreate())
->setBillingAddress($billingAddress) ->setBillingAddress($billingAddress)
->setShippingAddress($shippingAddress) ->setShippingAddress($shippingAddress)
->setLineItems($lineItems) ->setLineItems($lineItems)
->setCurrency($currency) ->setCurrency($currency)
->setLanguage($language)
->setSpaceViewId($settings->getSpaceViewId()) ->setSpaceViewId($settings->getSpaceViewId())
->setAutoConfirmationEnabled(false) ->setAutoConfirmationEnabled(false)
->setChargeRetryEnabled(false) ->setChargeRetryEnabled(false)
@@ -593,14 +649,16 @@ class TransactionService
->setSuccessUrl($homeUrl . '?success') ->setSuccessUrl($homeUrl . '?success')
->setFailedUrl($homeUrl . '?fail'); ->setFailedUrl($homeUrl . '?fail');
if($this->isSubscription($salesChannelContext)) { if ($this->isSubscription($salesChannelContext)) {
$transactionPayload->setTokenizationMode(TokenizationMode::FORCE_CREATION); $transactionPayload->setTokenizationMode(TokenizationMode::FORCE_CREATION);
} }
$transactionService = $settings->getApiClient()->getTransactionService(); $transactionService = $settings->getApiClient()->getTransactionService();
$transaction = $transactionService->create($settings->getSpaceId(), $transactionPayload); $transaction = $transactionService->create($settings->getSpaceId(), $transactionPayload);
$transactionId = $transaction->getId(); $transactionId = $transaction->getId();
$_SESSION['transactionId'] = $transactionId;
// Store in cache and session for transaction reuse.
$this->storeTransactionIdInContext($salesChannelContext, $transactionId);
} }
return $transactionId; return $transactionId;
@@ -611,7 +669,7 @@ class TransactionService
* @param int $transactionId * @param int $transactionId
* @return void * @return void
*/ */
public function updateTempTransaction(SalesChannelContext $salesChannelContext, int $transactionId): void public function updateTempTransaction(SalesChannelContext $salesChannelContext, int $transactionId, array $lineItems = []): void
{ {
$pendingTransaction = new TransactionPending(); $pendingTransaction = new TransactionPending();
$pendingTransaction->setId($transactionId); $pendingTransaction->setId($transactionId);
@@ -622,17 +680,95 @@ class TransactionService
$currency = $salesChannelContext->getCurrency()->getIsoCode(); $currency = $salesChannelContext->getCurrency()->getIsoCode();
$language = $this->localeCodeProvider->getLocaleCodeFromContext($salesChannelContext->getContext());
$pendingTransaction->setCurrency($currency); $pendingTransaction->setCurrency($currency);
$pendingTransaction->setLanguage($language);
$billingAddress = $this->buildAddress($salesChannelContext, $salesChannelContext->getCustomer()->getActiveBillingAddress()); $billingAddress = $this->buildAddress($salesChannelContext, $salesChannelContext->getCustomer()->getActiveBillingAddress());
$shippingAddress = $this->buildAddress($salesChannelContext, $salesChannelContext->getCustomer()->getActiveShippingAddress()); $shippingAddress = $this->buildAddress($salesChannelContext, $salesChannelContext->getCustomer()->getActiveShippingAddress());
$pendingTransaction->setBillingAddress($billingAddress); $pendingTransaction->setBillingAddress($billingAddress);
$pendingTransaction->setShippingAddress($shippingAddress); $pendingTransaction->setShippingAddress($shippingAddress);
if (!empty($lineItems)) {
$pendingTransaction->setLineItems($lineItems);
}
$settings->getApiClient()->getTransactionService() $settings->getApiClient()->getTransactionService()
->update($settings->getSpaceId(), $pendingTransaction); ->update($settings->getSpaceId(), $pendingTransaction);
} }
/**
* Extracts line items from the given source (Event or Cart) and appends shipping costs.
*
* @param mixed $source
* @param SalesChannelContext|null $salesChannelContext
* @return array
*/
public function extractLineItems(
$source,
?SalesChannelContext $salesChannelContext = null,
): array {
$lineItems = [];
if ($source) {
if ($source instanceof CheckoutConfirmPageLoadedEvent) {
$cartLineItems = $source->getPage()->getCart()->getLineItems()->getElements();
foreach ($cartLineItems as $cartLineItem) {
if ($cartLineItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
continue;
}
$lineItems[] = $this->createTempLineItem($cartLineItem);
}
} elseif ($source instanceof AccountEditOrderPageLoadedEvent) {
$order = $source->getPage()->getOrder();
foreach ($order->getLineItems() as $orderLineItem) {
$lineItems[] = $this->createTempLineItem($orderLineItem);
}
} elseif ($source instanceof \Shopware\Core\Checkout\Cart\Cart) {
$cartLineItems = $source->getLineItems()->getElements();
foreach ($cartLineItems as $cartLineItem) {
if ($cartLineItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
continue;
}
$lineItems[] = $this->createTempLineItem($cartLineItem);
}
}
// Extract and append shipping costs as a line item if applicable.
$shippingCosts = null;
$taxStatus = 'gross';
if ($source instanceof CheckoutConfirmPageLoadedEvent) {
$cart = $source->getPage()->getCart();
$shippingCosts = $cart->getDeliveries()->getShippingCosts();
if ($salesChannelContext !== null) {
$taxStatus = $salesChannelContext->getTaxState();
}
} elseif ($source instanceof AccountEditOrderPageLoadedEvent) {
$order = $source->getPage()->getOrder();
$shippingCosts = $order->getShippingCosts();
$taxStatus = $order->getTaxStatus();
} elseif ($source instanceof \Shopware\Core\Checkout\Cart\Cart) {
$shippingCosts = $source->getDeliveries()->getShippingCosts();
if ($salesChannelContext !== null) {
$taxStatus = $salesChannelContext->getTaxState();
}
}
if ($shippingCosts !== null) {
$shippingLineItem = $this->extractShippingLineItem(
$shippingCosts,
$taxStatus,
$salesChannelContext,
);
if ($shippingLineItem !== null) {
$lineItems[] = $shippingLineItem;
}
}
}
return $lineItems;
}
/** /**
* @param ChargeAttempt|null $chargeAttempt * @param ChargeAttempt|null $chargeAttempt
* @param string $descriptorKey * @param string $descriptorKey
@@ -717,32 +853,38 @@ class TransactionService
return $chargeAttempts ? $chargeAttempts[0] : null; return $chargeAttempts ? $chargeAttempts[0] : null;
} }
private function createTempLineItem($productData): LineItemCreate private function createTempLineItem($productData): LineItemCreate
{ {
$lineItem = new LineItemCreate(); $lineItem = new LineItemCreate();
$roundedPrice = $this->round($productData->getPrice()->getUnitPrice()); $price = $productData->getPrice();
$unit = $price->getUnitPrice();
if ($productData instanceof LineItem) { // Expects discounts as separate items, avoid negative prices
$lineItem->setName($productData->getLabel()); if ($unit < 0) {
$lineItem->setUniqueId($productData->getId()); return $this->mapDiscountLineItem($productData);
$lineItem->setSku($productData->getReferencedId() ?? $productData->getId()); }
$lineItem->setQuantity($productData->getQuantity());
$lineItem->setAmountIncludingTax($roundedPrice);
} elseif ($productData instanceof OrderLineItemEntity) {
$lineItem->setName($productData->getLabel());
$lineItem->setUniqueId($productData->getId());
$lineItem->setSku($productData->getProductId() ?? $productData->getIdentifier() ?? $productData->getId());
$lineItem->setQuantity($productData->getQuantity());
$lineItem->setAmountIncludingTax($roundedPrice);
} else {
throw new \InvalidArgumentException('Unsupported line item type: ' . get_class($productData));
}
$lineItem->setType(LineItemType::PRODUCT); if ($productData instanceof LineItem) {
$lineItem->setName($productData->getLabel());
$lineItem->setUniqueId($productData->getId());
$lineItem->setSku($productData->getReferencedId() ?? $productData->getId());
$lineItem->setQuantity($productData->getQuantity());
$lineItem->setAmountIncludingTax($this->round($unit));
} elseif ($productData instanceof OrderLineItemEntity) {
$lineItem->setName($productData->getLabel());
$lineItem->setUniqueId($productData->getId());
$lineItem->setSku($productData->getProductId() ?? $productData->getIdentifier() ?? $productData->getId());
$lineItem->setQuantity($productData->getQuantity());
$lineItem->setAmountIncludingTax($this->round($unit));
} else {
throw new \InvalidArgumentException('Unsupported line item type: ' . get_class($productData));
}
return $lineItem; $lineItem->setType(LineItemType::PRODUCT);
}
return $lineItem;
}
/** /**
* Build a VRPayment address from Shopware customer address. * Build a VRPayment address from Shopware customer address.
@@ -764,6 +906,7 @@ class TransactionService
$address->setOrganizationName($addressEntity->getCompany()); $address->setOrganizationName($addressEntity->getCompany());
$address->setPhoneNumber($addressEntity->getPhoneNumber()); $address->setPhoneNumber($addressEntity->getPhoneNumber());
$address->setCountry($addressEntity->getCountry()->getIso()); $address->setCountry($addressEntity->getCountry()->getIso());
$address->setCity($addressEntity->getCity() ?: '');
$postalState = $addressEntity?->getCountryState()?->getName() $postalState = $addressEntity?->getCountryState()?->getName()
?: $addressEntity?->getCountryState()?->getShortCode() ?: $addressEntity?->getCountryState()?->getShortCode()
@@ -785,8 +928,8 @@ class TransactionService
$address->setSalutation($salutationEntity?->getDisplayName() ?? ''); $address->setSalutation($salutationEntity?->getDisplayName() ?? '');
$address->setGender( $address->setGender(
strtolower($salutationEntity?->getSalutationKey() ?? '') === 'mr' strtolower($salutationEntity?->getSalutationKey() ?? '') === 'mr'
? Gender::MALE ? Gender::MALE
: Gender::FEMALE : Gender::FEMALE
); );
return $address; return $address;
@@ -798,7 +941,8 @@ class TransactionService
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext * @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext
* @return bool * @return bool
*/ */
private function isSubscription(SalesChannelContext $salesChannelContext): bool { private function isSubscription(SalesChannelContext $salesChannelContext): bool
{
$extensionName = 'subscription'; $extensionName = 'subscription';
if (class_exists(\Shopware\Commercial\Subscription\Framework\Struct\SubscriptionContextStruct::class)) { if (class_exists(\Shopware\Commercial\Subscription\Framework\Struct\SubscriptionContextStruct::class)) {
$extensionName = SubscriptionContextStruct::SUBSCRIPTION_EXTENSION; $extensionName = SubscriptionContextStruct::SUBSCRIPTION_EXTENSION;
@@ -810,12 +954,195 @@ class TransactionService
} }
/** /**
* @param $amount * @param $amount
* @param int $precision * @param int $precision
* *
* @return float * @return float
*/ */
private function round($value, $precision = 2): float { private function round($value, $precision = 2): float
{
return \round($value, $precision); return \round($value, $precision);
} }
/**
* Generates a cache key for the pending transaction ID.
* Uses customer ID for authenticated users, which works for both headless and storefront.
*
* @param SalesChannelContext $salesChannelContext
* @return string|null
*/
private function getPendingTransactionCacheKey(SalesChannelContext $salesChannelContext): ?string
{
$customer = $salesChannelContext->getCustomer();
if ($customer) {
return 'vrpn_pending_transaction_id_customer_' . $customer->getId();
}
return null;
}
/**
* Retrieves the stored pending transaction ID from cache or session.
* Uses customer ID as cache key for headless (stateless) support.
* Falls back to session for Storefront (stateful) compatibility.
*
* @param SalesChannelContext $salesChannelContext
* @return int|null The transaction ID if found, otherwise null.
*/
private function getTransactionIdFromContext(SalesChannelContext $salesChannelContext): ?int
{
// Try cache first (for headless/API where session might not persist or be shared).
$cacheKey = $this->getPendingTransactionCacheKey($salesChannelContext);
if ($cacheKey) {
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
return (int) $item->get();
}
}
// Fallback to PHP session for traditional Storefront compatibility.
if (isset($_SESSION['transactionId'])) {
return (int) $_SESSION['transactionId'];
}
return null;
}
/**
* Clears the pending transaction ID from cache and session.
*
* @param SalesChannelContext $salesChannelContext
*/
public function clearTransactionIdFromContext(SalesChannelContext $salesChannelContext): void
{
// Clear from cache key.
$cacheKey = $this->getPendingTransactionCacheKey($salesChannelContext);
if ($cacheKey) {
$this->cache->deleteItem($cacheKey);
}
// Clear from session.
if (isset($_SESSION['transactionId'])) {
unset($_SESSION['transactionId']);
}
}
/**
* Stores the pending transaction ID in cache and session.
* This persists in the database (via cache) and works across all request types (Storefront & headless).
*
* @param SalesChannelContext $salesChannelContext
* @param int $transactionId
*/
private function storeTransactionIdInContext(SalesChannelContext $salesChannelContext, int $transactionId): void
{
// Store in cache for headless.
$cacheKey = $this->getPendingTransactionCacheKey($salesChannelContext);
if ($cacheKey) {
$item = $this->cache->getItem($cacheKey);
$item->set($transactionId);
// Expire after 2 hours to avoid stale data (matching typical cart lifetime).
$item->expiresAfter(7200);
$this->cache->save($item);
}
// Store in session for Storefront.
$_SESSION['transactionId'] = $transactionId;
}
/**
* Creates a discount line item for negative-priced cart entries.
*
* @param $productData
* @return LineItemCreate
*
*/
private function mapDiscountLineItem($productData): LineItemCreate
{
$price = $productData->getPrice();
$lineItem = new LineItemCreate();
$amount = abs($price->getTotalPrice());
$lineItem->setName($productData->getLabel() ?: 'Discount');
$lineItem->setUniqueId('discount-' . $productData->getId());
$lineItem->setSku('discount');
$lineItem->setQuantity(1);
$lineItem->setAmountIncludingTax($this->round($amount));
$lineItem->setType(LineItemType::DISCOUNT);
return $lineItem;
}
/**
* Extracts shipping line item from cart/order shipping costs.
*
* @param mixed $shippingCosts
* @param string $taxStatus
* @param SalesChannelContext|null $salesChannelContext
* @return LineItemCreate|null
*/
private function extractShippingLineItem(
$shippingCosts,
string $taxStatus,
?SalesChannelContext $salesChannelContext = null,
): ?LineItemCreate {
// When shipping costs are extracted from a Cart, they are returned as a PriceCollection
// containing multiple CalculatedPrice items. We must sum them to get a single aggregated price.
if ($shippingCosts instanceof \Shopware\Core\Checkout\Cart\Price\Struct\PriceCollection) {
$shippingCosts = $shippingCosts->sum();
}
if (!$shippingCosts instanceof \Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice) {
return null;
}
if ($shippingCosts->getTotalPrice() <= 0) {
return null;
}
$amount = $shippingCosts->getTotalPrice();
if ($taxStatus === 'net') {
$amount += $shippingCosts->getCalculatedTaxes()->getAmount();
}
$roundedAmount = $this->round($amount);
$shippingMethodName = $salesChannelContext?->getShippingMethod()?->getName();
$translator = $this->container->has('translator') ? $this->container->get('translator') : null;
$fallbackName = $translator ? $translator->trans('vrpayment.payload.shipping.name') : 'Shipping';
$shippingName = $shippingMethodName ?? $fallbackName;
$shippingLineItem = new LineItemCreate();
$shippingLineItem->setAmountIncludingTax($roundedAmount)
->setName($this->fixLength($shippingName . ' ' . ($translator ? $translator->trans('vrpayment.payload.shipping.lineItem') : 'Shipping'), 150))
->setQuantity($shippingCosts->getQuantity() ?? 1)
->setSku($this->fixLength($shippingName . '-Shipping', 200))
->setType(LineItemType::SHIPPING)
->setUniqueId($this->fixLength($shippingName . '-Shipping', 200));
if ($taxStatus !== 'tax-free') {
$taxes = [];
foreach ($shippingCosts->getCalculatedTaxes() as $calculatedTax) {
$tax = (new TaxCreate())
->setRate($calculatedTax->getTaxRate())
->setTitle($this->fixLength($shippingName . ' : ' . $calculatedTax->getTaxRate(), 40));
$taxes[] = $tax;
}
$shippingLineItem->setTaxes($taxes);
}
return $shippingLineItem;
}
/**
* Fix string length to specific length.
*
* @param string $string
* @param int $maxLength
* @return string
*/
private function fixLength(string $string, int $maxLength): string
{
return \mb_substr($string, 0, $maxLength, 'UTF-8');
}
} }
@@ -486,7 +486,7 @@ class WebHookController extends AbstractController {
$transaction = $this->settings->getApiClient() $transaction = $this->settings->getApiClient()
->getTransactionService() ->getTransactionService()
->read($callBackData->getSpaceId(), $callBackData->getEntityId()); ->read($callBackData->getSpaceId(), $callBackData->getEntityId());
$orderId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID]; $orderId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID] ?? null;
if(!empty($orderId) && !$transaction->getParent()) { if(!empty($orderId) && !$transaction->getParent()) {
$this->executeLocked($orderId, $context, function () use ($orderId, $transaction, $context, $callBackData) { $this->executeLocked($orderId, $context, function () use ($orderId, $transaction, $context, $callBackData) {
$this->transactionService->upsert($transaction, $context); $this->transactionService->upsert($transaction, $context);
@@ -700,16 +700,6 @@ class WebHookController extends AbstractController {
private function unholdAndCancelDelivery(string $orderId, Context $context): void private function unholdAndCancelDelivery(string $orderId, Context $context): void
{ {
$order = $this->getOrderEntity($orderId, $context); $order = $this->getOrderEntity($orderId, $context);
try {
$this->orderService->orderStateTransition(
$order->getId(),
StateMachineTransitionActions::ACTION_CANCEL,
new ParameterBag(),
$context
);
} catch (\Exception $exception) {
$this->logger->info($exception->getMessage(), $exception->getTrace());
}
try { try {
/** /**
@@ -64,7 +64,7 @@ class WebHookRefundStrategy extends WebHookStrategyBase implements WebhookStrate
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getOrderIdByTransaction($transaction): string public function getOrderIdByTransaction($transaction): string|null
{ {
/** @var \VRPayment\Sdk\Model\Refund $transaction */ /** @var \VRPayment\Sdk\Model\Refund $transaction */
return $transaction->getTransaction() return $transaction->getTransaction()
@@ -378,16 +378,6 @@ abstract class WebHookStrategyBase implements WebHookStrategyInterface {
protected function unholdAndCancelDelivery(string $orderId, Context $context): void protected function unholdAndCancelDelivery(string $orderId, Context $context): void
{ {
$order = $this->getOrderEntity($orderId, $context); $order = $this->getOrderEntity($orderId, $context);
try {
$this->orderService->orderStateTransition(
$order->getId(),
StateMachineTransitionActions::ACTION_CANCEL,
new ParameterBag(),
$context
);
} catch (\Exception $exception) {
$this->logger->info($exception->getMessage(), $exception->getTrace());
}
try { try {
@@ -63,10 +63,10 @@ class WebHookTransactionInvoiceStrategy extends WebHookStrategyBase implements W
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getOrderIdByTransaction($transaction): string public function getOrderIdByTransaction($transactionInvoice): string|null
{ {
/** @var \VRPayment\Sdk\Model\TransactionInvoice $transaction */ /** @var \VRPayment\Sdk\Model\TransactionInvoice $transaction */
return $transaction->getCompletion() return $transactionInvoice->getCompletion()
->getLineItemVersion() ->getLineItemVersion()
->getTransaction() ->getTransaction()
->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID]; ->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID];
@@ -61,7 +61,7 @@ class WebHookTransactionStrategy extends WebHookStrategyBase implements WebhookS
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function getOrderIdByTransaction(Transaction $transaction): string public function getOrderIdByTransaction(Transaction $transaction): string|null
{ {
/** @var \VRPayment\Sdk\Model\Transaction $transaction */ /** @var \VRPayment\Sdk\Model\Transaction $transaction */
return $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID]; return $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID];
@@ -116,10 +116,12 @@ class WebHookTransactionStrategy extends WebHookStrategyBase implements WebhookS
/** @var \Shopware\Core\Checkout\Order\OrderEntity $order */ /** @var \Shopware\Core\Checkout\Order\OrderEntity $order */
$transaction = $this->getTransaction($request); $transaction = $this->getTransaction($request);
$token = $transaction->getToken(); $token = $transaction->getToken();
$orderId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID]; $orderId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_ID] ?? null;
if (!empty($orderId) && !$transaction->getParent()) { if (!empty($orderId) && !$transaction->getParent()) {
$this->executeLocked($orderId, $context, function () use ($orderId, $transaction, $context, $request, $token) { $this->executeLocked($orderId, $context, function () use ($orderId, $transaction, $context, $request, $token) {
$this->transactionService->upsert($transaction, $context); if ($this->allowUpsert($transaction, $orderId, $context)) {
$this->transactionService->upsert($transaction, $context);
}
$orderTransactionId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID]; $orderTransactionId = $transaction->getMetaData()[TransactionPayload::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID];
$orderTransaction = $this->getOrderTransaction($orderId, $context); $orderTransaction = $this->getOrderTransaction($orderId, $context);
$this->logger->info("OrderId: {orderId} Current state: {state}", [ $this->logger->info("OrderId: {orderId} Current state: {state}", [
@@ -178,4 +180,26 @@ class WebHookTransactionStrategy extends WebHookStrategyBase implements WebhookS
return new JsonResponse(['data' => $request->jsonSerialize()], $status); return new JsonResponse(['data' => $request->jsonSerialize()], $status);
} }
/**
* Checks the incoming transaction ID against the current transaction ID of the order.
* If they dont match, the saved transaction in the database remains unchanged.
*
* @param Transaction $transaction The transaction data retrieved from the portal.
* @param string $orderId The order ID of the current transaction.
* @param Context $context The operational context providing settings and environment for transaction processing.
* @return bool Returns a value that determines whether to upsert the transaction into the database.
*/
private function allowUpsert(Transaction $transaction, string $orderId, Context $context): bool
{
try {
$transactionEntity = $this->transactionService->getByOrderId($orderId, $context);
if ($transactionEntity->getTransactionId() !== $transaction->getId()) {
return false;
}
} catch (\Throwable $e) {
}
return true;
}
} }
@@ -57,5 +57,5 @@ interface WebhookStrategyActionsInterface {
* @param Transaction|TransactionInvoiceState|Refund|mixed $transaction The transaction object from which the order ID should be extracted. * @param Transaction|TransactionInvoiceState|Refund|mixed $transaction The transaction object from which the order ID should be extracted.
* @return string The order ID as a string. * @return string The order ID as a string.
*/ */
public function getOrderIdByTransaction(Transaction $transaction): string; public function getOrderIdByTransaction(Transaction $transaction): string|null;
} }
@@ -1,4 +1,6 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\PaymentHandler; namespace VRPaymentPayment\Core\Checkout\PaymentHandler;
@@ -23,7 +25,7 @@ use Shopware\Core\{
Framework\Struct\Struct, Framework\Struct\Struct,
Framework\Validation\DataBag\RequestDataBag, Framework\Validation\DataBag\RequestDataBag,
System\StateMachine\Aggregation\StateMachineState\StateMachineStateEntity, System\StateMachine\Aggregation\StateMachineState\StateMachineStateEntity,
System\SalesChannel\Context\SalesChannelContextService, System\SalesChannel\Context\SalesChannelContextServiceInterface,
System\SalesChannel\Context\SalesChannelContextServiceParameters System\SalesChannel\Context\SalesChannelContextServiceParameters
}; };
use Shopware\Core\Framework\Util\Random; use Shopware\Core\Framework\Util\Random;
@@ -69,7 +71,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
*/ */
private $orderTransactionStateHandler; private $orderTransactionStateHandler;
protected SalesChannelContextService $salesChannelContextService; protected SalesChannelContextServiceInterface $salesChannelContextService;
protected EntityRepository $orderTransactionRepository; protected EntityRepository $orderTransactionRepository;
@@ -82,7 +84,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
CustomCartPersister $cartPersister, CustomCartPersister $cartPersister,
PluginTransactionService $pluginTransactionService, PluginTransactionService $pluginTransactionService,
OrderTransactionStateHandler $orderTransactionStateHandler, OrderTransactionStateHandler $orderTransactionStateHandler,
SalesChannelContextService $salesChannelContextService, SalesChannelContextServiceInterface $salesChannelContextService,
EntityRepository $orderTransactionRepository, EntityRepository $orderTransactionRepository,
?EntityRepository $subscriptionRepository, ?EntityRepository $subscriptionRepository,
) { ) {
@@ -119,15 +121,14 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
PaymentTransactionStruct $transaction, PaymentTransactionStruct $transaction,
Context $context, Context $context,
?Struct $validateStruct ?Struct $validateStruct
): RedirectResponse ): RedirectResponse {
{
try { try {
$orderTransactionId = $transaction->getOrderTransactionId(); $orderTransactionId = $transaction->getOrderTransactionId();
$orderTransaction = $this->orderTransactionRepository->search( $orderTransaction = $this->orderTransactionRepository->search(
(new Criteria([$orderTransactionId])) (new Criteria([$orderTransactionId]))
->addAssociation('order') ->addAssociation('order')
->addAssociation('stateMachineState'), ->addAssociation('stateMachineState'),
$context $context
)->getEntities()->first(); )->getEntities()->first();
$contextSource = $context->getSource(); $contextSource = $context->getSource();
@@ -136,7 +137,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
} }
$contextToken = $this->getContextToken($request); $contextToken = $this->getContextToken($request);
$parameters = new SalesChannelContextServiceParameters($salesChannelContextId, $contextToken, originalContext: $context); $parameters = new SalesChannelContextServiceParameters($salesChannelContextId, $contextToken, languageId: $context->getLanguageId(), originalContext: $context);
$salesChannelContext = $this->salesChannelContextService->get($parameters); $salesChannelContext = $this->salesChannelContextService->get($parameters);
$redirectUrl = $transaction->getReturnUrl(); $redirectUrl = $transaction->getReturnUrl();
@@ -150,10 +151,18 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
} }
return new RedirectResponse($redirectUrl); return new RedirectResponse($redirectUrl);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$request->getSession()->remove('transactionId'); // Clear the transaction ID from the cache or session context depending on whether the SalesChannelContext was initialized
// to prevent subsequent checkout attempts from reusing a failed/invalid transaction ID.
if (isset($salesChannelContext)) {
$this->pluginTransactionService->clearTransactionIdFromContext($salesChannelContext);
} else {
$request->getSession()->remove('transactionId');
}
$errorMessage = 'An error occurred during the communication with external payment gateway : ' . $e->getMessage(); $errorMessage = 'An error occurred during the communication with external payment gateway : ' . $e->getMessage();
$this->logger->critical($errorMessage); $this->logger->critical($errorMessage);
if ($orderTransaction->getState()?->getTechnicalName() === OrderTransactionStates::STATE_CANCELLED) { // If the transaction has already been marked as cancelled (e.g. via webhook or concurrent request),
// throw an interrupted exception to signal that payment processing cannot be finalized normally.
if ($orderTransaction->getStateMachineState()?->getTechnicalName() === OrderTransactionStates::STATE_CANCELLED) {
throw PaymentException::asyncFinalizeInterrupted($orderTransaction->getOrder()->getId(), $errorMessage); throw PaymentException::asyncFinalizeInterrupted($orderTransaction->getOrder()->getId(), $errorMessage);
} }
throw PaymentException::customerCanceled($transaction->getOrderTransactionId(), $errorMessage); throw PaymentException::customerCanceled($transaction->getOrderTransactionId(), $errorMessage);
@@ -175,14 +184,13 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
Request $request, Request $request,
PaymentTransactionStruct $transaction, PaymentTransactionStruct $transaction,
Context $context Context $context
): void ): void {
{
$orderTransactionId = $transaction->getOrderTransactionId(); $orderTransactionId = $transaction->getOrderTransactionId();
$orderTransaction = $this->orderTransactionRepository->search( $orderTransaction = $this->orderTransactionRepository->search(
(new Criteria([$orderTransactionId])) (new Criteria([$orderTransactionId]))
->addAssociation('order') ->addAssociation('order')
->addAssociation('stateMachineState'), ->addAssociation('stateMachineState'),
$context $context
)->getEntities()->first(); )->getEntities()->first();
if ($orderTransaction->getOrder()->getAmountTotal() > 0) { if ($orderTransaction->getOrder()->getAmountTotal() > 0) {
@@ -196,12 +204,27 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
$transactionEntity->getSalesChannelId() $transactionEntity->getSalesChannelId()
); );
if (in_array($vRPaymentTransaction->getState(), [TransactionState::FAILED])) { if (in_array($vRPaymentTransaction->getState(), [TransactionState::FAILED, TransactionState::DECLINE, TransactionState::VOIDED,])) {
$errorMessage = strtr('Customer canceled payment for :orderId on SalesChannel :salesChannelName', [ $errorMessage = strtr('Customer canceled payment for :orderId on SalesChannel :salesChannelName', [
':orderId' => $orderTransaction->getOrder()->getId(), ':orderId' => $orderTransaction->getOrder()->getId(),
':salesChannelName' => $transactionEntity->getSalesChannelId(), ':salesChannelName' => $transactionEntity->getSalesChannelId(),
]); ]);
$request->getSession()->remove('transactionId');
// Retrieve the sales channel context parameters to clear the transaction ID from cache or session context,
// ensuring that a failed transaction is not reused when the customer retries payment.
$token = $this->getContextToken($request);
if ($token) {
$parameters = new SalesChannelContextServiceParameters(
$transactionEntity->getSalesChannelId(),
$token,
originalContext: $context,
);
$salesChannelContext = $this->salesChannelContextService->get($parameters);
$this->pluginTransactionService->clearTransactionIdFromContext($salesChannelContext);
} else {
$request->getSession()->remove('transactionId');
}
$this->logger->info($errorMessage); $this->logger->info($errorMessage);
throw PaymentException::customerCanceled($orderTransactionId, $errorMessage); throw PaymentException::customerCanceled($orderTransactionId, $errorMessage);
} }
@@ -209,7 +232,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
$this->orderTransactionStateHandler->paid($orderTransaction->getId(), $context); $this->orderTransactionStateHandler->paid($orderTransaction->getId(), $context);
} }
$token = $request->getSession()->get('sw-context-token'); $token = $this->getContextToken($request);
if ($token) { if ($token) {
$orderEntity = $this->pluginTransactionService->getOrderEntity( $orderEntity = $this->pluginTransactionService->getOrderEntity(
$orderTransaction->getOrder()->getId(), $orderTransaction->getOrder()->getId(),
@@ -222,6 +245,24 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
$salesChannelContext->getContext()->addState('do-cart-delete'); $salesChannelContext->getContext()->addState('do-cart-delete');
$this->logger->info('Clearing cart with token: ' . $token); $this->logger->info('Clearing cart with token: ' . $token);
$this->cartPersister->delete($salesChannelContext->getToken(), $salesChannelContext); $this->cartPersister->delete($salesChannelContext->getToken(), $salesChannelContext);
} else {
// Fallback: Try to get token from transaction custom fields (for Headless redirects)
$customFields = $orderTransaction->getCustomFields() ?? [];
$storedToken = $customFields[TransactionPayload::ORDER_TRANSACTION_CUSTOM_FIELDS_VRPAYMENT_TOKEN] ?? null;
if ($storedToken) {
$this->logger->info('Clearing cart with stored token: ' . $storedToken);
$orderEntity = $this->pluginTransactionService->getOrderEntity(
$orderTransaction->getOrder()->getId(),
$context
);
$salesChannelId = $orderEntity->getSalesChannelId();
$parameters = new SalesChannelContextServiceParameters($salesChannelId, $storedToken, originalContext: $context);
$salesChannelContext = $this->salesChannelContextService->get($parameters);
$salesChannelContext->getContext()->addState('do-cart-delete');
$this->cartPersister->delete($salesChannelContext->getToken(), $salesChannelContext);
}
} }
} }
@@ -232,7 +273,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
PaymentHandlerType $type, PaymentHandlerType $type,
string $paymentMethodId, string $paymentMethodId,
Context $context Context $context
): bool { ): bool {
// Both PaymentHandlerType::RECURRING and PaymentHandlerType::REFUND are supported // Both PaymentHandlerType::RECURRING and PaymentHandlerType::REFUND are supported
//TODO: check that the payment method really supports recurring. //TODO: check that the payment method really supports recurring.
// In order to do that, we need to get this information in when synching the payment methods. // In order to do that, we need to get this information in when synching the payment methods.
@@ -284,7 +325,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
throw PaymentException::recurringInterrupted($newTransactionId, 'No orders found associated with the subscription.'); throw PaymentException::recurringInterrupted($newTransactionId, 'No orders found associated with the subscription.');
} }
$orders->sort(fn (OrderEntity $a, OrderEntity $b) => $a->getCreatedAt() <=> $b->getCreatedAt()); $orders->sort(fn(OrderEntity $a, OrderEntity $b) => $a->getCreatedAt() <=> $b->getCreatedAt());
/** @var OrderEntity|null $originalOrder */ /** @var OrderEntity|null $originalOrder */
$originalOrder = $orders->first(); $originalOrder = $orders->first();
@@ -296,7 +337,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
/** @var OrderTransactionEntity|null $originalTransaction */ /** @var OrderTransactionEntity|null $originalTransaction */
$originalTransaction = $originalTransactions->filter( $originalTransaction = $originalTransactions->filter(
fn (OrderTransactionEntity $t) => $t->getStateMachineState()?->getTechnicalName() === OrderTransactionStates::STATE_PAID fn(OrderTransactionEntity $t) => $t->getStateMachineState()?->getTechnicalName() === OrderTransactionStates::STATE_PAID
)->first(); )->first();
if ($originalTransaction === null) { if ($originalTransaction === null) {
@@ -305,7 +346,8 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
$newOrderTransaction = $this->orderTransactionRepository->search( $newOrderTransaction = $this->orderTransactionRepository->search(
(new Criteria([$newTransactionId])) (new Criteria([$newTransactionId]))
->addAssociation('order'), $context ->addAssociation('order'),
$context
)->getEntities()->first(); )->getEntities()->first();
$orderNumber = $newOrderTransaction->getOrder()->getOrderNumber(); $orderNumber = $newOrderTransaction->getOrder()->getOrderNumber();
@@ -378,8 +420,7 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
// Update the new order transaction with the new transaction details // Update the new order transaction with the new transaction details
$this->orderTransactionRepository->update([$data], $context); $this->orderTransactionRepository->update([$data], $context);
$this->pluginTransactionService->upsert($newSdkTransaction, $context); $this->pluginTransactionService->upsert($newSdkTransaction, $context);
} } catch (\Throwable $e) {
catch (\Throwable $e) {
$errorMessage = 'An error occurred during the communication with external payment gateway : ' . $e->getMessage(); $errorMessage = 'An error occurred during the communication with external payment gateway : ' . $e->getMessage();
$this->logger->critical($errorMessage); $this->logger->critical($errorMessage);
throw PaymentException::recurringInterrupted($transaction->getOrderTransactionId(), $errorMessage); throw PaymentException::recurringInterrupted($transaction->getOrderTransactionId(), $errorMessage);
@@ -392,7 +433,8 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
* @param \VRPayment\Sdk\Model\Address $address The address model from the SDK. * @param \VRPayment\Sdk\Model\Address $address The address model from the SDK.
* @return \VRPayment\Sdk\Model\AddressCreate The newly created AddressCreate instance. * @return \VRPayment\Sdk\Model\AddressCreate The newly created AddressCreate instance.
*/ */
private function addressCreateFromSdk(\VRPayment\Sdk\Model\Address $address): \VRPayment\Sdk\Model\AddressCreate { private function addressCreateFromSdk(\VRPayment\Sdk\Model\Address $address): \VRPayment\Sdk\Model\AddressCreate
{
$addressCreate = new \VRPayment\Sdk\Model\AddressCreate; $addressCreate = new \VRPayment\Sdk\Model\AddressCreate;
$addressCreate->setCity($address->getCity()); $addressCreate->setCity($address->getCity());
@@ -473,16 +515,17 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
} }
/** /**
* @param $amount * @param $amount
* @param int $precision * @param int $precision
* *
* @return float * @return float
*/ */
private function round($value, $precision = 2): float { private function round($value, $precision = 2): float
{
return \round($value, $precision); return \round($value, $precision);
} }
private function getContextToken(Request $request): string private function getContextToken(Request $request): ?string
{ {
$headerContextToken = $request->headers->get('sw-context-token'); $headerContextToken = $request->headers->get('sw-context-token');
if ($headerContextToken) { if ($headerContextToken) {
@@ -490,9 +533,10 @@ class VRPaymentPaymentHandler extends AbstractPaymentHandler
} }
$sessionContextToken = $request->getSession()->get("sw-context-token"); $sessionContextToken = $request->getSession()->get("sw-context-token");
if (!$sessionContextToken) { if ($sessionContextToken) {
return $sessionContextToken; return $sessionContextToken;
} }
return Random::getAlphanumericString(32);
return null;
} }
} }
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\PaymentMethod\SalesChannel;
use Shopware\Core\Checkout\Payment\PaymentMethodDefinition;
use Shopware\Core\Checkout\Payment\SalesChannel\AbstractPaymentMethodRoute;
use Shopware\Core\Checkout\Payment\SalesChannel\PaymentMethodRouteResponse;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VRPaymentPayment\Core\Checkout\Service\PaymentMethodFilterService;
#[Package('checkout')]
/**
* This decorator intercepts the Store API payment method route to apply WhitelabelMachineName-specific filtering.
* It ensures that only payment methods valid for the current transaction (given currency, amount, etc.) are shown.
*/
class PaymentMethodRouteDecorator extends AbstractPaymentMethodRoute
{
/**
* @var AbstractPaymentMethodRoute
* The original route being decorated.
*/
private AbstractPaymentMethodRoute $decorated;
/**
* @var PaymentMethodFilterService
* Service used to filter the payment methods based on WhitelabelMachineName API availability.
*/
private PaymentMethodFilterService $paymentMethodFilterService;
/**
* @param AbstractPaymentMethodRoute $decorated
* @param PaymentMethodFilterService $paymentMethodFilterService
*/
public function __construct(
AbstractPaymentMethodRoute $decorated,
PaymentMethodFilterService $paymentMethodFilterService
) {
$this->decorated = $decorated;
$this->paymentMethodFilterService = $paymentMethodFilterService;
}
/**
* Returns the decorated service.
*
* @return AbstractPaymentMethodRoute
*/
public function getDecorated(): AbstractPaymentMethodRoute
{
return $this->decorated;
}
/**
* Loads the payment methods and applies the WhitelabelMachineName filter to the result.
*
* @param Request $request
* @param SalesChannelContext $context
* @param Criteria $criteria
* @return PaymentMethodRouteResponse
*/
#[Route(
path: '/store-api/payment-method',
name: 'store-api.payment.method',
methods: ['GET', 'POST'],
defaults: ['_entity' => 'payment_method']
)]
public function load(Request $request, SalesChannelContext $context, Criteria $criteria): PaymentMethodRouteResponse
{
// Fetch the initial list of payment methods from the decorated service.
$response = $this->decorated->load($request, $context, $criteria);
$currentRoute = $request->attributes->get('_route');
if ($currentRoute === 'frontend.checkout.finish.page' || !$this->allowFilterPaymentMethods($request)) {
return $response;
}
$paymentMethods = $response->getPaymentMethods();
// Apply WhitelabelMachineName-specific filtering logic via the dedicated service.
$filteredCollection = $this->paymentMethodFilterService->filterPaymentMethods(
$paymentMethods,
$context
);
// Return the filtered results as a new response.
return new PaymentMethodRouteResponse(
new EntitySearchResult(
'payment_method',
(int)$filteredCollection->count(),
$filteredCollection,
null,
$criteria,
$context->getContext()
)
);
}
/**
* We prevent filtering methods unless onlyAvailable is true.
* This is because the filterPaymentMethods() function creates unnecessary pending transactions in the
* portal when logged-in users navigate between pages.
* The onlyAvailable flag applies rule-based filtering of payment methods and is usually true on checkout pages,
* so we apply filterPaymentMethods() only when relevant.
*
* @param Request $request
* @return bool
*/
private function allowFilterPaymentMethods(Request $request): bool
{
if ($request->query->getBoolean('onlyAvailable') || $request->request->getBoolean('onlyAvailable')) {
return true;
}
return false;
}
}
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Service;
use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\LineItemFactoryRegistry;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemCollection;
use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemEntity;
use Shopware\Core\Checkout\Order\OrderEntity;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\Request;
use VRPaymentPayment\Core\Util\Payload\CustomProducts\CustomProductsLineItemTypes;
/**
* This service handles the reconstruction of a Shopware cart from an existing order.
* It ensures that line items, including those from complex plugins like 'Customized Products',
* are correctly re-added to a fresh cart.
*/
class CartRecoveryService
{
/**
* @var CartService
* Shopware service for cart-related operations.
*/
private CartService $cartService;
/**
* @var LineItemFactoryRegistry
* Registry to create Shopware LineItems from raw data.
*/
private LineItemFactoryRegistry $lineItemFactoryRegistry;
/**
* @var EntityRepository
* Repository for accessing order data.
*/
private EntityRepository $orderRepository;
/**
* @var object|null
* Optional route service for adding customized products to the cart.
*/
private ?object $addCustomizedProductsRoute;
/**
* @param CartService $cartService
* @param LineItemFactoryRegistry $lineItemFactoryRegistry
* @param EntityRepository $orderRepository
* @param object|null $addCustomizedProductsRoute
*/
public function __construct(
CartService $cartService,
LineItemFactoryRegistry $lineItemFactoryRegistry,
EntityRepository $orderRepository,
?object $addCustomizedProductsRoute = null
) {
$this->cartService = $cartService;
$this->lineItemFactoryRegistry = $lineItemFactoryRegistry;
$this->orderRepository = $orderRepository;
$this->addCustomizedProductsRoute = $addCustomizedProductsRoute;
}
/**
* Recreates a cart based on the items in an existing order.
*
* @param OrderEntity $order The source order.
* @param SalesChannelContext $salesChannelContext The current sales channel context.
* @return Cart The newly created and populated cart.
*/
public function recreateCartFromOrder(OrderEntity $order, SalesChannelContext $salesChannelContext): Cart
{
// Start with a clean slate by deleting any existing cart for the current session.
$this->cartService->deleteCart($salesChannelContext);
$cart = $this->cartService->createNew($salesChannelContext->getToken());
$orderItems = $order->getLineItems();
if ($orderItems === null) {
return $cart;
}
// Special handling for Customized Products if the plugin logic is available.
if ($this->hasCustomProducts($orderItems) && $this->addCustomizedProductsRoute) {
$cart = $this->addCustomProducts($orderItems, $salesChannelContext, $cart);
}
/** @var OrderLineItemEntity $orderLineItemEntity */
foreach ($orderItems as $orderLineItemEntity) {
$type = (string)$orderLineItemEntity->getType();
// Skip child items and complex types that should have been handled by specialized logic.
if ($type !== CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT || $orderLineItemEntity->getParentId() !== null) {
continue;
}
// Create a standard product line item.
$lineItem = $this->lineItemFactoryRegistry->create([
'id' => $orderLineItemEntity->getId(),
'quantity' => (int)$orderLineItemEntity->getQuantity(),
'referencedId' => (string)$orderLineItemEntity->getReferencedId(),
'type' => $type,
], $salesChannelContext);
// Preserve payload data to ensure product options and other metadata are carried over.
$lineItemPayload = $orderLineItemEntity->getPayload();
if (!empty($lineItemPayload)) {
$lineItem->setPayload($lineItemPayload);
}
// Add the item to the cart.
$cart = $this->cartService->add($cart, $lineItem, $salesChannelContext);
}
return $cart;
}
/**
* Checks if the order contains any line items belonging to the Customized Products plugin.
*
* @param OrderLineItemCollection $orderItems The items in the order.
* @return bool True if customized products are present.
*/
private function hasCustomProducts(OrderLineItemCollection $orderItems): bool
{
/** @var OrderLineItemEntity $orderItem */
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
return true;
}
}
return false;
}
/**
* Specialized logic to re-add customized products to the cart via the plugin's own route.
*
* @param OrderLineItemCollection $orderItems All order items.
* @param SalesChannelContext $salesChannelContext Context.
* @param Cart $cart Current cart.
* @return Cart Updated cart.
*/
private function addCustomProducts(OrderLineItemCollection $orderItems, SalesChannelContext $salesChannelContext, Cart $cart): Cart
{
if (!$this->addCustomizedProductsRoute) {
return $cart;
}
/** @var OrderLineItemEntity $orderItem */
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() !== CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
continue;
}
// Find the main product associated with this customized product container.
$product = $this->getCustomProduct($orderItems, (string)$orderItem->getId());
if (!$product) continue;
// Gather the chosen options and their values.
$productOptions = $this->getCustomProductOptions($orderItems, (string)$orderItem->getId());
$optionValues = $this->getOptionValues($productOptions);
// Prepare the data bag for the specialized add-to-cart route.
$params = new RequestDataBag([
'customized-products-template' => new RequestDataBag([
'id' => (string)$orderItem->getReferencedId(),
'options' => new RequestDataBag($optionValues),
]),
]);
$request = new Request([], [
'lineItems' => [
(string)$product->getReferencedId() => [
'quantity' => (int)$orderItem->getQuantity(),
'id' => (string)$product->getReferencedId(),
'type' => CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT,
'referencedId' => (string)$product->getReferencedId(),
'stackable' => (bool)$orderItem->getStackable(),
'removable' => (bool)$orderItem->getRemovable(),
]
]
]);
// Call the Customized Products plugin's internal logic to add the item with its complex configuration.
$this->addCustomizedProductsRoute->add($params, $request, $salesChannelContext, $cart);
// Re-fetch the cart to reflect changes made by the plugin route.
$cart = $this->cartService->getCart($salesChannelContext->getToken(), $salesChannelContext);
}
return $cart;
}
/**
* Finds the main product item within a customized product structure.
*
* @param OrderLineItemCollection $orderItems All order items.
* @param string $parentId The ID of the customized product container.
* @return OrderLineItemEntity|null The product line item entity.
*/
private function getCustomProduct(OrderLineItemCollection $orderItems, string $parentId): ?OrderLineItemEntity
{
/** @var OrderLineItemEntity $orderItem */
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT && $orderItem->getParentId() === $parentId) {
return $orderItem;
}
}
return null;
}
/**
* Gathers all option items for a customized product.
*
* @param OrderLineItemCollection $orderItems All items.
* @param string $parentId The ID of the customized product container.
* @return OrderLineItemEntity[] List of option entities.
*/
private function getCustomProductOptions(OrderLineItemCollection $orderItems, string $parentId): array
{
$options = [];
/** @var OrderLineItemEntity $orderItem */
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS_OPTION && $orderItem->getParentId() === $parentId) {
$options[] = $orderItem;
}
}
return $options;
}
/**
* Converts a list of options into a data structure suitable for the Customized Products logic.
*
* @param OrderLineItemEntity[] $productOptions List of option entities.
* @return array Formatted option values.
*/
private function getOptionValues(array $productOptions): array
{
$optionValues = [];
foreach ($productOptions as $productOption) {
$payload = (array)$productOption->getPayload();
$optionType = (string)($payload['type'] ?? '');
switch ($optionType) {
case CustomProductsLineItemTypes::PRODUCT_OPTION_TYPE_IMAGE_UPLOAD:
case CustomProductsLineItemTypes::PRODUCT_OPTION_TYPE_FILE_UPLOAD:
$media = (array)($payload['media'] ?? []);
foreach ($media as $mediaItem) {
$optionValues[(string)$productOption->getReferencedId()] = new RequestDataBag([
'media' => new RequestDataBag([
(string)$mediaItem['filename'] => new RequestDataBag([
'id' => (string)$mediaItem['mediaId'],
'filename' => (string)$mediaItem['filename'],
]),
]),
]);
}
break;
default:
$optionValues[(string)$productOption->getReferencedId()] = new RequestDataBag([
'value' => (string)($payload['value'] ?? ''),
]);
}
}
return $optionValues;
}
/**
* Fetches a full order entity with necessary associations for recovery or display.
*
* @param string $orderId The ID of the order.
* @param Context $context The current system context.
* @return OrderEntity The order entity.
* @throws \Exception If the order is not found.
*/
public function getOrderEntity(string $orderId, Context $context): OrderEntity
{
$criteria = (new Criteria([$orderId]))
->addAssociation('lineItems.cover')
->addAssociation('transactions.paymentMethod')
->addAssociation('deliveries.shippingMethod');
/** @var OrderEntity|null $order */
$order = $this->orderRepository->search($criteria, $context)->get($orderId);
if (!$order) {
throw new \Exception('Order not found');
}
return $order;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Service;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use VRPaymentPayment\Core\Settings\Service\SettingsService;
/**
* This service provides methods for retrieving invoice documents associated with WhitelabelMachineName transactions.
* It abstracts the API calls needed to fetch invoice data from the WhitelabelMachineName platform.
*/
class InvoiceService
{
/**
* @var SettingsService
* The service used to access sales channel specific settings.
*/
protected SettingsService $settingsService;
/**
* @var TransactionService
* The service used to handle transaction-specific operations.
*/
protected TransactionService $transactionService;
/**
* @param SettingsService $settingsService
* @param TransactionService $transactionService
*/
public function __construct(
SettingsService $settingsService,
TransactionService $transactionService
) {
$this->settingsService = $settingsService;
$this->transactionService = $transactionService;
}
/**
* Fetches the invoice document metadata for a given order.
*
* @param string $orderId The Shopware order ID.
* @param SalesChannelContext $salesChannelContext The current context.
*
* @return object The invoice document metadata (instance of \VRPayment\Sdk\Model\RenderedDocument).
*/
public function getInvoiceDocument(string $orderId, SalesChannelContext $salesChannelContext): object
{
// Retrieve valid settings for the current sales channel.
$settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId());
// Fetch the local transaction entity associated with the order.
$transactionEntity = $this->transactionService->getByOrderId($orderId, $salesChannelContext->getContext());
// Perform the API call to WhitelabelMachineName to get the invoice document metadata.
return $settings->getApiClient()->getTransactionService()->getInvoiceDocument(
(int)$settings->getSpaceId(),
(int)$transactionEntity->getTransactionId()
);
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Service;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use VRPaymentPayment\Core\Settings\Options\Integration;
use VRPaymentPayment\Core\Settings\Service\SettingsService;
use VRPaymentPayment\Core\Checkout\Struct\PaymentConfigStruct;
use VRPaymentPayment\Core\Util\LocaleCodeProvider;
use VRPayment\Sdk\Model\TransactionState;
/**
* This service provides the consolidated payment configuration needed for the frontend.
* it handles the generation of JS URLs and provides integration parameters for both Storefront and headless clients.
*/
class PaymentIntegrationService
{
/**
* @var TransactionService
* Service to handle transaction-specific API calls.
*/
private TransactionService $transactionService;
/**
* @var SettingsService
* Service to retrieve sales channel specific settings.
*/
private SettingsService $settingsService;
/**
* @var TransactionManagementService
* Service to manage transaction state.
*/
private TransactionManagementService $transactionManagementService;
/**
* @var RouterInterface
* Shopware router for generating callback and redirect URLs.
*/
private RouterInterface $router;
/**
* @var LocaleCodeProvider
*/
private LocaleCodeProvider $localeCodeProvider;
/**
* @param TransactionService $transactionService
* @param SettingsService $settingsService
* @param TransactionManagementService $transactionManagementService
* @param RouterInterface $router
* @param LocaleCodeProvider $localeCodeProvider
*/
public function __construct(
TransactionService $transactionService,
SettingsService $settingsService,
TransactionManagementService $transactionManagementService,
RouterInterface $router,
LocaleCodeProvider $localeCodeProvider
) {
$this->transactionService = $transactionService;
$this->settingsService = $settingsService;
$this->transactionManagementService = $transactionManagementService;
$this->router = $router;
$this->localeCodeProvider = $localeCodeProvider;
}
/**
* Generates the payment configuration for a given transaction ID.
* This is used on the checkout confirm page before the order is created.
*
* @param int $transactionId The WhitelabelMachineName transaction ID.
* @param SalesChannelContext $salesChannelContext The context.
* @return PaymentConfigStruct The consolidated integration data.
*/
public function getConfigForTransaction(
int $transactionId,
SalesChannelContext $salesChannelContext
): PaymentConfigStruct {
$settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId());
// Fetch the transaction details from WhitelabelMachineName API.
$vrpaymentTransaction = $settings->getApiClient()->getTransactionService()->read(
$settings->getSpaceId(),
$transactionId
);
$localeCode = $this->localeCodeProvider->getLocaleCodeFromContext($salesChannelContext->getContext());
$paymentPageLocale = $this->localeCodeProvider->mapToPaymentPageLocale($localeCode);
$javascriptUrl = $this->getTransactionJavaScriptUrl($settings, $transactionId, $paymentPageLocale);
$possiblePaymentMethods = $settings->getApiClient()
->getTransactionService()
->fetchPaymentMethods(
$settings->getSpaceId(),
$transactionId,
$settings->getIntegration()
);
$cartRecreateUrl = $this->router->generate(
'frontend.checkout.cart.page',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
$checkoutUrl = $this->router->generate(
'frontend.checkout.confirm.page',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
return (new PaymentConfigStruct())
->setIntegration((string)$settings->getIntegration())
->setJavascriptUrl($javascriptUrl)
->setDeviceJavascriptUrl($this->getDeviceJavascriptUrl((int)$settings->getSpaceId()))
->setTransactionPossiblePaymentMethods($possiblePaymentMethods)
->setTransactionId($transactionId)
->setSpaceId((int)$settings->getSpaceId())
->setCartRecreateUrl($cartRecreateUrl)
->setCheckoutUrl($checkoutUrl);
}
/**
* Generates the payment configuration for a given order.
*
* @param string $orderId The Shopware order ID.
* @param SalesChannelContext $salesChannelContext The context.
* @param string|null $cartRecreateUrl Optional override for the cart recreation URL.
* @param string|null $checkoutUrl Optional override for the checkout confirmation URL.
* @return PaymentConfigStruct The consolidated integration data.
*/
public function getPaymentConfig(
string $orderId,
SalesChannelContext $salesChannelContext,
?string $cartRecreateUrl = null,
?string $checkoutUrl = null
): PaymentConfigStruct {
// Retrieve settings and the transaction entity for the order.
$settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId());
$transactionEntity = $this->transactionService->getByOrderId($orderId, $salesChannelContext->getContext());
// Default to storefront URLs if no overrides are provided.
if ($cartRecreateUrl === null) {
$cartRecreateUrl = $this->router->generate(
'frontend.vrpayment.checkout.recreate-cart',
['orderId' => $orderId],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
if ($checkoutUrl === null) {
$checkoutUrl = $this->router->generate(
'frontend.checkout.confirm.page',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
return $this->getConfigForTransaction((int)$transactionEntity->getTransactionId(), $salesChannelContext)
->setCartRecreateUrl($cartRecreateUrl)
->setCheckoutUrl($checkoutUrl);
}
/**
* Determines the JavaScript URL for the WhitelabelMachineName integration.
*
* @param mixed $settings The plugin settings.
* @param int $transactionId The transaction ID.
* @param string $paymentPageLocale The payment page locale.
* @return string The absolute URL to the JavaScript component.
*/
private function getTransactionJavaScriptUrl($settings, int $transactionId, string $paymentPageLocale = ''): string
{
$javascriptUrl = '';
switch ($settings->getIntegration()) {
case Integration::IFRAME:
$javascriptUrl = $settings->getApiClient()->getTransactionIframeService()
->javascriptUrl($settings->getSpaceId(), $transactionId);
break;
case Integration::LIGHTBOX:
$javascriptUrl = $settings->getApiClient()->getTransactionLightboxService()
->javascriptUrl($settings->getSpaceId(), $transactionId);
break;
}
if ($javascriptUrl && $paymentPageLocale) {
$separator = str_contains($javascriptUrl, '?') ? '&' : '?';
$javascriptUrl .= $separator . 'language=' . $paymentPageLocale;
}
return $javascriptUrl;
}
/**
* Generates the device tracking JavaScript URL.
*
* @param int $spaceId The WhitelabelMachineName space ID.
* @return string The tracking URL.
*/
private function getDeviceJavascriptUrl(int $spaceId): string
{
return 'https://gateway.vr-payment.de/s/' . $spaceId . '/payment/device.js?session=' . Uuid::randomHex();
}
}
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Service;
use Shopware\Core\Checkout\Payment\PaymentMethodCollection;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use VRPaymentPayment\Core\Checkout\PaymentHandler\VRPaymentPaymentHandler;
use VRPaymentPayment\Core\Settings\Service\SettingsService;
use VRPaymentPayment\Core\Util\PaymentMethodUtil;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Psr\Log\LoggerInterface;
/**
* This service centralizes the logic for filtering WhitelabelMachineName payment methods.
* It ensures that only valid and available payment methods are displayed to the customer,
* based on the current transaction state and configured settings.
*/
class PaymentMethodFilterService
{
/**
* @var PaymentMethodConfigurationService
* Service to handle WhitelabelMachineName payment method configurations.
*/
private PaymentMethodConfigurationService $paymentMethodConfigurationService;
/**
* @var TransactionService
* Service to manage WhitelabelMachineName transactions via API.
*/
private TransactionService $transactionService;
/**
* @var SettingsService
* Service to retrieve plugin settings.
*/
private SettingsService $settingsService;
/**
* @var PaymentMethodUtil
* Utility for payment method operations.
*/
private PaymentMethodUtil $paymentMethodUtil;
/**
* @var LoggerInterface
*/
private LoggerInterface $logger;
/**
* @var TransactionManagementService
* Service to manage transaction state consistency.
*/
private TransactionManagementService $transactionManagementService;
/**
* @var CartService
*/
private CartService $cartService;
/**
* @param SettingsService $settingsService
* @param TransactionService $transactionService
* @param PaymentMethodConfigurationService $paymentMethodConfigurationService
* @param PaymentMethodUtil $paymentMethodUtil
* @param TransactionManagementService $transactionManagementService
* @param CartService $cartService
*/
public function __construct(
SettingsService $settingsService,
TransactionService $transactionService,
PaymentMethodConfigurationService $paymentMethodConfigurationService,
PaymentMethodUtil $paymentMethodUtil,
TransactionManagementService $transactionManagementService,
CartService $cartService
) {
$this->settingsService = $settingsService;
$this->transactionService = $transactionService;
$this->paymentMethodConfigurationService = $paymentMethodConfigurationService;
$this->paymentMethodUtil = $paymentMethodUtil;
$this->transactionManagementService = $transactionManagementService;
$this->cartService = $cartService;
}
/**
* @param LoggerInterface $logger
*/
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
/**
* Filters the given collection of payment methods based on WhitelabelMachineName's availability logic.
*
* @param PaymentMethodCollection $paymentMethodCollection The initial collection of payment methods.
* @param SalesChannelContext $salesChannelContext The current sales channel context.
* @param mixed $event Optional event that triggered the filtering.
* @return PaymentMethodCollection The filtered collection of payment methods.
*/
public function filterPaymentMethods(
PaymentMethodCollection $paymentMethodCollection,
SalesChannelContext $salesChannelContext,
$event = null
): PaymentMethodCollection {
// Fetch valid settings for the current sales channel.
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
// If settings are missing, remove all WhitelabelMachineName payment methods to prevent incorrect behavior.
if (is_null($settings)) {
return $this->removeVRPaymentPaymentMethods($paymentMethodCollection, $salesChannelContext);
}
// If there is no customer, we cannot create a transaction or perform API-based filtering.
// This typically happens on non-checkout pages like the frontpage footer.
if ($salesChannelContext->getCustomer() === null) {
return $paymentMethodCollection;
}
$source = $event;
if ($source === null) {
// In headless (Store API) flow, event is null. We explicitly fetch the cart to get line items.
$source = $this->cartService->getCart($salesChannelContext->getToken(), $salesChannelContext);
}
// Ensure a pending transaction exists in WhitelabelMachineName for correct filtering,
// using the transaction management service for state consistency.
$createdTransactionId = $this->transactionManagementService->getOrCreatePendingTransaction($salesChannelContext, $source);
// Update the temporary transaction if customer data has changed.
$this->transactionManagementService->updateTempTransactionIfNeeded($salesChannelContext, $createdTransactionId, $source);
// Fetch available payment method IDs from WhitelabelMachineName API for this transaction.
$allowedIds = $this->fetchAvailablePaymentMethodIds($settings, $createdTransactionId, $salesChannelContext);
// Return a new collection containing only allowed methods.
return $this->buildFilteredCollection($paymentMethodCollection, $allowedIds, $settings->getSpaceId(), $salesChannelContext);
}
/**
* Removes all WhitelabelMachineName-related payment methods from the collection.
*
* @param PaymentMethodCollection $paymentMethodCollection The collection to clean.
* @param SalesChannelContext $salesChannelContext The context.
* @return PaymentMethodCollection The cleaned collection.
*/
private function removeVRPaymentPaymentMethods(
PaymentMethodCollection $paymentMethodCollection,
SalesChannelContext $salesChannelContext
): PaymentMethodCollection {
$paymentMethodIds = $this->paymentMethodUtil->getVRPaymentPaymentMethodIds($salesChannelContext->getContext());
foreach ($paymentMethodIds as $paymentMethodId) {
$paymentMethodCollection->remove($paymentMethodId);
}
return $paymentMethodCollection;
}
/**
* Fetches the list of allowed payment method IDs from the WhitelabelMachineName API.
*
* @param mixed $settings The plugin settings.
* @param int $createdTransactionId The WhitelabelMachineName transaction ID.
* @param SalesChannelContext $salesChannelContext The context.
* @return string[] Array of allowed payment method configuration IDs.
*/
private function fetchAvailablePaymentMethodIds(
$settings,
int $createdTransactionId,
SalesChannelContext $salesChannelContext
): array {
$transactionService = $settings->getApiClient()->getTransactionService();
$possiblePaymentMethods = $transactionService->fetchPaymentMethods(
$settings->getSpaceId(),
$createdTransactionId,
$settings->getIntegration()
);
$arrayOfPossibleMethods = [];
foreach ($possiblePaymentMethods as $possiblePaymentMethod) {
$arrayOfPossibleMethods[] = (string) $possiblePaymentMethod->getId();
}
// Store the allowed IDs in context extension for later use.
$salesChannelContext->getContext()->addExtension(
'possibleMethods',
new ArrayEntity(['ids' => $arrayOfPossibleMethods])
);
return $arrayOfPossibleMethods;
}
/**
* Builds a filtered PaymentMethodCollection based on allowed IDs.
*
* Filters the original collection (which already has Shopware's availability rules applied)
* to only include WhitelabelMachineName methods that are also allowed by the API.
* Non-WhitelabelMachineName methods are kept as-is.
*
* @param PaymentMethodCollection $paymentMethodCollection Original collection (already rule-filtered by Shopware).
* @param string[] $allowedIds List of allowed configuration IDs from the WhitelabelMachineName API.
* @param int $spaceId WhitelabelMachineName space ID.
* @param SalesChannelContext $salesChannelContext The context.
* @return PaymentMethodCollection The final collection.
*/
private function buildFilteredCollection(
PaymentMethodCollection $paymentMethodCollection,
array $allowedIds,
int $spaceId,
SalesChannelContext $salesChannelContext
): PaymentMethodCollection {
// Fetch all WhitelabelMachineName payment method configurations for the space.
$paymentMethodConfigurations = $this->paymentMethodConfigurationService
->getAllPaymentMethodConfigurations($spaceId, $salesChannelContext->getContext());
// Build a map of Shopware payment method ID => configuration for methods allowed by the API.
$allowedWLConfigByPmId = [];
foreach ($paymentMethodConfigurations as $paymentMethodConfiguration) {
if ($paymentMethodConfiguration->getPaymentMethod() === null) {
continue;
}
$pmId = $paymentMethodConfiguration->getPaymentMethod()->getId();
$pmConfigId = (string) $paymentMethodConfiguration->getPaymentMethodConfigurationId();
if (
$paymentMethodConfiguration->getSpaceId() === $spaceId
&& \in_array($pmConfigId, $allowedIds, true)
) {
$allowedWLConfigByPmId[$pmId] = $paymentMethodConfiguration;
}
}
// Filter the original collection to preserve Shopware's availability rule filtering.
// Non-WLM methods pass through unchanged; WLM methods are kept only if allowed by the API.
$collection = new PaymentMethodCollection();
foreach ($paymentMethodCollection as $method) {
$isVRPaymentPM = VRPaymentPaymentHandler::class === $method->getHandlerIdentifier();
if (!$isVRPaymentPM) {
$collection->add($method);
continue;
}
if (isset($allowedWLConfigByPmId[$method->getId()])) {
$method->addExtension('vrpayment_config', $allowedWLConfigByPmId[$method->getId()]);
$collection->add($method);
}
}
$collection->sort(function ($a, $b) {
return ($a->getPosition() ?? 0) <=> ($b->getPosition() ?? 0);
});
return $collection;
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Service;
use Psr\Cache\CacheItemPoolInterface;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use VRPaymentPayment\Core\Settings\Service\SettingsService;
use VRPayment\Sdk\Model\TransactionState;
/**
* This service manages the lifecycle of WhitelabelMachineName transactions and their state within the Shopware context.
* It provides methods to retrieve, create, and update transactions while ensuring state consistency.
*/
class TransactionManagementService
{
/**
* @var TransactionService
* The service used to interact with the WhitelabelMachineName API for transaction operations.
*/
private TransactionService $transactionService;
/**
* @var SettingsService
* The service used to retrieve configuration settings for the current sales channel.
*/
private SettingsService $settingsService;
/**
* @var CacheItemPoolInterface
* Cache for headless transaction persistence
*/
private CacheItemPoolInterface $cache;
/**
* @param TransactionService $transactionService
* @param SettingsService $settingsService
* @param CacheItemPoolInterface $cache
*/
public function __construct(
TransactionService $transactionService,
SettingsService $settingsService,
CacheItemPoolInterface $cache
) {
$this->transactionService = $transactionService;
$this->settingsService = $settingsService;
$this->cache = $cache;
}
/**
* Retrieves an existing pending transaction ID from the context or creates a new one if necessary.
*
* @param SalesChannelContext $salesChannelContext The current sales channel context.
* @param mixed $event Optional event context.
* @return int The WhitelabelMachineName transaction ID.
* @throws \Exception If settings are not configured.
*/
public function getOrCreatePendingTransaction(SalesChannelContext $salesChannelContext, $event = null): int
{
// Try to get the transaction ID from the current context state.
$transactionId = $this->getTransactionIdFromContext($salesChannelContext);
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
if (!$settings) {
throw new \Exception('Space settings not configured');
}
$expiredTransaction = true;
if ($transactionId) {
try {
// Verify if the transaction still exists and is in a PENDING state.
$pendingTransaction = $this->transactionService->read($transactionId, (string)$salesChannelContext->getSalesChannel()->getId());
if ($pendingTransaction->getState() === TransactionState::PENDING) {
$expiredTransaction = false;
}
} catch (\Exception $e) {
// If the transaction cannot be read, we treat it as expired or invalid.
}
}
// Create a new transaction if none exists or the existing one is no longer valid.
if (!$transactionId || $expiredTransaction) {
$transactionId = (int)$this->transactionService->createPendingTransaction($salesChannelContext, $event);
$this->storeTransactionIdInContext($salesChannelContext, $transactionId);
}
return $transactionId;
}
/**
* Updates the WhitelabelMachineName transaction if the customer context (address or currency) or line items have changed.
*
* @param SalesChannelContext $salesChannelContext The current context.
* @param int $transactionId The WhitelabelMachineName transaction ID to update.
* @param mixed $event The event that triggered this update (optional).
*/
public function updateTempTransactionIfNeeded(SalesChannelContext $salesChannelContext, int $transactionId, $event = null): void
{
$ctx = $salesChannelContext->getContext();
/** @var ArrayEntity|null $ext */
$ext = $ctx->getExtension('checkoutState');
$oldAddressHash = $ext instanceof ArrayEntity ? (string)$ext->get('addressHash') : null;
$oldCurrency = $ext instanceof ArrayEntity ? (string)$ext->get('currency') : null;
$oldLineItemHash = $ext instanceof ArrayEntity ? (string)$ext->get('lineItemHash') : null;
$customer = $salesChannelContext->getCustomer();
$addressHash = $customer ? md5(json_encode((array) $customer)) : null;
$currency = (string)$salesChannelContext->getCurrency()->getIsoCode();
$lineItems = $this->transactionService->extractLineItems(
$event,
$salesChannelContext,
);
$lineItemHash = !empty($lineItems) ? md5(json_encode($lineItems)) : $oldLineItemHash;
$needsUpdate = ($oldAddressHash !== $addressHash)
|| ($oldCurrency !== $currency)
|| ($oldLineItemHash !== $lineItemHash);
if ($needsUpdate) {
// Update the transaction in WhitelabelMachineName to reflect current cart and customer data.
if ($transactionId) {
$this->transactionService->updateTempTransaction($salesChannelContext, $transactionId, $lineItems);
}
// Clear payment method cache as options might have changed due to address/currency change.
$ctx->addExtension('possibleMethods', new ArrayEntity(['ids' => []]));
// Persist the new state hash in the context.
$ctx->addExtension(
'checkoutState',
new ArrayEntity([
'transactionId' => $transactionId,
'addressHash' => $addressHash,
'currency' => $currency,
'lineItemHash' => $lineItemHash,
])
);
}
}
/**
* Retrieves the stored WhitelabelMachineName transaction ID from the context, cache, or session.
*
* @param SalesChannelContext $salesChannelContext The context.
* @return int|null The transaction ID if found, otherwise null.
*/
public function getTransactionIdFromContext(SalesChannelContext $salesChannelContext): ?int
{
/** @var ArrayEntity|null $ext */
$ext = $salesChannelContext->getContext()->getExtension('checkoutState');
if ($ext instanceof ArrayEntity && $ext->get('transactionId')) {
return (int) $ext->get('transactionId');
}
// Try to get from cache (headless support).
$customer = $salesChannelContext->getCustomer();
if ($customer) {
$cacheKey = 'vrpn_pending_transaction_id_customer_' . $customer->getId();
$item = $this->cache->getItem($cacheKey);
if ($item->isHit()) {
return (int) $item->get();
}
}
// Fallback to PHP session for traditional Storefront compatibility.
if (isset($_SESSION['transactionId'])) {
return (int) $_SESSION['transactionId'];
}
return null;
}
/**
* Persists the transaction ID in the context state, cache, and session.
*
* @param SalesChannelContext $salesChannelContext The context.
* @param int $transactionId The transaction ID to store.
*/
private function storeTransactionIdInContext(SalesChannelContext $salesChannelContext, int $transactionId): void
{
$ctx = $salesChannelContext->getContext();
/** @var ArrayEntity|null $ext */
$ext = $ctx->getExtension('checkoutState');
$data = $ext instanceof ArrayEntity ? $ext->all() : [];
$data['transactionId'] = $transactionId;
// Store in context extension for stateless (headless) support within the request.
$ctx->addExtension('checkoutState', new ArrayEntity($data));
// Store in cache for persistent headless support.
$customer = $salesChannelContext->getCustomer();
if ($customer) {
$cacheKey = 'vrpn_pending_transaction_id_customer_' . $customer->getId();
$item = $this->cache->getItem($cacheKey);
$item->set($transactionId);
$item->expiresAfter(7200);
$this->cache->save($item);
}
// Sync with PHP session for stateful Storefront support.
$_SESSION['transactionId'] = $transactionId;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\StoreApi\Route;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use VRPaymentPayment\Core\Checkout\Service\CartRecoveryService;
#[Package('checkout')]
#[Route(defaults: ['_routeScope' => ['store-api']])]
/**
* This Store API route allows headless clients to recreate a cart from an existing order.
* This is particularly useful for 'Try Again' scenarios if a payment fails.
*/
class CartRecoveryRoute
{
/**
* @var CartRecoveryService
* Service to handle the logic of reconstructing a cart from order items.
*/
private CartRecoveryService $cartRecoveryService;
/**
* @var TransactionService
* Service to clear and manage transactions.
*/
private TransactionService $transactionService;
/**
* @param CartRecoveryService $cartRecoveryService
* @param TransactionService $transactionService
*/
public function __construct(
CartRecoveryService $cartRecoveryService,
TransactionService $transactionService,
) {
$this->cartRecoveryService = $cartRecoveryService;
$this->transactionService = $transactionService;
}
/**
* Recreates a cart based on the provided order ID.
*
* @param string $orderId The ID of the order to recover the cart from.
* @param Request $request The incoming request.
* @param SalesChannelContext $context The current sales channel context.
* @return JsonResponse A JSON response containing either the new cart data or an error message.
*/
#[Route(
path: '/store-api/vrpayment/checkout/recreate-cart/{orderId}',
name: 'store-api.vrpayment.checkout.recreate-cart',
methods: ['POST']
)]
public function recreate(string $orderId, Request $request, SalesChannelContext $context): JsonResponse
{
try {
// Fetch the order entity.
$order = $this->cartRecoveryService->getOrderEntity($orderId, $context->getContext());
// Security check: ensure the order belongs to the current sales channel.
if ($order->getSalesChannelId() !== $context->getSalesChannelId()) {
return new JsonResponse(['error' => 'Sales channel mismatch'], 403);
}
// Clear the transaction ID from cache and session to prevent the subsequent
// checkout attempt from reusing a stale/failed transaction.
$this->transactionService->clearTransactionIdFromContext($context);
// Perform the cart reconstruction.
$cart = $this->cartRecoveryService->recreateCartFromOrder($order, $context);
// Return the reconstructed cart data.
return new JsonResponse($cart);
} catch (\Exception $e) {
// Handle any exceptions during the process.
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\StoreApi\Route;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VRPaymentPayment\Core\Checkout\Service\InvoiceService;
#[Package('checkout')]
#[Route(defaults: ['_routeScope' => ['store-api']])]
/**
* This Store API route provides access to invoice documents for headless clients.
*/
class InvoiceRoute
{
/**
* @var InvoiceService
* Service to handle the retrieval of invoice documents from WhitelabelMachineName.
*/
private InvoiceService $invoiceService;
/**
* @param InvoiceService $invoiceService
*/
public function __construct(InvoiceService $invoiceService)
{
$this->invoiceService = $invoiceService;
}
/**
* Fetches the invoice document metadata and content for a given order.
*
* @param string $orderId The ID of the order.
* @param Request $request The incoming request.
* @param SalesChannelContext $context The current sales channel context.
* @return JsonResponse A JSON response containing the invoice title, MIME type, and base64-encoded data.
*/
#[Route(
path: '/store-api/vrpayment/account/order/invoice/{orderId}',
name: 'store-api.vrpayment.account.order.invoice',
methods: ['GET']
)]
public function load(string $orderId, Request $request, SalesChannelContext $context): JsonResponse
{
try {
// Retrieve the invoice document via the dedicated service.
$invoice = $this->invoiceService->getInvoiceDocument($orderId, $context);
// Structure the response for headless consumption.
return new JsonResponse([
'title' => (string)$invoice->getTitle(),
'mimeType' => (string)$invoice->getMimeType(),
'data' => (string)$invoice->getData(), // Base64 encoded
]);
} catch (\Exception $e) {
// Handle errors (e.g., order not found or API failure).
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\StoreApi\Route;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService;
/**
* This route provides transaction-specific configuration for a given order.
* It is primarily used by headless clients to initialize the WhitelabelMachineName payment iframe or lightbox.
*/
#[Package('checkout')]
#[Route(defaults: ['_routeScope' => ['store-api']])]
class WhitelabelMachineNameTransactionInfoRoute
{
/**
* @var PaymentIntegrationService
* The service responsible for generating the unified payment configuration.
*/
private PaymentIntegrationService $paymentIntegrationService;
/**
* @param PaymentIntegrationService $paymentIntegrationService
*/
public function __construct(PaymentIntegrationService $paymentIntegrationService)
{
$this->paymentIntegrationService = $paymentIntegrationService;
}
/**
* Loads the payment configuration for a specific order.
*
* @param string $orderId The Shopware order ID.
* @param Request $request The incoming request object.
* @param SalesChannelContext $context The current sales channel context.
* @return JsonResponse JSON response containing the PaymentConfigStruct data.
*/
#[Route(
path: '/store-api/vrpayment/transaction/info/{orderId}',
name: 'store-api.vrpayment.transaction.info',
methods: ['GET']
)]
public function load(string $orderId, Request $request, SalesChannelContext $context): JsonResponse
{
try {
// Retrieve the payment configuration using the common service.
// This ensures logic parity between Storefront and Store API.
$config = $this->paymentIntegrationService->getPaymentConfig($orderId, $context);
// Return the configuration as a JSON response for the headless client.
return new JsonResponse($config);
} catch (\Exception $e) {
// In case of error, return a 400 response with the error message.
return new JsonResponse(['error' => $e->getMessage()], 400);
}
}
}
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Checkout\Struct;
use Shopware\Core\Framework\Struct\Struct;
/**
* This struct encapsulates all the configuration data required to initialize a WhitelabelMachineName payment integration.
*/
class PaymentConfigStruct extends Struct
{
/**
* @var string
* The type of integration (e.g., 'iframe' or 'lightbox').
*/
protected string $integration;
/**
* @var string
* The absolute URL to the WhitelabelMachineName JavaScript component for transaction handling.
*/
protected string $javascriptUrl;
/**
* @var string
* The absolute URL to the WhitelabelMachineName JavaScript component for device tracking.
*/
protected string $deviceJavascriptUrl;
/**
* @var array
* A list of possible payment methods for the current transaction.
*/
protected array $transactionPossiblePaymentMethods;
/**
* @var int
* The unique ID of the WhitelabelMachineName transaction.
*/
protected int $transactionId;
/**
* @var int
* The unique ID of the WhitelabelMachineName space.
*/
protected int $spaceId;
/**
* @var string|null
* The URL to redirect to if the cart needs to be recreated (e.g., after an error).
*/
protected ?string $cartRecreateUrl = null;
/**
* @var string|null
* The URL to the checkout confirmation page.
*/
protected ?string $checkoutUrl = null;
/**
* @return string
*/
public function getIntegration(): string
{
return $this->integration;
}
/**
* @param string $integration
* @return self
*/
public function setIntegration(string $integration): self
{
$this->integration = $integration;
return $this;
}
/**
* @return string
*/
public function getJavascriptUrl(): string
{
return $this->javascriptUrl;
}
/**
* @param string $javascriptUrl
* @return self
*/
public function setJavascriptUrl(string $javascriptUrl): self
{
$this->javascriptUrl = $javascriptUrl;
return $this;
}
/**
* @return string
*/
public function getDeviceJavascriptUrl(): string
{
return $this->deviceJavascriptUrl;
}
/**
* @param string $deviceJavascriptUrl
* @return self
*/
public function setDeviceJavascriptUrl(string $deviceJavascriptUrl): self
{
$this->deviceJavascriptUrl = $deviceJavascriptUrl;
return $this;
}
/**
* @return array
*/
public function getTransactionPossiblePaymentMethods(): array
{
return $this->transactionPossiblePaymentMethods;
}
/**
* @param array $transactionPossiblePaymentMethods
* @return self
*/
public function setTransactionPossiblePaymentMethods(array $transactionPossiblePaymentMethods): self
{
$this->transactionPossiblePaymentMethods = $transactionPossiblePaymentMethods;
return $this;
}
/**
* @return int
*/
public function getTransactionId(): int
{
return $this->transactionId;
}
/**
* @param int $transactionId
* @return self
*/
public function setTransactionId(int $transactionId): self
{
$this->transactionId = $transactionId;
return $this;
}
/**
* @return int
*/
public function getSpaceId(): int
{
return $this->spaceId;
}
/**
* @param int $spaceId
* @return self
*/
public function setSpaceId(int $spaceId): self
{
$this->spaceId = $spaceId;
return $this;
}
/**
* @return string|null
*/
public function getCartRecreateUrl(): ?string
{
return $this->cartRecreateUrl;
}
/**
* @param string|null $cartRecreateUrl
* @return self
*/
public function setCartRecreateUrl(?string $cartRecreateUrl): self
{
$this->cartRecreateUrl = $cartRecreateUrl;
return $this;
}
/**
* @return string|null
*/
public function getCheckoutUrl(): ?string
{
return $this->checkoutUrl;
}
/**
* @param string|null $checkoutUrl
* @return self
*/
public function setCheckoutUrl(?string $checkoutUrl): self
{
$this->checkoutUrl = $checkoutUrl;
return $this;
}
}
@@ -1,10 +1,12 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Storefront\Account\Controller; namespace VRPaymentPayment\Core\Storefront\Account\Controller;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shopware\Core\{ use Shopware\Core\{
Checkout\Cart\Exception\CustomerNotLoggedInException, Checkout\Cart\CartException,
Checkout\Customer\CustomerEntity, Checkout\Customer\CustomerEntity,
PlatformRequest, PlatformRequest,
System\SalesChannel\SalesChannelContext System\SalesChannel\SalesChannelContext
@@ -20,111 +22,134 @@ use Symfony\Component\{
}; };
use VRPaymentPayment\Core\{ use VRPaymentPayment\Core\{
Api\Transaction\Service\TransactionService, Api\Transaction\Service\TransactionService,
Settings\Service\SettingsService Checkout\Service\InvoiceService
}; };
#[Package('storefront')] #[Package('storefront')]
#[Route(defaults: ['_routeScope' => ['storefront']])] #[Route(defaults: ['_routeScope' => ['storefront']])]
/**
* This controller handles account-related actions for orders, specifically
* allowing customers to download their invoice documents.
*/
class AccountOrderController extends StorefrontController class AccountOrderController extends StorefrontController
{ {
/** /**
* @var \VRPaymentPayment\Core\Settings\Service\SettingsService * @var LoggerInterface
*/ */
protected $settingsService; protected LoggerInterface $logger;
/** /**
* @var \Psr\Log\LoggerInterface * @var TransactionService
*/ * Local transaction service for order data retrieval.
protected $logger; */
protected TransactionService $transactionService;
/**
* @var \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService /**
*/ * @var RequestStack
protected $transactionService; * Symfony service to access the current request context.
*/
/** protected RequestStack $requestStack;
* @var RequestStack
*/ /**
protected $requestStack; * @var InvoiceService
* Service to fetch invoice documents from WhitelabelMachineName.
/** */
* AccountOrderController constructor. private InvoiceService $invoiceService;
* @param \VRPaymentPayment\Core\Settings\Service\SettingsService $settingsService
* @param \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService $transactionService /**
* @param RequestStack $requestStack * @param TransactionService $transactionService
*/ * @param RequestStack $requestStack
public function __construct(SettingsService $settingsService, TransactionService $transactionService, RequestStack $requestStack) * @param InvoiceService $invoiceService
{ */
$this->settingsService = $settingsService; public function __construct(
$this->transactionService = $transactionService; TransactionService $transactionService,
$this->requestStack = $requestStack; RequestStack $requestStack,
InvoiceService $invoiceService
) {
$this->transactionService = $transactionService;
$this->requestStack = $requestStack;
$this->invoiceService = $invoiceService;
}
/**
* @param LoggerInterface $logger
*/
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
/**
* Downloads an invoice document for a specific order.
*
* @param string $orderId The ID of the order.
* @param SalesChannelContext $salesChannelContext The context.
* @return Response The PDF document as a download response.
*/
#[Route(
path: "/vrpayment/account/order/download/invoice/document/{orderId}",
name: "frontend.vrpayment.account.order.download.invoice.document",
methods: ['GET']
)]
public function downloadInvoiceDocument(string $orderId, SalesChannelContext $salesChannelContext): Response
{
try {
// Ensure the user is logged in.
$customer = $this->getLoggedInCustomer();
// Fetch the transaction entity to verify ownership.
$transactionEntity = $this->transactionService->getByOrderId($orderId, $salesChannelContext->getContext());
// Security check: ensure the document belongs to the logged-in customer.
if (strcasecmp((string)$customer->getCustomerNumber(), (string)$transactionEntity->getData()['customerId']) != 0) {
throw new AccessDeniedException();
}
// Retrieve the invoice document metadata and content.
/** @var object $invoiceDocument */
$invoiceDocument = $this->invoiceService->getInvoiceDocument($orderId, $salesChannelContext);
// Sanitize the filename for the download.
$filename = preg_replace('/[\x00-\x1F\x7F-\xFF]/', '_', (string)$invoiceDocument->getTitle()) . '.pdf';
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$filename,
$filename
);
// Create the response with the PDF content (base64 decoded).
$response = new Response(base64_decode((string)$invoiceDocument->getData()));
$response->headers->set('Content-Type', (string)$invoiceDocument->getMimeType());
$response->headers->set('Content-Disposition', $disposition);
return $response;
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
return $this->redirectToRoute('frontend.home.page');
} }
}
/**
* @param \Psr\Log\LoggerInterface $logger /**
* @internal * Helper to retrieve the currently logged-in customer.
* @required *
* * @return CustomerEntity
*/ * @throws CartException
public function setLogger(LoggerInterface $logger): void */
{ protected function getLoggedInCustomer(): CustomerEntity
$this->logger = $logger; {
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
throw CartException::customerNotLoggedIn();
} }
/** /** @var SalesChannelContext|null $context */
* Download invoice document $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
*
* @param string $orderId if ($context && $context->getCustomer() && $context->getCustomer()->getGuest() === false) {
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext return $context->getCustomer();
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \VRPayment\Sdk\ApiException
* @throws \VRPayment\Sdk\Http\ConnectionException
* @throws \VRPayment\Sdk\VersioningException
*/
#[Route("/vrpayment/account/order/download/invoice/document/{orderId}",
name: "frontend.vrpayment.account.order.download.invoice.document",
methods: ['GET'])]
public function downloadInvoiceDocument(string $orderId, SalesChannelContext $salesChannelContext): Response
{
$customer = $this->getLoggedInCustomer();
$settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId());
$transactionEntity = $this->transactionService->getByOrderId($orderId, $salesChannelContext->getContext());
if (strcasecmp($customer->getCustomerNumber(), $transactionEntity->getData()['customerId']) != 0) {
throw new AccessDeniedException();
}
$invoiceDocument = $settings->getApiClient()->getTransactionService()->getInvoiceDocument($settings->getSpaceId(), $transactionEntity->getTransactionId());
$forceDownload = true;
$filename = preg_replace('/[\x00-\x1F\x7F-\xFF]/', '_', $invoiceDocument->getTitle()) . '.pdf';
$disposition = HeaderUtils::makeDisposition(
$forceDownload ? HeaderUtils::DISPOSITION_ATTACHMENT : HeaderUtils::DISPOSITION_INLINE,
$filename,
$filename
);
$response = new Response(base64_decode($invoiceDocument->getData()));
$response->headers->set('Content-Type', $invoiceDocument->getMimeType());
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
/**
* @return CustomerEntity
*/
protected function getLoggedInCustomer(): CustomerEntity
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
throw new CustomerNotLoggedInException();
}
$context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
if ($context && $context->getCustomer() && $context->getCustomer()->getGuest() === false) {
return $context->getCustomer();
}
throw new CustomerNotLoggedInException();
} }
throw CartException::customerNotLoggedIn();
}
} }
@@ -1,568 +1,241 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Storefront\Checkout\Controller; namespace VRPaymentPayment\Core\Storefront\Checkout\Controller;
use VRPayment\Sdk\Model\TransactionState as SdkTransactionState;
use VRPaymentPayment\Core\Api\Transaction\Service\TransactionService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shopware\Core\{ use Shopware\Core\{
Checkout\Cart\Cart, Checkout\Cart\CartException,
Checkout\Cart\CartException, Checkout\Order\SalesChannel\AbstractOrderRoute,
Checkout\Cart\LineItemFactoryRegistry,
Checkout\Cart\SalesChannel\CartService,
Checkout\Order\Aggregate\OrderLineItem\OrderLineItemCollection,
Checkout\Order\Aggregate\OrderLineItem\OrderLineItemEntity,
Checkout\Order\OrderEntity,
Checkout\Order\SalesChannel\AbstractOrderRoute,
Framework\Context,
Framework\DataAbstractionLayer\Search\Criteria,
Framework\DataAbstractionLayer\Search\Filter\EqualsFilter,
Framework\DataAbstractionLayer\Search\Sorting\FieldSorting,
Framework\Log\Package, Framework\Log\Package,
Framework\Routing\Exception\MissingRequestParameterException, Framework\Routing\RoutingException,
Framework\Uuid\Uuid, Framework\DataAbstractionLayer\Search\Criteria,
Framework\Uuid\Exception\InvalidUuidException, System\SalesChannel\SalesChannelContext
Framework\Validation\DataBag\RequestDataBag,
System\SalesChannel\SalesChannelContext
}; };
use Shopware\Storefront\{ use Shopware\Storefront\{
Controller\StorefrontController, Controller\StorefrontController,
Page\Checkout\Finish\CheckoutFinishPage, Page\Checkout\Finish\CheckoutFinishPage,
Page\GenericPageLoaderInterface Page\GenericPageLoaderInterface
}; };
use Symfony\Component\{ use Symfony\Component\{
HttpFoundation\Request, HttpFoundation\Request,
HttpFoundation\Response, HttpFoundation\Response,
Routing\Attribute\Route, Routing\Attribute\Route
Routing\Generator\UrlGeneratorInterface
};
use VRPayment\Sdk\{
Model\Transaction,
Model\TransactionState
}; };
use VRPaymentPayment\Core\{ use VRPaymentPayment\Core\{
Api\Transaction\Service\TransactionService, Checkout\Service\CartRecoveryService,
Settings\Options\Integration, Checkout\Service\PaymentIntegrationService,
Settings\Service\SettingsService, Settings\Service\SettingsService
Storefront\Checkout\Struct\CheckoutPageData,
Util\Payload\CustomProducts\CustomProductsLineItemTypes
}; };
/**
* Class CheckoutController
*
* @package VRPaymentPayment\Core\Storefront\Checkout\Controller
*
*/
#[Package('checkout')] #[Package('checkout')]
#[Route(defaults: ['_routeScope' => ['storefront']])] #[Route(defaults: ['_routeScope' => ['storefront']])]
class CheckoutController extends StorefrontController { /**
* This controller handles Storefront-specific actions for the WhitelabelMachineName integration,
* such as rendering the payment page and recreating a cart from a failed order.
*/
class CheckoutController extends StorefrontController
{
/**
* @var GenericPageLoaderInterface
* Loader for basic Shopware page data.
*/
protected GenericPageLoaderInterface $genericLoader;
/** /**
* @var \Shopware\Storefront\Page\GenericPageLoader * @var SettingsService
*/ * Plugin settings service.
protected $genericLoader; */
protected SettingsService $settingsService;
/** /**
* @var \Shopware\Core\Checkout\Cart\SalesChannel\CartService * @var LoggerInterface|null
*/ * Logger for recording errors and important information.
protected $cartService; */
private ?LoggerInterface $logger = null;
/** /**
* @var \VRPaymentPayment\Core\Settings\Service\SettingsService * @var AbstractOrderRoute
*/ * Shopware service for order retrieval.
protected $settingsService; */
private AbstractOrderRoute $orderRoute;
/** /**
* @var \VRPaymentPayment\Core\Settings\Struct\Settings * @var CartRecoveryService
*/ * Service to help customers recover their cart from a past order.
protected $settings; */
private CartRecoveryService $cartRecoveryService;
/** /**
* @var \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService * @var PaymentIntegrationService
*/ * Service to provide the integration parameters (JS URL, transaction ID, etc.).
protected $transactionService; */
private PaymentIntegrationService $paymentIntegrationService;
/** /**
* @var \Psr\Log\LoggerInterface * @var TransactionService
*/ * Service to check transaction details.
private $logger; */
private TransactionService $transactionService;
/** /**
* @var \Shopware\Core\Checkout\Cart\LineItemFactoryRegistry * @param SettingsService $settingsService
*/ * @param GenericPageLoaderInterface $genericLoader
private $lineItemFactoryRegistry; * @param AbstractOrderRoute $orderRoute
* @param CartRecoveryService $cartRecoveryService
* @param PaymentIntegrationService $paymentIntegrationService
*/
public function __construct(
SettingsService $settingsService,
GenericPageLoaderInterface $genericLoader,
AbstractOrderRoute $orderRoute,
CartRecoveryService $cartRecoveryService,
PaymentIntegrationService $paymentIntegrationService,
TransactionService $transactionService
) {
$this->genericLoader = $genericLoader;
$this->settingsService = $settingsService;
$this->orderRoute = $orderRoute;
$this->cartRecoveryService = $cartRecoveryService;
$this->paymentIntegrationService = $paymentIntegrationService;
$this->transactionService = $transactionService;
}
/** /**
* @var \Shopware\Core\Checkout\Order\SalesChannel\AbstractOrderRoute * @param LoggerInterface $logger
*/ */
private $orderRoute; public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
/** /**
* PaymentController constructor. * Renders the WhitelabelMachineName payment page (usually contains the iframe or lightbox script).
* *
* @param \Shopware\Core\Checkout\Cart\LineItemFactoryRegistry $lineItemFactoryRegistry * @param SalesChannelContext $salesChannelContext The current context.
* @param \Shopware\Core\Checkout\Cart\SalesChannel\CartService $cartService * @param Request $request The incoming request.
* @param \VRPaymentPayment\Core\Settings\Service\SettingsService $settingsService * @return Response The rendered payment page.
* @param \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService $transactionService */
* @param \Shopware\Storefront\Page\GenericPageLoaderInterface $genericLoader
* @param \Shopware\Core\Checkout\Order\SalesChannel\AbstractOrderRoute $orderRoute
*/
public function __construct(
LineItemFactoryRegistry $lineItemFactoryRegistry,
CartService $cartService,
SettingsService $settingsService,
TransactionService $transactionService,
GenericPageLoaderInterface $genericLoader,
AbstractOrderRoute $orderRoute
)
{
$this->cartService = $cartService;
$this->genericLoader = $genericLoader;
$this->settingsService = $settingsService;
$this->transactionService = $transactionService;
$this->lineItemFactoryRegistry = $lineItemFactoryRegistry;
$this->orderRoute = $orderRoute;
}
/**
* @param \Psr\Log\LoggerInterface $logger
*
* @internal
* @required
*
*/
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
/**
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext
* @param \Symfony\Component\HttpFoundation\Request $request
*
* @return \Symfony\Component\HttpFoundation\Response
* @throws \VRPayment\Sdk\ApiException
* @throws \VRPayment\Sdk\Http\ConnectionException
* @throws \VRPayment\Sdk\VersioningException
*
*/
#[Route( #[Route(
path: "/vrpayment/checkout/pay", path: "/vrpayment/checkout/pay",
name: "frontend.vrpayment.checkout.pay", name: "frontend.vrpayment.checkout.pay",
options: ["seo" => false], options: ["seo" => false],
methods: ["GET"], methods: ["GET"],
)] )]
public function pay(SalesChannelContext $salesChannelContext, Request $request): Response public function pay(SalesChannelContext $salesChannelContext, Request $request): Response
{ {
$orderId = $request->query->get('orderId'); $orderId = (string)$request->query->get('orderId');
if (empty($orderId)) { if (empty($orderId)) {
throw new MissingRequestParameterException('orderId'); throw RoutingException::missingRequestParameter('orderId');
} }
// Configuration try {
$this->settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId()); // Load the order with necessary associations for the product table and addresses.
$criteria = new Criteria([$orderId]);
$criteria->addAssociation('lineItems.product')
->addAssociation('deliveries.shippingOrderAddress.country')
->addAssociation('orderCustomer.customer')
->addAssociation('transactions.paymentMethod');
$transaction = $this->getTransaction($orderId, $salesChannelContext->getContext()); $order = $this->orderRoute->load(new Request(), $salesChannelContext, $criteria)->getOrders()->first();
$recreateCartUrl = $this->generateUrl(
'frontend.vrpayment.checkout.recreate-cart',
['orderId' => $orderId,],
UrlGeneratorInterface::ABSOLUTE_URL
);
if (in_array( if (!$order) {
$transaction->getState(), throw RoutingException::missingRequestParameter('orderId');
[ }
TransactionState::AUTHORIZED,
TransactionState::COMPLETED,
TransactionState::FULFILL,
]
)) {
return $this->redirect($transaction->getSuccessUrl(), Response::HTTP_MOVED_PERMANENTLY);
} else {
if (in_array(
$transaction->getState(),
[
TransactionState::DECLINE,
TransactionState::FAILED,
TransactionState::VOIDED,
]
)) {
return $this->redirect($transaction->getFailedUrl(), Response::HTTP_MOVED_PERMANENTLY);
}
}
$possiblePaymentMethods = $this->settings->getApiClient() // Fetch the configuration required for the frontend integration.
->getTransactionService() $paymentConfig = $this->paymentIntegrationService->getPaymentConfig($orderId, $salesChannelContext);
->fetchPaymentMethods(
$this->settings->getSpaceId(),
$transaction->getId(),
$this->settings->getIntegration()
);
if (empty($possiblePaymentMethods)) { // Load a generic Shopware page to have layout headers/footers.
$this->addFlash('danger', $this->trans('vrpayment.paymentMethod.notAvailable')); $page = $this->genericLoader->load($request, $salesChannelContext);
return $this->redirect($recreateCartUrl, Response::HTTP_MOVED_PERMANENTLY); $page->addExtension('vRPaymentData', $paymentConfig);
}
$javascriptUrl = $this->getTransactionJavaScriptUrl($transaction->getId()); // Assign the order to the page so the templates can access page.order.
$page->assign(['order' => $order]);
// Set Checkout Page Data // Render the specialized Twig template for WhitelabelMachineName.
$checkoutPageData = (new CheckoutPageData()) return $this->renderStorefront(
->setIntegration($this->settings->getIntegration()) '@VRPaymentPayment/storefront/page/checkout/order/vrpayment.html.twig',
->setJavascriptUrl($javascriptUrl) ['page' => $page]
->setDeviceJavascriptUrl($this->settings->getSpaceId(), Uuid::randomHex()) );
->setTransactionPossiblePaymentMethods($possiblePaymentMethods) } catch (\Exception $e) {
->setCheckoutUrl($this->generateUrl( if ($this->logger) {
'frontend.vrpayment.checkout.pay', $this->logger->error($e->getMessage());
['orderId' => $orderId,], }
UrlGeneratorInterface::ABSOLUTE_URL // Clear the transaction ID from the cache/session context on error to ensure
)) // subsequent payment attempts will create/retrieve a clean transaction.
->setCartRecreateUrl($recreateCartUrl); $this->transactionService->clearTransactionIdFromContext($salesChannelContext);
$page = $this->load($request, $salesChannelContext); $this->addFlash('danger', $this->trans('vrpayment.paymentMethod.notAvailable'));
$page->addExtension('vRPaymentData', $checkoutPageData); return $this->redirectToRoute('frontend.home.page');
}
}
return $this->renderStorefront( /**
'@VRPaymentPayment/storefront/page/checkout/order/vrpayment.html.twig', * Redirects the user to a route that recreates their cart from an existing order.
['page' => $page] * This is useful for allowing users to try payment again with different details.
); *
} * @param Request $request The incoming request.
* @param SalesChannelContext $salesChannelContext The context.
/** * @return Response Redirect to the checkout confirmation page.
* Get transaction Javascript URL */
*
* @param int $transactionId
*
* @return string
* @throws \VRPayment\Sdk\ApiException
* @throws \VRPayment\Sdk\Http\ConnectionException
* @throws \VRPayment\Sdk\VersioningException
*/
private function getTransactionJavaScriptUrl(int $transactionId): string
{
$javascriptUrl = '';
switch ($this->settings->getIntegration()) {
case Integration::IFRAME:
$javascriptUrl = $this->settings->getApiClient()->getTransactionIframeService()
->javascriptUrl($this->settings->getSpaceId(), $transactionId);
break;
case Integration::LIGHTBOX:
$javascriptUrl = $this->settings->getApiClient()->getTransactionLightboxService()
->javascriptUrl($this->settings->getSpaceId(), $transactionId);
break;
default:
$this->logger->critical(strtr('invalid integration : :integration', [':integration' => $this->settings->getIntegration()]));
}
return $javascriptUrl;
}
/**
* @param $orderId
* @param \Shopware\Core\Framework\Context $context
*
* @return \VRPayment\Sdk\Model\Transaction
* @throws \VRPayment\Sdk\ApiException
* @throws \VRPayment\Sdk\Http\ConnectionException
* @throws \VRPayment\Sdk\VersioningException
*/
private function getTransaction($orderId, Context $context): Transaction
{
$transactionEntity = $this->transactionService->getByOrderId($orderId, $context);
return $this->settings->getApiClient()->getTransactionService()->read($this->settings->getSpaceId(), $transactionEntity->getTransactionId());
}
/**
* @param \Symfony\Component\HttpFoundation\Request $request
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext
*
* @return \Shopware\Storefront\Page\Checkout\Finish\CheckoutFinishPage
*/
protected function load(Request $request, SalesChannelContext $salesChannelContext): CheckoutFinishPage
{
$page = CheckoutFinishPage::createFrom($this->genericLoader->load($request, $salesChannelContext));
$page->setOrder($this->getOrder($request, $salesChannelContext));
return $page;
}
/**
* @param \Symfony\Component\HttpFoundation\Request $request
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext
*
* @return \Shopware\Core\Checkout\Order\OrderEntity
*/
private function getOrder(Request $request, SalesChannelContext $salesChannelContext): OrderEntity
{
$orderId = $request->get('orderId');
if (!$orderId) {
throw new MissingRequestParameterException('orderId', '/orderId');
}
$criteria = (new Criteria([$orderId]))
->addAssociation('lineItems.cover')
->addAssociation('transactions.paymentMethod')
->addAssociation('deliveries.shippingMethod');
$customer = $salesChannelContext->getCustomer();
if ($customer !== null) {
$criteria = $criteria->addFilter(new EqualsFilter('order.orderCustomer.customerId', $customer->getId()));
}
$criteria->getAssociation('transactions')->addSorting(new FieldSorting('createdAt'));
try {
$searchResult = $this->orderRoute
->load(new Request(), $salesChannelContext, $criteria)
->getOrders();
} catch (InvalidUuidException $e) {
throw CartException::orderNotFound($orderId);
}
/** @var OrderEntity|null $order */
$order = $searchResult->get($orderId);
if (!$order) {
throw CartException::orderNotFound($orderId);
}
return $order;
}
/**
* Recreate Cart
*
* @param \Symfony\Component\HttpFoundation\Request $request
* @param \Shopware\Core\System\SalesChannel\SalesChannelContext $salesChannelContext
*
* @return \Symfony\Component\HttpFoundation\Response
*
*/
#[Route( #[Route(
path: "/vrpayment/checkout/recreate-cart", path: "/vrpayment/checkout/recreate-cart",
name: "frontend.vrpayment.checkout.recreate-cart", name: "frontend.vrpayment.checkout.recreate-cart",
options: ["seo" => false], options: ["seo" => false],
methods: ["GET"], methods: ["GET"],
)] )]
public function recreateCart(Request $request, SalesChannelContext $salesChannelContext) public function recreateCart(Request $request, SalesChannelContext $salesChannelContext): Response
{ {
$orderId = $request->query->get('orderId'); $orderId = (string)$request->query->get('orderId');
if (empty($orderId)) { if (empty($orderId)) {
throw new MissingRequestParameterException('orderId'); throw RoutingException::missingRequestParameter('orderId');
} }
// Adoption for Headless Storefronts try {
$orderRepo = $this->container->get('order.repository'); // Find the order that should be recovered.
$criteria = new Criteria([$orderId]); $order = $this->cartRecoveryService->getOrderEntity($orderId, $salesChannelContext->getContext());
$orderEntity = $orderRepo->search($criteria, $salesChannelContext->getContext())->first(); // Security: Order must belong to the active sales channel.
if ($order->getSalesChannelId() !== $salesChannelContext->getSalesChannelId()) {
return $this->redirectToRoute('frontend.home.page');
}
if($orderEntity->getSalesChannelId() !== $salesChannelContext->getSalesChannelId()) { // Perform the recovery process.
$this->settings = $this->settingsService->getSettings($orderEntity->getSalesChannelId()); $transactionEntity = $this->transactionService->getByOrderId($order->getId(), $salesChannelContext->getContext());
$trans = $this->getTransaction($orderId, $salesChannelContext->getContext()); if ($transactionEntity) {
return $this->redirect($trans->getSuccessUrl()); $transaction = $this->transactionService->read($transactionEntity->getTransactionId(), $salesChannelContext->getSalesChannelId());
} if (in_array($transaction->getState(), [
// End Adoption for Headless Storefronts SdkTransactionState::AUTHORIZED,
SdkTransactionState::CONFIRMED,
SdkTransactionState::FULFILL
])) {
return $this->redirectToRoute('frontend.checkout.finish.page', ['orderId' => $orderId]);
}
try { if ($transaction->getUserFailureMessage()) {
$this->cartService->deleteCart($salesChannelContext); $this->addFlash('danger', $transaction->getUserFailureMessage());
$cart = $this->cartService->createNew($salesChannelContext->getToken()); }
}
// Clear the transaction ID from cache and session to prevent the subsequent
// checkout attempt from reusing a stale/failed transaction.
$this->transactionService->clearTransactionIdFromContext($salesChannelContext);
// Configuration $this->cartRecoveryService->recreateCartFromOrder($order, $salesChannelContext);
$this->settings = $this->settingsService->getSettings($salesChannelContext->getSalesChannel()->getId()); } catch (\Exception $exception) {
$orderEntity = $this->getOrder($request, $salesChannelContext); $this->addFlash('danger', $this->trans('error.addToCartError'));
$lastTransaction = $orderEntity->getTransactions()->last(); if ($this->logger) {
if ($lastTransaction && !$lastTransaction->getPaymentMethod()->getAfterOrderEnabled()) { $this->logger->critical($exception->getMessage());
return $this->redirectToRoute('frontend.home.page'); }
} return $this->redirectToRoute('frontend.home.page');
}
$transaction = $this->getTransaction($orderId, $salesChannelContext->getContext()); // Send the user back to the checkout confirm page with their items restored.
if (!empty($transaction->getUserFailureMessage())) { return $this->redirectToRoute('frontend.checkout.confirm.page');
$this->addFlash('danger', $transaction->getUserFailureMessage()); }
}
$orderItems = $orderEntity->getLineItems();
$hasCustomProducts = $this->hasCustomProducts($orderItems);
if ($hasCustomProducts === true) {
$cart = $this->addCustomProducts($orderItems, $request, $salesChannelContext);
}
foreach ($orderItems as $orderLineItemEntity) {
$type = $orderLineItemEntity->getType();
if ($type !== CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT || $orderLineItemEntity->getParentid() !== null) {
continue;
}
$lineItem = $this->lineItemFactoryRegistry->create([
'id' => $orderLineItemEntity->getId(),
'quantity' => $orderLineItemEntity->getQuantity(),
'referencedId' => $orderLineItemEntity->getReferencedId(),
'type' => $type,
], $salesChannelContext);
$lineItemPayload = $orderLineItemEntity->getPayload();
if (!empty($lineItemPayload)) {
$lineItem->setPayload($lineItemPayload);
}
$cart = $this->cartService->add($cart, $lineItem, $salesChannelContext);
}
} catch (\Exception $exception) {
$this->addFlash('danger', $this->trans('error.addToCartError'));
$this->logger->critical($exception->getMessage());
return $this->redirectToRoute('frontend.home.page');
}
return $this->redirectToRoute('frontend.checkout.confirm.page');
}
/**
* @param OrderLineItemCollection $orderItems
*
* @return bool
*/
private function hasCustomProducts(OrderLineItemCollection $orderItems): bool
{
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
return true;
}
}
return false;
}
/**
* @param OrderLineItemCollection $orderItems
* @param string $parentId
*
* @return OrderLineItemEntity|null
*/
private function getCustomProduct(OrderLineItemCollection $orderItems, string $parentId): ?OrderLineItemEntity
{
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT && $orderItem->getParentId() === $parentId) {
return $orderItem;
}
}
return null;
}
/**
* @param OrderLineItemCollection $orderItems
* @param string $parentId
*
* @return array
*/
private function getCustomProductOptions(OrderLineItemCollection $orderItems, string $parentId): array
{
$options = [];
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() === CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS_OPTION && $orderItem->getParentId() === $parentId) {
$options[] = $orderItem;
}
}
return $options;
}
/**
* @param $orderItems
* @param $request
* @param $salesChannelContext
*
* @return Cart
*/
private function addCustomProducts(OrderLineItemCollection $orderItems, Request $request, SalesChannelContext $salesChannelContext): Cart
{
$cart = $this->cartService->getCart($salesChannelContext->getToken(), $salesChannelContext);
if (!\class_exists('Swag\\CustomizedProducts\\Core\\Checkout\\Cart\\Route\\AddCustomizedProductsToCartRoute')) {
return $cart;
}
$customProductsService = $this->get('Swag\CustomizedProducts\Core\Checkout\Cart\Route\AddCustomizedProductsToCartRoute');
foreach ($orderItems as $orderItem) {
if ($orderItem->getType() !== CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS) {
continue;
}
$product = $this->getCustomProduct($orderItems, $orderItem->getId());
$productOptions = $this->getCustomProductOptions($orderItems, $orderItem->getId());
$optionValues = $this->getOptionValues($productOptions);
$params = new RequestDataBag([
'customized-products-template' => new RequestDataBag([
'id' => $orderItem->getReferencedId(),
'options' => new RequestDataBag($optionValues),
]),
]);
$request->request->add(
[
'lineItems' =>
[
$product->getProductId() =>
[
'quantity' => $orderItem->getQuantity(),
'id' => $product->getProductId(),
'type' => CustomProductsLineItemTypes::LINE_ITEM_TYPE_PRODUCT,
'referencedId' => $product->getReferencedId(),
'stackable' => $orderItem->getStackable(),
'removable' => $orderItem->getRemovable(),
]
]
]
);
$customProductsService->add($params, $request, $salesChannelContext, $cart);
$cart = $this->cartService->getCart($salesChannelContext->getToken(), $salesChannelContext);
}
return $cart;
}
/**
* @param array $productOptions
*
* @return array
*/
private function getOptionValues(array $productOptions): array
{
$optionValues = [];
foreach ($productOptions as $productOption) {
$optionType = $productOption->getPayload()['type'] ?: '';
switch ($optionType) {
case CustomProductsLineItemTypes::PRODUCT_OPTION_TYPE_IMAGE_UPLOAD:
case CustomProductsLineItemTypes::PRODUCT_OPTION_TYPE_FILE_UPLOAD:
$media = $productOption->getPayload()['media'] ?: [];
foreach ($media as $mediaItem) {
$optionValues[$productOption->getReferencedId()] = new RequestDataBag([
'media' => new RequestDataBag([
$mediaItem['filename'] => new RequestDataBag([
'id' => $mediaItem['mediaId'],
'filename' => $mediaItem['filename'],
]),
]),
]);
}
break;
default:
$optionValues[$productOption->getReferencedId()] = new RequestDataBag([
'value' => $productOption->getPayload()['value'] ?: '',
]);
}
}
return $optionValues;
}
} }
@@ -1,106 +1,70 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Storefront\Checkout\Subscriber; namespace VRPaymentPayment\Core\Storefront\Checkout\Subscriber;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shopware\Core\{Checkout\Order\Aggregate\OrderTransaction\OrderTransactionCollection, use Shopware\Core\{
Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates, Checkout\Order\Aggregate\OrderTransaction\OrderTransactionCollection,
Checkout\Order\OrderEntity, Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates,
Content\MailTemplate\Service\Event\MailBeforeValidateEvent}; Checkout\Order\OrderEntity,
Content\MailTemplate\Service\Event\MailBeforeValidateEvent
};
use Shopware\Core\Checkout\Payment\PaymentMethodCollection; use Shopware\Core\Checkout\Payment\PaymentMethodCollection;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\System\SalesChannel\SalesChannelContext; use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Storefront\Page\Account\Order\AccountEditOrderPageLoadedEvent; use Shopware\Storefront\Page\Account\Order\AccountEditOrderPageLoadedEvent;
use Shopware\Storefront\Page\Account\PaymentMethod\AccountPaymentMethodPageLoadedEvent; use Shopware\Storefront\Page\Account\PaymentMethod\AccountPaymentMethodPageLoadedEvent;
use Shopware\Storefront\Page\Checkout\Confirm\CheckoutConfirmPageLoadedEvent; use Shopware\Storefront\Page\Checkout\Confirm\CheckoutConfirmPageLoadedEvent;
use Shopware\Storefront\Page\Checkout\Finish\CheckoutFinishPageLoadedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use VRPaymentPayment\Core\{Api\Transaction\Service\TransactionService, use VRPaymentPayment\Core\Checkout\PaymentHandler\VRPaymentPaymentHandler;
Checkout\PaymentHandler\VRPaymentPaymentHandler, use VRPaymentPayment\Core\Settings\Service\SettingsService;
Settings\Service\SettingsService, use VRPaymentPayment\Core\Checkout\Service\PaymentMethodFilterService;
Settings\Struct\Settings, use VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService;
Util\PaymentMethodUtil};
use VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService;
use VRPaymentPayment\Sdk\{Model\AddressCreate,
Model\ChargeAttempt,
Model\CreationEntityState,
Model\CriteriaOperator,
Model\EntityQuery,
Model\EntityQueryFilter,
Model\EntityQueryFilterType,
Model\LineItemAttributeCreate,
Model\LineItemCreate,
Model\LineItemType,
Model\TaxCreate,
Model\Transaction,
Model\TransactionCreate,
Model\TransactionPending};
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
/** /**
* Class CheckoutSubscriber * This subscriber listens to page load events in the Storefront to filter out
* * WhitelabelMachineName payment methods that are not applicable for the current cart or customer.
* @package VRPaymentPayment\Storefront\Checkout\Subscriber
*/ */
class CheckoutSubscriber implements EventSubscriberInterface class CheckoutSubscriber implements EventSubscriberInterface
{ {
/**
* @var LoggerInterface
*/
protected LoggerInterface $logger;
/** /**
* @var \Psr\Log\LoggerInterface * @var SettingsService
*/ */
protected $logger; private SettingsService $settingsService;
/** /**
* @var \VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService * @var PaymentMethodFilterService
*/ */
private $paymentMethodConfigurationService; private PaymentMethodFilterService $paymentMethodFilterService;
/** /**
* @var \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService * @var PaymentIntegrationService
*/ */
private $transactionService; private PaymentIntegrationService $paymentIntegrationService;
/** /**
* @var \VRPaymentPayment\Core\Settings\Service\SettingsService * @param SettingsService $settingsService
* @param PaymentMethodFilterService $paymentMethodFilterService
* @param PaymentIntegrationService $paymentIntegrationService
*/ */
private $settingsService; public function __construct(
SettingsService $settingsService,
/** PaymentMethodFilterService $paymentMethodFilterService,
* @var \VRPaymentPayment\Core\Util\PaymentMethodUtil PaymentIntegrationService $paymentIntegrationService
*/ ) {
private $paymentMethodUtil; $this->settingsService = $settingsService;
$this->paymentMethodFilterService = $paymentMethodFilterService;
/** @var EntityRepository */ $this->paymentIntegrationService = $paymentIntegrationService;
private EntityRepository $paymentMethodRepository;
/**
* CheckoutSubscriber constructor.
*
* @param \VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService $paymentMethodConfigurationService
* @param \VRPaymentPayment\Core\Api\Transaction\Service\TransactionService $transactionService
* @param \VRPaymentPayment\Core\Settings\Service\SettingsService $settingsService
* @param \VRPaymentPayment\Core\Util\PaymentMethodUtil $paymentMethodUtil
* @param EntityRepository $paymentMethodRepository
*/
public function __construct(PaymentMethodConfigurationService $paymentMethodConfigurationService, TransactionService $transactionService, SettingsService $settingsService, PaymentMethodUtil $paymentMethodUtil, EntityRepository $paymentMethodRepository)
{
$this->paymentMethodConfigurationService = $paymentMethodConfigurationService;
$this->transactionService = $transactionService;
$this->settingsService = $settingsService;
$this->paymentMethodUtil = $paymentMethodUtil;
$this->paymentMethodRepository = $paymentMethodRepository;
} }
/** /**
* @param \Psr\Log\LoggerInterface $logger * @param LoggerInterface $logger
*
* @internal
* @required
*
*/ */
public function setLogger(LoggerInterface $logger): void public function setLogger(LoggerInterface $logger): void
{ {
@@ -108,38 +72,75 @@ class CheckoutSubscriber implements EventSubscriberInterface
} }
/** /**
* Register events to listen to.
*
* @return array * @return array
*/ */
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
{ {
return [ return [
CheckoutConfirmPageLoadedEvent::class => 'onCheckoutConfirmLoaded', CheckoutConfirmPageLoadedEvent::class => 'onPageLoaded',
AccountEditOrderPageLoadedEvent::class => 'onAccountOrderEditLoaded', AccountEditOrderPageLoadedEvent::class => 'onPageLoaded',
AccountPaymentMethodPageLoadedEvent::class => 'onAccountPaymentMethodLoaded', AccountPaymentMethodPageLoadedEvent::class => 'onPageLoaded',
"subscription." . CheckoutConfirmPageLoadedEvent::class => ['onConfirmPageLoaded', 1], "subscription." . CheckoutConfirmPageLoadedEvent::class => ['onPageLoaded', 1],
MailBeforeValidateEvent::class => ['onMailBeforeValidate', 1], MailBeforeValidateEvent::class => ['onMailBeforeValidate', 1],
]; ];
} }
/** /**
* Stop order emails being sent out * Handles filtering of payment methods when a relevant page is loaded.
* *
* @param \Shopware\Core\Content\MailTemplate\Service\Event\MailBeforeValidateEvent $event * @param mixed $event The page loaded event.
*/
public function onPageLoaded($event): void
{
try {
$salesChannelContext = $event->getSalesChannelContext();
// Access the payment methods available for the current page.
$paymentMethodCollection = $event->getPage()->getPaymentMethods();
// Delegate filtering to the centralized service.
$filteredCollection = $this->paymentMethodFilterService->filterPaymentMethods(
$paymentMethodCollection,
$salesChannelContext,
$event
);
// Update the page with the filtered list.
$event->getPage()->setPaymentMethods($filteredCollection);
// If we are on a checkout or account page and have a pending transaction, provide integration data.
$transactionId = $salesChannelContext->getContext()->getExtension('vrpayment_transaction_id');
if ($transactionId) {
$paymentConfig = $this->paymentIntegrationService->getConfigForTransaction(
(int) $transactionId->getVars()['value'],
$salesChannelContext
);
$event->getPage()->addExtension('vRPaymentData', $paymentConfig);
}
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
}
}
/**
* Handles logic before a mail is validated/sent.
*
* @param MailBeforeValidateEvent $event
*/ */
public function onMailBeforeValidate(MailBeforeValidateEvent $event): void public function onMailBeforeValidate(MailBeforeValidateEvent $event): void
{ {
$templateData = $event->getTemplateData(); $templateData = $event->getTemplateData();
/** /** @var OrderEntity|null $order */
* @var $order \Shopware\Core\Checkout\Order\OrderEntity
*/
$order = !empty($templateData['order']) && $templateData['order'] instanceof OrderEntity ? $templateData['order'] : null; $order = !empty($templateData['order']) && $templateData['order'] instanceof OrderEntity ? $templateData['order'] : null;
if (!empty($order) && $order->getAmountTotal() > 0) { if (!empty($order) && $order->getAmountTotal() > 0) {
// Check if WhitelabelMachineName emails are enabled for this sales channel.
$isVRPaymentEmailSettingEnabled = (bool)$this->settingsService->getSettings($order->getSalesChannelId())->isEmailEnabled();
$isVRPaymentEmailSettingEnabled = $this->settingsService->getSettings($order->getSalesChannelId())->isEmailEnabled(); if (!$isVRPaymentEmailSettingEnabled) {
if (!$isVRPaymentEmailSettingEnabled) { //setting is disabled
return; return;
} }
@@ -147,254 +148,30 @@ class CheckoutSubscriber implements EventSubscriberInterface
if (!($orderTransactions instanceof OrderTransactionCollection)) { if (!($orderTransactions instanceof OrderTransactionCollection)) {
return; return;
} }
$orderTransactionLast = $orderTransactions->last(); $orderTransactionLast = $orderTransactions->last();
if (empty($orderTransactionLast) || empty($orderTransactionLast->getPaymentMethod())) { // no payment method available if (empty($orderTransactionLast) || empty($orderTransactionLast->getPaymentMethod())) {
return; return;
} }
// Check if the payment method used belongs to this plugin.
$isVRPaymentPM = VRPaymentPaymentHandler::class == $orderTransactionLast->getPaymentMethod()->getHandlerIdentifier(); $isVRPaymentPM = VRPaymentPaymentHandler::class == $orderTransactionLast->getPaymentMethod()->getHandlerIdentifier();
if (!$isVRPaymentPM) { // not our payment method
return;
}
$isOrderTransactionStateOpen = in_array(
$orderTransactionLast->getStateMachineState()->getTechnicalName(), [
OrderTransactionStates::STATE_OPEN,
OrderTransactionStates::STATE_IN_PROGRESS,
]);
if (!$isOrderTransactionStateOpen) { // order payment status is open or in progress
return;
}
}
}
/**
* @param CheckoutConfirmPageLoadedEvent $event
* @return void
*/
public function onCheckoutConfirmLoaded(CheckoutConfirmPageLoadedEvent $event): void
{
try {
$salesChannelContext = $event->getSalesChannelContext();
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
if (is_null($settings)) {
$this->logger->notice('Removing payment methods because settings are invalid');
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
}
$createdTransactionId = $this->transactionService->createPendingTransaction($salesChannelContext, $event);
$this->updateTempTransactionIfNeeded($salesChannelContext, $createdTransactionId);
$this->getAvailablePaymentMethods($settings, $createdTransactionId, $salesChannelContext);
$this->setPossiblePaymentMethods($settings->getSpaceId(), $event);
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
}
}
/**
* @param AccountEditOrderPageLoadedEvent $event
* @return void
*/
public function onAccountOrderEditLoaded(AccountEditOrderPageLoadedEvent $event): void
{
try {
$this->handlePaymentMethodFiltering($event);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
}
}
/**
* @param AccountPaymentMethodPageLoadedEvent $event
* @return void
*/
public function onAccountPaymentMethodLoaded(AccountPaymentMethodPageLoadedEvent $event): void
{
try {
$this->handlePaymentMethodFiltering($event);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
}
}
/**
* @param \Shopware\Storefront\Page\Checkout\Confirm\CheckoutConfirmPageLoadedEvent $event
*/
public function onConfirmPageLoaded(CheckoutConfirmPageLoadedEvent $event): void
{
try {
$this->handlePaymentMethodFiltering($event);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
}
}
/**
* @param $event
* @return void
*/
private function handlePaymentMethodFiltering($event): void
{
$salesChannelContext = $event->getSalesChannelContext();
$settings = $this->settingsService->getValidSettings($salesChannelContext->getSalesChannel()->getId());
if (is_null($settings)) {
$this->logger->notice('Removing payment methods because settings are invalid');
$this->removeVRPaymentPaymentMethodFromConfirmPage($event);
return;
}
$createdTransactionId = $this->transactionService->createPendingTransaction($salesChannelContext, $event);
$this->updateTempTransactionIfNeeded($salesChannelContext, $createdTransactionId);
$this->getAvailablePaymentMethods($settings, $createdTransactionId, $salesChannelContext);
$this->setPossiblePaymentMethods($settings->getSpaceId(), $event);
}
/**
* @param $event
* @return void
*/
private function removeVRPaymentPaymentMethodFromConfirmPage($event): void
{
$paymentMethodCollection = $event->getPage()->getPaymentMethods();
$paymentMethodIds = $this->paymentMethodUtil->getVRPaymentPaymentMethodIds($event->getContext());
foreach ($paymentMethodIds as $paymentMethodId) {
$paymentMethodCollection->remove($paymentMethodId);
}
}
/**
* @param Settings $settings
* @param int $createdTransactionId
* @return void
*/
private function getAvailablePaymentMethods(Settings $settings, int $createdTransactionId, SalesChannelContext $salesChannelContext): void
{
$transactionService = $settings->getApiClient()->getTransactionService();
$possiblePaymentMethods = $transactionService->fetchPaymentMethods(
$settings->getSpaceId(),
$createdTransactionId,
$settings->getIntegration()
);
$arrayOfPossibleMethods = [];
foreach ($possiblePaymentMethods as $possiblePaymentMethod) {
$arrayOfPossibleMethods[] = $possiblePaymentMethod->getId();
}
$salesChannelContext->getContext()->addExtension(
'possibleMethods',
new ArrayEntity(['ids' => $arrayOfPossibleMethods])
);
}
/**
* @param int $spaceId
* @param CheckoutConfirmPageLoadedEvent $event
* @return void
*/
private function setPossiblePaymentMethods(int $spaceId, $event): void
{
$paymentIds = [];
$paymentMethodCollection = $event->getPage()->getPaymentMethods();
foreach ($paymentMethodCollection as $paymentMethodCollectionItem) {
$isVRPaymentPM = VRPaymentPaymentHandler::class === $paymentMethodCollectionItem->getHandlerIdentifier();
if (!$isVRPaymentPM) { if (!$isVRPaymentPM) {
$paymentIds[] = $paymentMethodCollectionItem->getId(); return;
}
}
$allowedWLMethods = [];
$paymentMethodConfigurations = $this->paymentMethodConfigurationService
->getAllPaymentMethodConfigurations($spaceId, $event->getSalesChannelContext()->getContext());
foreach ($paymentMethodConfigurations as $paymentMethodConfiguration) {
if ($paymentMethodConfiguration->getPaymentMethod() === null) {
continue;
} }
$pmId = $paymentMethodConfiguration->getPaymentMethod()->getId(); // Verify if the transaction is in a state where an email should be handled.
$pmConfigId = $paymentMethodConfiguration->getPaymentMethodConfigurationId(); $isOrderTransactionStateOpen = in_array(
$allowedIds = $this->getAllowedPaymentMethodIds($event->getSalesChannelContext()); $orderTransactionLast->getStateMachineState()->getTechnicalName(),
[
if ($paymentMethodConfiguration->getSpaceId() === $spaceId OrderTransactionStates::STATE_OPEN,
&& \in_array($pmConfigId, $allowedIds, true)) { OrderTransactionStates::STATE_IN_PROGRESS,
$allowedWLMethods[] = $pmId; ]
}
}
$allPaymentIds = array_unique(array_merge($paymentIds, $allowedWLMethods));
$collection = new PaymentMethodCollection();
if (!empty($allPaymentIds)) {
$criteria = new Criteria($allPaymentIds);
$criteria->addFilter(new EqualsFilter('active', true));
$criteria->addFilter(
new EqualsFilter('salesChannels.id', $event->getSalesChannelContext()->getSalesChannelId())
); );
$criteria->addSorting(new FieldSorting('position', FieldSorting::ASCENDING));
$result = $this->paymentMethodRepository->search($criteria, $event->getContext()); if (!$isOrderTransactionStateOpen) {
foreach ($result->getEntities() as $method) { return;
if (!$collection->has($method->getId())) {
$collection->add($method);
}
} }
} }
$event->getPage()->setPaymentMethods($collection);
} }
/**
* @param SalesChannelContext $salesChannelContext
* @param int $createdTransactionId
* @return void
*/
private function updateTempTransactionIfNeeded(SalesChannelContext $salesChannelContext, int $createdTransactionId): void
{
$ctx = $salesChannelContext->getContext();
/** @var ArrayEntity|null $ext */
$ext = $ctx->getExtension('checkoutState');
$oldAddressHash = $ext instanceof ArrayEntity ? $ext->get('addressHash') : null;
$oldCurrency = $ext instanceof ArrayEntity ? $ext->get('currency') : null;
$customer = $salesChannelContext->getCustomer();
$addressHash = md5(json_encode((array) $customer));
$currency = $salesChannelContext->getCurrency()->getIsoCode();
$needsUpdate = ($oldAddressHash !== $addressHash) || ($oldCurrency !== $currency);
if ($needsUpdate) {
if ($createdTransactionId) {
$this->transactionService->updateTempTransaction($salesChannelContext, $createdTransactionId);
}
$ctx->addExtension('possibleMethods', new ArrayEntity(['ids' => []]));
$ctx->addExtension(
'checkoutState',
new ArrayEntity([
'addressHash' => $addressHash,
'currency' => $currency,
])
);
}
}
/**
* @param SalesChannelContext $salesChannelContext
* @return array
*/
private function getAllowedPaymentMethodIds(SalesChannelContext $salesChannelContext): array
{
$ext = $salesChannelContext->getContext()->getExtension('possibleMethods');
return $ext instanceof ArrayEntity ? ($ext->get('ids') ?? []) : [];
}
} }
+1 -1
View File
@@ -26,7 +26,7 @@ class Analytics {
self::SHOP_SYSTEM => 'shopware', self::SHOP_SYSTEM => 'shopware',
self::SHOP_SYSTEM_VERSION => '6', self::SHOP_SYSTEM_VERSION => '6',
self::SHOP_SYSTEM_AND_VERSION => 'shopware-6', self::SHOP_SYSTEM_AND_VERSION => 'shopware-6',
self::PLUGIN_SYSTEM_VERSION => '7.2.0', self::PLUGIN_SYSTEM_VERSION => '7.3.4',
]; ];
} }
@@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace VRPaymentPayment\Core\Util\Exception;
class RefundNotSupportedException extends \LogicException{
}
+20
View File
@@ -91,6 +91,26 @@ class LocaleCodeProvider {
return $language->getLocale() ? $language->getLocale()->getCode() : $defaultLocale; return $language->getLocale() ? $language->getLocale()->getCode() : $defaultLocale;
} }
/**
* Maps a locale code to a VRPayment-supported payment page locale by matching the language prefix.
* E.g. de-CH -> de-DE, fr-CH -> fr-FR, en-US -> en-GB, it-CH -> it-IT.
*
* @param string $localeCode
* @return string
*/
public function mapToPaymentPageLocale(string $localeCode): string
{
$supportedLocales = [
'de' => self::LOCALE_GERMANY_GERMAN,
'fr' => self::LOCALE_FRANCE_FRENCH,
'it' => self::LOCALE_ITALY_ITALIAN,
'en' => self::LOCALE_GREAT_BRITAIN_ENGLISH,
];
$languagePrefix = substr($localeCode, 0, 2);
return $supportedLocales[$languagePrefix] ?? self::LOCALE_GREAT_BRITAIN_ENGLISH;
}
/** /**
* @param \Shopware\Core\Framework\Context $context * @param \Shopware\Core\Framework\Context $context
+96 -50
View File
@@ -1,10 +1,13 @@
<?php declare(strict_types=1); <?php
declare(strict_types=1);
namespace VRPaymentPayment\Core\Util\Payload; namespace VRPaymentPayment\Core\Util\Payload;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Shopware\Core\{Checkout\Cart\Tax\Struct\CalculatedTaxCollection, use Shopware\Core\{
Checkout\Cart\Tax\Struct\CalculatedTaxCollection,
Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity, Checkout\Customer\Aggregate\CustomerAddress\CustomerAddressEntity,
Checkout\Customer\CustomerEntity, Checkout\Customer\CustomerEntity,
Checkout\Order\Aggregate\OrderLineItem\OrderLineItemEntity, Checkout\Order\Aggregate\OrderLineItem\OrderLineItemEntity,
@@ -15,10 +18,12 @@ use Shopware\Core\{Checkout\Cart\Tax\Struct\CalculatedTaxCollection,
}; };
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Api\Context\SalesChannelApiSource;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use VRPayment\Sdk\{Model\AddressCreate, use VRPayment\Sdk\{
Model\AddressCreate,
Model\ChargeAttempt, Model\ChargeAttempt,
Model\CreationEntityState, Model\CreationEntityState,
Model\CriteriaOperator, Model\CriteriaOperator,
@@ -32,7 +37,8 @@ use VRPayment\Sdk\{Model\AddressCreate,
Model\TransactionCreate, Model\TransactionCreate,
Model\TransactionPending Model\TransactionPending
}; };
use VRPaymentPayment\Core\{Api\PaymentMethodConfiguration\Entity\PaymentMethodConfigurationEntity, use VRPaymentPayment\Core\{
Api\PaymentMethodConfiguration\Entity\PaymentMethodConfigurationEntity,
Settings\Struct\Settings, Settings\Struct\Settings,
Util\Exception\InvalidPayloadException, Util\Exception\InvalidPayloadException,
Util\LocaleCodeProvider, Util\LocaleCodeProvider,
@@ -98,6 +104,16 @@ class TransactionPayload extends AbstractPayload
protected OrderEntity $order; protected OrderEntity $order;
/**
* @var int
*/
protected $transactionId;
public function setTransactionId(int $transactionId): void
{
$this->transactionId = $transactionId;
}
/** /**
* TransactionPayload constructor. * TransactionPayload constructor.
* *
@@ -113,8 +129,7 @@ class TransactionPayload extends AbstractPayload
SalesChannelContext $salesChannelContext, SalesChannelContext $salesChannelContext,
Settings $settings, Settings $settings,
PaymentTransactionStruct $transaction PaymentTransactionStruct $transaction
) ) {
{
$this->localeCodeProvider = $localeCodeProvider; $this->localeCodeProvider = $localeCodeProvider;
$this->salesChannelContext = $salesChannelContext; $this->salesChannelContext = $salesChannelContext;
$this->settings = $settings; $this->settings = $settings;
@@ -135,7 +150,7 @@ class TransactionPayload extends AbstractPayload
->addAssociation('orderCustomer') ->addAssociation('orderCustomer')
->addAssociation('transactions') ->addAssociation('transactions')
->addAssociation('currency') ->addAssociation('currency')
; ;
$this->order = $this->container->get('order.repository')->search($criteria, $this->salesChannelContext->getContext())->getEntities()->first(); $this->order = $this->container->get('order.repository')->search($criteria, $this->salesChannelContext->getContext())->getEntities()->first();
} }
@@ -182,7 +197,7 @@ class TransactionPayload extends AbstractPayload
'merchant_reference' => $this->fixLength($this->order->getOrderNumber(), 100), 'merchant_reference' => $this->fixLength($this->order->getOrderNumber(), 100),
'meta_data' => [ 'meta_data' => [
self::VRPAYMENT_METADATA_ORDER_ID => $this->order->getId(), self::VRPAYMENT_METADATA_ORDER_ID => $this->order->getId(),
self::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID => $this->order->getTransactions()->first()->getId(), self::VRPAYMENT_METADATA_ORDER_TRANSACTION_ID => $this->transaction->getOrderTransactionId(),
self::VRPAYMENT_METADATA_SALES_CHANNEL_ID => $this->salesChannelContext->getSalesChannel()->getId(), self::VRPAYMENT_METADATA_SALES_CHANNEL_ID => $this->salesChannelContext->getSalesChannel()->getId(),
self::VRPAYMENT_METADATA_CUSTOMER_NAME => $customerName, self::VRPAYMENT_METADATA_CUSTOMER_NAME => $customerName,
], ],
@@ -218,7 +233,7 @@ class TransactionPayload extends AbstractPayload
} }
$transactionPayload = (new TransactionPending()) $transactionPayload = (new TransactionPending())
->setId($_SESSION['transactionId']) ->setId($this->transactionId)
->setVersion($version) ->setVersion($version)
->setBillingAddress($billingAddress) ->setBillingAddress($billingAddress)
->setCurrency($transactionData['currency']) ->setCurrency($transactionData['currency'])
@@ -243,7 +258,9 @@ class TransactionPayload extends AbstractPayload
} }
$successUrl = $this->transaction->getReturnUrl() . '&status=paid'; $successUrl = $this->transaction->getReturnUrl() . '&status=paid';
$failedUrl = $this->getFailUrl($this->order->getId()) . '&status=fail'; // For headless clients, use the returnUrl for failure as well (they handle the status parameter).
// For Storefront, use the recreate-cart route.
$failedUrl = $this->getFailUrl($this->order->getId(), $this->transaction->getReturnUrl()) . '&status=fail';
$transactionPayload->setSuccessUrl($successUrl) $transactionPayload->setSuccessUrl($successUrl)
->setFailedUrl($failedUrl); ->setFailedUrl($failedUrl);
@@ -295,8 +312,8 @@ class TransactionPayload extends AbstractPayload
protected function shouldSkipLineItem($shopLineItem): bool protected function shouldSkipLineItem($shopLineItem): bool
{ {
return in_array($shopLineItem->getType(), [ return in_array($shopLineItem->getType(), [
CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS, CustomProductsLineItemTypes::LINE_ITEM_TYPE_CUSTOMIZED_PRODUCTS,
'promotion' 'promotion'
]); ]);
} }
@@ -361,7 +378,7 @@ class TransactionPayload extends AbstractPayload
$rate = $calculatedTax->getTaxRate(); $rate = $calculatedTax->getTaxRate();
$amount = $this->calculateDiscountAmount($calculatedTax); $amount = $this->calculateDiscountAmount($calculatedTax);
$lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate); $lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate, $discount->getId());
} }
} else { } else {
$taxRules = $calculatedPrice->getTaxRules(); $taxRules = $calculatedPrice->getTaxRules();
@@ -370,12 +387,12 @@ class TransactionPayload extends AbstractPayload
foreach ($taxRules as $taxRule) { foreach ($taxRules as $taxRule) {
$rate = $taxRule->getTaxRate(); $rate = $taxRule->getTaxRate();
$amount = $calculatedPrice->getTotalPrice(); $amount = $calculatedPrice->getTotalPrice();
$lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate); $lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate, $discount->getId());
} }
} else { } else {
$rate = $this->getDefaultTaxRate(); $rate = $this->getDefaultTaxRate();
$amount = $calculatedPrice->getTotalPrice(); $amount = $calculatedPrice->getTotalPrice();
$lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate); $lineItems[] = $this->createDiscountLineItem($discountName, $amount, $rate, $discount->getId());
} }
} }
} }
@@ -386,7 +403,7 @@ class TransactionPayload extends AbstractPayload
* @param float $rate * @param float $rate
* @return LineItemCreate * @return LineItemCreate
*/ */
private function createDiscountLineItem(string $discountName, float $amount, float $rate): LineItemCreate private function createDiscountLineItem(string $discountName, float $amount, float $rate, string $discountId): LineItemCreate
{ {
$lineItem = new LineItemCreate(); $lineItem = new LineItemCreate();
@@ -405,7 +422,7 @@ class TransactionPayload extends AbstractPayload
->setShippingRequired(false) ->setShippingRequired(false)
->setSku($discountSkuName, 200) ->setSku($discountSkuName, 200)
->setType(LineItemType::DISCOUNT) ->setType(LineItemType::DISCOUNT)
->setUniqueId('coupon-' . $discountSkuName); ->setUniqueId('coupon-' . $discountSkuName . '-' . $discountId);
$taxRate = new TaxCreate([ $taxRate = new TaxCreate([
'title' => 'Discount Tax: ' . $rate, 'title' => 'Discount Tax: ' . $rate,
@@ -540,7 +557,6 @@ class TransactionPayload extends AbstractPayload
$this->translator->trans('vrpayment.payload.taxes'), $this->translator->trans('vrpayment.payload.taxes'),
$amount $amount
); );
} else { } else {
$productAttributes = $this->getProductAttributes($shopLineItem); $productAttributes = $this->getProductAttributes($shopLineItem);
@@ -590,7 +606,7 @@ class TransactionPayload extends AbstractPayload
throw new InvalidPayloadException('Tax payload invalid:' . json_encode($tax->listInvalidProperties())); throw new InvalidPayloadException('Tax payload invalid:' . json_encode($tax->listInvalidProperties()));
} }
$taxes [] = $tax; $taxes[] = $tax;
} }
return $taxes; return $taxes;
@@ -670,7 +686,6 @@ class TransactionPayload extends AbstractPayload
return $lineItem; return $lineItem;
} }
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage()); $this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage());
} }
@@ -696,19 +711,19 @@ class TransactionPayload extends AbstractPayload
} }
$taxRate = $taxItem->getTaxRate(); $taxRate = $taxItem->getTaxRate();
$tax = (new TaxCreate()) $tax = (new TaxCreate())
->setRate($taxRate) ->setRate($taxRate)
->setTitle('Tax rate: '.$taxRate); ->setTitle('Tax rate: ' . $taxRate);
$roundedAmount = self::round($amount); $roundedAmount = self::round($amount);
$name = $taxRate . '%-' . $shippingName; $name = $taxRate . '%-' . $shippingName;
$lineItem = (new LineItemCreate()) $lineItem = (new LineItemCreate())
->setAmountIncludingTax($roundedAmount) ->setAmountIncludingTax($roundedAmount)
->setName($this->fixLength($name . ' ' . $this->translator->trans('vrpayment.payload.shipping.lineItem'), 150)) ->setName($this->fixLength($name . ' ' . $this->translator->trans('vrpayment.payload.shipping.lineItem'), 150))
->setQuantity($this->order->getShippingCosts()->getQuantity() ?? 1) ->setQuantity($this->order->getShippingCosts()->getQuantity() ?? 1)
->setSku($this->fixLength($name . '-Shipping', 200)) ->setSku($this->fixLength($name . '-Shipping', 200))
->setType($isFirst ? LineItemType::SHIPPING : LineItemType::FEE) // First item as SHIPPING, rest as FEE ->setType($isFirst ? LineItemType::SHIPPING : LineItemType::FEE) // First item as SHIPPING, rest as FEE
->setUniqueId($this->fixLength($name . '-Shipping', 200)); ->setUniqueId($this->fixLength($name . '-Shipping', 200));
if ($this->order->getTaxStatus() !== 'tax-free') { if ($this->order->getTaxStatus() !== 'tax-free') {
$lineItem->setTaxes([$tax]); $lineItem->setTaxes([$tax]);
@@ -724,7 +739,6 @@ class TransactionPayload extends AbstractPayload
} }
return $lineItems; return $lineItems;
} }
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage()); $this->logger->critical(__CLASS__ . ' : ' . __FUNCTION__ . ' : ' . $exception->getMessage());
} }
@@ -767,7 +781,6 @@ class TransactionPayload extends AbstractPayload
]); ]);
$this->logger->critical($error); $this->logger->critical($error);
throw new \Exception($error); throw new \Exception($error);
} else { } else {
$lineItem = (new LineItemCreate()) $lineItem = (new LineItemCreate())
->setName($this->translator->trans('vrpayment.payload.adjustmentLineItem')) ->setName($this->translator->trans('vrpayment.payload.adjustmentLineItem'))
@@ -849,7 +862,6 @@ class TransactionPayload extends AbstractPayload
} else { } else {
if (!empty($customer->getSalutation())) { if (!empty($customer->getSalutation())) {
$salutation = $customer->getSalutation()->getDisplayName(); $salutation = $customer->getSalutation()->getDisplayName();
} }
} }
$salutation = !empty($salutation) ? $this->fixLength($salutation, 20) : null; $salutation = !empty($salutation) ? $this->fixLength($salutation, 20) : null;
@@ -914,31 +926,65 @@ class TransactionPayload extends AbstractPayload
return $addressPayload; return $addressPayload;
} }
/** /**
* @param string $paymentMethodId * @param string $paymentMethodId
* @param int $spaceId * @param int $spaceId
* @return PaymentMethodConfigurationEntity|null * @return PaymentMethodConfigurationEntity|null
*/ */
protected function getPaymentConfiguration(string $paymentMethodId, int $spaceId): ?PaymentMethodConfigurationEntity protected function getPaymentConfiguration(string $paymentMethodId, int $spaceId): ?PaymentMethodConfigurationEntity
{ {
$criteria = new Criteria(); $criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('paymentMethodId', $paymentMethodId)); $criteria->addFilter(new EqualsFilter('paymentMethodId', $paymentMethodId));
$criteria->addFilter(new EqualsFilter('spaceId', $spaceId)); $criteria->addFilter(new EqualsFilter('spaceId', $spaceId));
return $this->container->get('vrpayment_payment_method_configuration.repository') return $this->container->get('vrpayment_payment_method_configuration.repository')
->search($criteria, $this->salesChannelContext->getContext()) ->search($criteria, $this->salesChannelContext->getContext())
->first(); ->first();
} }
/** /**
* Get failure URL * Generates the failure URL for payment transactions.
* For headless clients (Store API), returns the client's returnUrl.
* For Storefront, returns the recreate-cart route.
* *
* @param string $orderId * @param string $orderId The order ID for the Storefront route.
* * @param string|null $returnUrl The client's return URL (used for headless).
* @return string * @return string The failure URL.
*/ */
protected function getFailUrl(string $orderId): string protected function getFailUrl(string $orderId, ?string $returnUrl = null): string
{ {
// For headless clients (Store API) or custom integrations, we use the returnUrl so the client can handle the failure.
// We detect "Standard Storefront" usage by checking if the returnUrl matches the standard Shopware 'finalize-transaction' route.
// If it DOES match, we usually redirect to 'recreate-cart' for better error handling. (Storefront / Edit Order)
// If it DOES NOT match (e.g. custom URL, headless URL), we return it as is.
$isStandardShopwareUrl = strpos($returnUrl ?? '', 'payment/finalize-transaction') !== false;
$request = $this->container->get('request_stack')->getCurrentRequest();
$isJsonRequest = false;
if ($request) {
// Check for JSON preference
$format = $request->getPreferredFormat();
$contentTypes = $request->getAcceptableContentTypes();
$isJsonRequest = $format === 'json' || in_array('application/json', $contentTypes);
}
if ($returnUrl) {
// Case 1: Custom URL (Headless detection part 1)
// If the URL is custom (not standard Shopware), we always respect it.
if (!$isStandardShopwareUrl) {
return $returnUrl;
}
// Case 2: Standard URL + JSON Request (Headless detection part 2)
// If URL is standard BUT request prefers JSON, it's likely a headless app (Store API) being proxied.
if ($isStandardShopwareUrl && $isJsonRequest) {
return $returnUrl;
}
}
// Default: Storefront (SystemSource, SalesChannelSource, or AdminSalesChannelApiSource via Edit Order/HTML)
// We generate the recreate-cart route to restore the cart and show the error.
return $this->container->get('router')->generate( return $this->container->get('router')->generate(
'frontend.vrpayment.checkout.recreate-cart', 'frontend.vrpayment.checkout.recreate-cart',
['orderId' => $orderId,], ['orderId' => $orderId,],
@@ -79,6 +79,9 @@ Component.register('vrpayment-order-action-refund-by-amount', {
case 'refundExceedsAmount': case 'refundExceedsAmount':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messageRefundAmountExceedsAvailableBalance'); errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messageRefundAmountExceedsAvailableBalance');
break; break;
case 'methodDoesNotSupportRefund':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default: default:
errorMessage = errorResponse.response.data.errors[0].detail; errorMessage = errorResponse.response.data.errors[0].detail;
} }
@@ -71,9 +71,18 @@ Component.register('vrpayment-order-action-refund-partial', {
}); });
}).catch((errorResponse) => { }).catch((errorResponse) => {
try { try {
var errorTitle = errorResponse?.response?.data?.errors?.[0]?.title ?? this.$tc('vrpayment-order.refundAction.refundCreateError.errorTitle')
var errorMessage;
switch(errorResponse.response.data) {
case 'methodDoesNotSupportRefund':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default:
errorMessage = errorResponse.response.data.errors[0].detail;
}
this.createNotificationError({ this.createNotificationError({
title: errorResponse.response.data.errors[0].title, title: errorTitle,
message: errorResponse.response.data.errors[0].detail, message: errorMessage,
autoClose: false autoClose: false
}); });
} catch (e) { } catch (e) {
@@ -70,9 +70,18 @@ Component.register('vrpayment-order-action-refund-selected', {
}); });
}).catch((errorResponse) => { }).catch((errorResponse) => {
try { try {
var errorTitle = errorResponse?.response?.data?.errors?.[0]?.title ?? this.$tc('vrpayment-order.refundAction.refundCreateError.errorTitle')
var errorMessage;
switch(errorResponse.response.data) {
case 'methodDoesNotSupportRefund':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default:
errorMessage = errorResponse.response.data.errors[0].detail;
}
this.createNotificationError({ this.createNotificationError({
title: errorResponse.response.data.errors[0].title, title: errorTitle,
message: errorResponse.response.data.errors[0].detail, message: errorMessage,
autoClose: false autoClose: false
}); });
} catch (e) { } catch (e) {
@@ -77,6 +77,9 @@ Component.register('vrpayment-order-action-refund', {
case 'refundExceedsQuantity': case 'refundExceedsQuantity':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messageRefundQuantityExceedsAvailableBalance'); errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messageRefundQuantityExceedsAvailableBalance');
break; break;
case 'methodDoesNotSupportRefund':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default: default:
errorMessage = errorResponse.response.data.errors[0].detail; errorMessage = errorResponse.response.data.errors[0].detail;
} }
@@ -98,7 +98,7 @@
<template #actions="{ item }"> <template #actions="{ item }">
<sw-context-menu-item <sw-context-menu-item
:disabled="transaction.state != 'FULFILL' || item.refundableQuantity != item.quantity || item.refundableAmount == 0 || item.itemRefundedAmount > 0 || item.itemRefundedQuantity > 0" :disabled="transaction.state != 'FULFILL' || item.refundableQuantity != item.quantity || item.refundableAmount == 0 || item.itemRefundedAmount > 0 || item.itemRefundedQuantity > 0"
@click="lineItemRefund(item.uniqueId)"> @click="lineItemRefund(item.uniqueId, item.quantity)">
{{ $tc('vrpayment-order.buttons.label.refund-whole-line-item') }} {{ $tc('vrpayment-order.buttons.label.refund-whole-line-item') }}
</sw-context-menu-item> </sw-context-menu-item>
@@ -332,12 +332,12 @@ Component.register('vrpayment-order-detail', {
this.modalType = ''; this.modalType = '';
}, },
lineItemRefund(lineItemId) { lineItemRefund(lineItemId, itemQuantity) {
this.isLoading = true; this.isLoading = true;
this.VRPaymentRefundService.createRefund( this.VRPaymentRefundService.createRefund(
this.transactionData.transactions[0].metaData.salesChannelId, this.transactionData.transactions[0].metaData.salesChannelId,
this.transactionData.transactions[0].id, this.transactionData.transactions[0].id,
0, itemQuantity,
lineItemId lineItemId
).then(() => { ).then(() => {
this.createNotificationSuccess({ this.createNotificationSuccess({
@@ -351,9 +351,18 @@ Component.register('vrpayment-order-detail', {
}); });
}).catch((errorResponse) => { }).catch((errorResponse) => {
try { try {
var errorTitle = errorResponse?.response?.data?.errors?.[0]?.title ?? this.$tc('vrpayment-order.refundAction.refundCreateError.errorTitle')
var errorMessage;
switch(errorResponse.response.data) {
case 'methodDoesNotSupportRefund':
errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default:
errorMessage = errorResponse.response.data.errors[0].detail;
}
this.createNotificationError({ this.createNotificationError({
title: errorResponse.response.data.errors[0].title, title: errorTitle,
message: errorResponse.response.data.errors[0].detail, message: errorMessage,
autoClose: false autoClose: false
}); });
} catch (e) { } catch (e) {
@@ -385,7 +394,7 @@ Component.register('vrpayment-order-detail', {
// Force the DOM to update before proceeding with the asynchronous operations // Force the DOM to update before proceeding with the asynchronous operations
this.$nextTick(() => { this.$nextTick(() => {
const refundPromises = this.selectedItems.map((item) => { const refundPromises = this.selectedItems.map((item) => {
return this.lineItemRefundBulk(item.uniqueId); // Simulated refund action with delay return this.lineItemRefundBulk(item.uniqueId, item.quantity); // Simulated refund action with delay
}); });
// Wait for all refund promises to complete // Wait for all refund promises to complete
@@ -399,6 +408,10 @@ Component.register('vrpayment-order-detail', {
}); });
}) })
.catch((error) => { .catch((error) => {
if (error?.response?.data === 'methodDoesNotSupportRefund') {
this.isLoading = false;
return;
}
// Handle any errors during the refund process // Handle any errors during the refund process
this.createNotificationError({ this.createNotificationError({
title: 'Error', title: 'Error',
@@ -410,12 +423,12 @@ Component.register('vrpayment-order-detail', {
}); });
} }
}, },
lineItemRefundBulk(lineItemId) { lineItemRefundBulk(lineItemId, itemQuantity) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.VRPaymentRefundService.createRefund( this.VRPaymentRefundService.createRefund(
this.transactionData.transactions[0].metaData.salesChannelId, this.transactionData.transactions[0].metaData.salesChannelId,
this.transactionData.transactions[0].id, this.transactionData.transactions[0].id,
0, itemQuantity,
lineItemId lineItemId
) )
.then(() => { .then(() => {
@@ -427,11 +440,20 @@ Component.register('vrpayment-order-detail', {
}) })
.catch((errorResponse) => { .catch((errorResponse) => {
try { try {
this.createNotificationError({ var errorTitle = errorResponse?.response?.data?.errors?.[0]?.title ?? this.$tc('vrpayment-order.refundAction.refundCreateError.errorTitle')
title: errorResponse.response.data.errors[0].title, var errorMessage;
message: errorResponse.response.data.errors[0].detail, switch(errorResponse.response.data) {
autoClose: false case 'methodDoesNotSupportRefund':
}); errorMessage = this.$tc('vrpayment-order.refundAction.refundCreateError.messagePaymentMethodDoesNotSupportRefund');
break;
default:
errorMessage = errorResponse.response.data.errors[0].detail;
}
this.createNotificationError({
title: errorTitle,
message: errorMessage,
autoClose: false
});
} catch (e) { } catch (e) {
this.createNotificationError({ this.createNotificationError({
title: errorResponse.title, title: errorResponse.title,
@@ -439,7 +461,7 @@ Component.register('vrpayment-order-detail', {
autoClose: false autoClose: false
}); });
} finally { } finally {
reject(); reject(errorResponse);
} }
}); });
}); });
@@ -83,7 +83,8 @@
"messageRefundAmountExceedsAvailableBalance": "Der Rückerstattungsbetrag übersteigt das verfügbare Guthaben.", "messageRefundAmountExceedsAvailableBalance": "Der Rückerstattungsbetrag übersteigt das verfügbare Guthaben.",
"messageRefundAmountIsZero": "Der Rückerstattungsbetrag muss größer als 0 sein.", "messageRefundAmountIsZero": "Der Rückerstattungsbetrag muss größer als 0 sein.",
"messageRefundQuantityExceedsAvailableBalance": "Rückerstattung nach Menge überschreitet die maximal verfügbare Anzahl an Artikeln zur Rückerstattung.", "messageRefundQuantityExceedsAvailableBalance": "Rückerstattung nach Menge überschreitet die maximal verfügbare Anzahl an Artikeln zur Rückerstattung.",
"messageRefundQuantityIsZero": "Rückerstattung nach Menge muss größer als 0 sein." "messageRefundQuantityIsZero": "Rückerstattung nach Menge muss größer als 0 sein.",
"messagePaymentMethodDoesNotSupportRefund": "Die Zahlungsmethode unterstützt keine Online-Rückerstattungen."
} }
}, },
"transactionHistory": { "transactionHistory": {
@@ -83,7 +83,8 @@
"messageRefundAmountExceedsAvailableBalance": "Refund amount exceeds available balance.", "messageRefundAmountExceedsAvailableBalance": "Refund amount exceeds available balance.",
"messageRefundAmountIsZero": "Refund amount must be greater than 0.", "messageRefundAmountIsZero": "Refund amount must be greater than 0.",
"messageRefundQuantityExceedsAvailableBalance": "Refund by quantity exceeds maximum available items to refund.", "messageRefundQuantityExceedsAvailableBalance": "Refund by quantity exceeds maximum available items to refund.",
"messageRefundQuantityIsZero": "Refund by quantity must be greater than 0." "messageRefundQuantityIsZero": "Refund by quantity must be greater than 0.",
"messagePaymentMethodDoesNotSupportRefund": "Payment method does not support online refunds."
} }
}, },
"transactionHistory": { "transactionHistory": {
@@ -83,7 +83,8 @@
"messageRefundAmountExceedsAvailableBalance": "Le montant du remboursement dépasse le solde disponible.", "messageRefundAmountExceedsAvailableBalance": "Le montant du remboursement dépasse le solde disponible.",
"messageRefundAmountIsZero": "Le montant du remboursement doit être supérieur à 0.", "messageRefundAmountIsZero": "Le montant du remboursement doit être supérieur à 0.",
"messageRefundQuantityExceedsAvailableBalance": "Le remboursement par quantité dépasse le nombre maximal darticles remboursables.", "messageRefundQuantityExceedsAvailableBalance": "Le remboursement par quantité dépasse le nombre maximal darticles remboursables.",
"messageRefundQuantityIsZero": "Le remboursement par quantité doit être supérieur à 0." "messageRefundQuantityIsZero": "Le remboursement par quantité doit être supérieur à 0.",
"messagePaymentMethodDoesNotSupportRefund": "Le mode de paiement ne prend pas en charge les remboursements en ligne."
} }
}, },
"transactionHistory": { "transactionHistory": {
@@ -83,7 +83,8 @@
"messageRefundAmountExceedsAvailableBalance": "LL'importo del rimborso supera il saldo disponibile.", "messageRefundAmountExceedsAvailableBalance": "LL'importo del rimborso supera il saldo disponibile.",
"messageRefundAmountIsZero": "L'importo del rimborso deve essere superiore a 0.", "messageRefundAmountIsZero": "L'importo del rimborso deve essere superiore a 0.",
"messageRefundQuantityExceedsAvailableBalance": "Il rimborso per quantità supera il numero massimo di articoli rimborsabili.", "messageRefundQuantityExceedsAvailableBalance": "Il rimborso per quantità supera il numero massimo di articoli rimborsabili.",
"messageRefundQuantityIsZero": "Il rimborso per quantità deve essere maggiore di 0." "messageRefundQuantityIsZero": "Il rimborso per quantità deve essere maggiore di 0.",
"messagePaymentMethodDoesNotSupportRefund": "Il metodo di pagamento non supporta i rimborsi online."
} }
}, },
"transactionHistory": { "transactionHistory": {
@@ -10,6 +10,9 @@ export const CONFIG_USER_ID = CONFIG_DOMAIN + '.' + 'userId';
export const CONFIG_STOREFRONT_WEBHOOKS_UPDATE_ENABLED = CONFIG_DOMAIN + '.' + 'storefrontWebhooksUpdateEnabled'; export const CONFIG_STOREFRONT_WEBHOOKS_UPDATE_ENABLED = CONFIG_DOMAIN + '.' + 'storefrontWebhooksUpdateEnabled';
export const CONFIG_STOREFRONT_PAYMENTS_UPDATE_ENABLED = CONFIG_DOMAIN + '.' + 'storefrontPaymentsUpdateEnabled'; export const CONFIG_STOREFRONT_PAYMENTS_UPDATE_ENABLED = CONFIG_DOMAIN + '.' + 'storefrontPaymentsUpdateEnabled';
// References vendor/shopware/core/Defaults.php::SALES_CHANNEL_TYPE_STOREFRONT
export const STOREFRONT_SALES_CHANNEL_TYPE_ID = '8a243080f92e4c719546314b577cf82b';
export default { export default {
CONFIG_DOMAIN, CONFIG_DOMAIN,
CONFIG_APPLICATION_KEY, CONFIG_APPLICATION_KEY,
@@ -21,5 +24,6 @@ export default {
CONFIG_STOREFRONT_INVOICE_DOWNLOAD_ENABLED, CONFIG_STOREFRONT_INVOICE_DOWNLOAD_ENABLED,
CONFIG_USER_ID, CONFIG_USER_ID,
CONFIG_STOREFRONT_WEBHOOKS_UPDATE_ENABLED, CONFIG_STOREFRONT_WEBHOOKS_UPDATE_ENABLED,
CONFIG_STOREFRONT_PAYMENTS_UPDATE_ENABLED CONFIG_STOREFRONT_PAYMENTS_UPDATE_ENABLED,
}; STOREFRONT_SALES_CHANNEL_TYPE_ID
};
@@ -35,6 +35,7 @@
{% block vrpayment_settings_content_card_channel_config %} {% block vrpayment_settings_content_card_channel_config %}
<sw-sales-channel-config v-model:value="config" <sw-sales-channel-config v-model:value="config"
v-model:selectedSalesChannelId="selectedSalesChannelId"
ref="configComponent" ref="configComponent"
:domain="CONFIG_DOMAIN"> :domain="CONFIG_DOMAIN">
@@ -45,14 +46,10 @@
<mt-card title="Sales Channel Switch"> <mt-card title="Sales Channel Switch">
{% block vrpayment_settings_content_card_channel_config_sales_channel_card_title %} {% block vrpayment_settings_content_card_channel_config_sales_channel_card_title %}
<sw-single-select <sw-sales-channel-switch
:value="selectedSalesChannelId" ref="channelSwitch"
:options="salesChannel.map(sc => ({ id: sc.id, name: sc.translated.name }))" @change-sales-channel-id="onSalesChannelSwitchChange($event, onInput)">
labelProperty="name" </sw-sales-channel-switch>
valueProperty="id"
:isLoading="isLoading"
@update:value="onInput"
/>
{% endblock %} {% endblock %}
{% block vrpayment_settings_content_card_channel_config_sales_channel_card_footer %} {% block vrpayment_settings_content_card_channel_config_sales_channel_card_footer %}
<template #footer> <template #footer>
@@ -3,7 +3,7 @@
import template from './index.html.twig'; import template from './index.html.twig';
import constants from './configuration-constants'; import constants from './configuration-constants';
const {Component, Mixin} = Shopware; const { Component, Mixin } = Shopware;
Component.register('vrpayment-settings', { Component.register('vrpayment-settings', {
@@ -11,7 +11,8 @@ Component.register('vrpayment-settings', {
inject: [ inject: [
'acl', 'acl',
'VRPaymentConfigurationService' 'VRPaymentConfigurationService',
'repositoryFactory'
], ],
mixins: [ mixins: [
@@ -40,6 +41,7 @@ Component.register('vrpayment-settings', {
isSetDefaultPaymentSuccessful: false, isSetDefaultPaymentSuccessful: false,
isSettingDefaultPaymentMethods: false, isSettingDefaultPaymentMethods: false,
selectedSalesChannelId: null,
configIntegrationDefaultValue: 'payment_page', configIntegrationDefaultValue: 'payment_page',
configEmailEnabledDefaultValue: true, configEmailEnabledDefaultValue: true,
@@ -69,7 +71,7 @@ Component.register('vrpayment-settings', {
config: { config: {
handler(configData) { handler(configData) {
const defaultConfig = (this.$refs.configComponent.allConfigs || {}).null || {}; const defaultConfig = (this.$refs.configComponent.allConfigs || {}).null || {};
const salesChannelId = this.$refs.configComponent.selectedSalesChannelId; const salesChannelId = this.selectedSalesChannelId;
if (salesChannelId === null) { if (salesChannelId === null) {
this.applicationKeyFilled = !!this.config[this.CONFIG_APPLICATION_KEY]; this.applicationKeyFilled = !!this.config[this.CONFIG_APPLICATION_KEY];
@@ -136,6 +138,16 @@ Component.register('vrpayment-settings', {
this.$emit('update:value', configData); this.$emit('update:value', configData);
}, },
deep: true deep: true
},
selectedSalesChannelId: {
handler(newValue) {
this.$nextTick(() => {
if (this.$refs.channelSwitch) {
this.$refs.channelSwitch.salesChannelId = newValue || '';
}
});
}
} }
}, },
@@ -161,21 +173,93 @@ Component.register('vrpayment-settings', {
}, },
getInheritValue(key) { getInheritValue(key) {
if (this.selectedSalesChannelId == null ) { if (this.selectedSalesChannelId == null) {
return this.actualConfigData[key]; return this.actualConfigData[key];
} else { } else {
return this.allConfigs['null'][key]; return this.allConfigs['null'][key];
} }
}, },
onSave() { async onSave() {
if (!(this.spaceIdFilled && this.userIdFilled && this.applicationKeyFilled)) { if (!(this.spaceIdFilled && this.userIdFilled && this.applicationKeyFilled)) {
this.setErrorStates(); this.setErrorStates();
return; return;
} }
this.isLoading = true;
const validationError = await this.validateHeadlessIntegration();
if (validationError === 'HEADLESS') {
this.createNotificationError({
title: this.$tc('vrpayment-settings.settingForm.titleError'),
message: this.$tc('vrpayment-settings.settingForm.messageHeadlessIntegrationError')
});
this.isLoading = false;
return;
} else if (validationError === 'GLOBAL') {
this.createNotificationError({
title: this.$tc('vrpayment-settings.settingForm.titleError'),
message: this.$tc('vrpayment-settings.settingForm.messageGlobalIframeError')
});
this.isLoading = false;
return;
}
this.save(); this.save();
}, },
async validateHeadlessIntegration() {
const salesChannelId = this.selectedSalesChannelId;
const currentIntegration = this.config[this.CONFIG_INTEGRATION];
// If integration is 'payment_page', it is always valid.
if (currentIntegration === 'payment_page') {
return null;
}
const salesChannelRepo = this.repositoryFactory.create('sales_channel');
try {
if (salesChannelId) {
// Specific Sales Channel Check
const salesChannel = await salesChannelRepo.get(salesChannelId, Shopware.Context.api);
const currentTypeId = salesChannel.typeId.replace(/-/g, '');
// REST-2: Inverted Logic
// We only allow 'iframe' integration if the Sales Channel is of type 'Storefront'.
// Any other type (Headless, Product Export, Custom, etc.) does not support Iframe injection.
const isStorefront = currentTypeId === constants.STOREFRONT_SALES_CHANNEL_TYPE_ID;
if (!isStorefront) {
return 'HEADLESS';
}
} else {
// Global Scope ("All Sales Channels") Check
// We must check if there is ANY Sales Channel that is NOT Storefront.
// If so, we cannot allow "Iframe" globally, as it would break those channels.
const criteria = new Shopware.Data.Criteria();
criteria.addFilter(
Shopware.Data.Criteria.not(
'AND',
[Shopware.Data.Criteria.equals('typeId', constants.STOREFRONT_SALES_CHANNEL_TYPE_ID)]
)
);
criteria.setLimit(1); // We only need to know if at least one exists
const result = await salesChannelRepo.search(criteria, Shopware.Context.api);
if (result.total > 0) {
return 'GLOBAL';
}
}
return null;
} catch (e) {
console.error(e);
return null;
}
},
save() { save() {
this.isLoading = true; this.isLoading = true;
@@ -197,7 +281,7 @@ Component.register('vrpayment-settings', {
return false; return false;
} }
this.VRPaymentConfigurationService.registerWebHooks(this.$refs.configComponent.selectedSalesChannelId) this.VRPaymentConfigurationService.registerWebHooks(this.selectedSalesChannelId)
.then(() => { .then(() => {
this.createNotificationSuccess({ this.createNotificationSuccess({
title: this.$tc('vrpayment-settings.settingForm.titleSuccess'), title: this.$tc('vrpayment-settings.settingForm.titleSuccess'),
@@ -210,7 +294,7 @@ Component.register('vrpayment-settings', {
}); });
this.isLoading = false; this.isLoading = false;
console.error('Error:', e); console.error('Error:', e);
}); });
}, },
synchronizePaymentMethodConfiguration() { synchronizePaymentMethodConfiguration() {
@@ -218,7 +302,7 @@ Component.register('vrpayment-settings', {
return false; return false;
} }
this.VRPaymentConfigurationService.synchronizePaymentMethodConfiguration(this.$refs.configComponent.selectedSalesChannelId) this.VRPaymentConfigurationService.synchronizePaymentMethodConfiguration(this.selectedSalesChannelId)
.then(() => { .then(() => {
this.createNotificationSuccess({ this.createNotificationSuccess({
title: this.$tc('vrpayment-settings.settingForm.titleSuccess'), title: this.$tc('vrpayment-settings.settingForm.titleSuccess'),
@@ -232,10 +316,10 @@ Component.register('vrpayment-settings', {
}); });
this.isLoading = false; this.isLoading = false;
console.error('Error:', e); console.error('Error:', e);
}); });
}, },
installOrderDeliveryStates(){ installOrderDeliveryStates() {
this.VRPaymentConfigurationService.installOrderDeliveryStates() this.VRPaymentConfigurationService.installOrderDeliveryStates()
.then(() => { .then(() => {
this.createNotificationSuccess({ this.createNotificationSuccess({
@@ -249,13 +333,13 @@ Component.register('vrpayment-settings', {
message: this.$tc('vrpayment-settings.settingForm.messageOrderDeliveryStateError') message: this.$tc('vrpayment-settings.settingForm.messageOrderDeliveryStateError')
}); });
this.isLoading = false; this.isLoading = false;
}); });
}, },
onSetPaymentMethodDefault() { onSetPaymentMethodDefault() {
this.isSettingDefaultPaymentMethods = true; this.isSettingDefaultPaymentMethods = true;
this.VRPaymentConfigurationService.setVRPaymentAsSalesChannelPaymentDefault( this.VRPaymentConfigurationService.setVRPaymentAsSalesChannelPaymentDefault(
this.$refs.configComponent.selectedSalesChannelId this.selectedSalesChannelId
).then(() => { ).then(() => {
this.isSettingDefaultPaymentMethods = false; this.isSettingDefaultPaymentMethods = false;
this.isSetDefaultPaymentSuccessful = true; this.isSetDefaultPaymentSuccessful = true;
@@ -311,7 +395,14 @@ Component.register('vrpayment-settings', {
message: this.$tc('vrpayment-settings.settingForm.credentials.alert.errorMessage') message: this.$tc('vrpayment-settings.settingForm.credentials.alert.errorMessage')
}); });
this.isTesting = false; this.isTesting = false;
}); });
},
onSalesChannelSwitchChange(id, onInput) {
this.selectedSalesChannelId = id;
if (typeof onInput === 'function') {
onInput(id);
}
} }
} }
}); });
@@ -1,105 +1,107 @@
{ {
"sw-privileges": { "sw-privileges": {
"permissions": { "permissions": {
"parents": { "parents": {
"vrpayment": "VRPayment plugin" "vrpayment": "VRPayment plugin"
}, },
"vrpayment": { "vrpayment": {
"label": "VRPayment permissions" "label": "VRPayment permissions"
} }
} }
}, },
"vrpayment-settings": { "vrpayment-settings": {
"general": { "general": {
"descriptionTextModule": "VRPayment settings", "descriptionTextModule": "VRPayment settings",
"mainMenuItemGeneral": "VRPayment" "mainMenuItemGeneral": "VRPayment"
}, },
"header": "VRPayment", "header": "VRPayment",
"messageNotBlank": "This value should not be blank.", "messageNotBlank": "This value should not be blank.",
"salesChannelCard": { "salesChannelCard": {
"button": { "button": {
"description": "Click this button to set VRPayment as default payment handler in the selected SalesChannel", "description": "Click this button to set VRPayment as default payment handler in the selected SalesChannel",
"label": "Set VRPayment as default payment handler" "label": "Set VRPayment as default payment handler"
}, },
"messageDefaultPaymentError": "VRPayment as default payment could not be set.", "messageDefaultPaymentError": "VRPayment as default payment could not be set.",
"messageDefaultPaymentUpdated": "VRPayment as default payment has been set." "messageDefaultPaymentUpdated": "VRPayment as default payment has been set."
}, },
"settingForm": { "settingForm": {
"credentials": { "credentials": {
"applicationKey": { "applicationKey": {
"label": "Application Key", "label": "Application Key",
"tooltipText": "The Application Key is used to authenticate this plugin with the VRPayment API." "tooltipText": "The Application Key is used to authenticate this plugin with the VRPayment API."
}, },
"cardTitle": "Credentials", "cardTitle": "Credentials",
"spaceId": { "spaceId": {
"label": "Space ID", "label": "Space ID",
"tooltipText": "The space ID is used to authenticate this plugin with the VRPayment API." "tooltipText": "The space ID is used to authenticate this plugin with the VRPayment API."
}, },
"userId": { "userId": {
"label": "User ID", "label": "User ID",
"tooltipText": "The user ID is used to authenticate this plugin with the VRPayment API." "tooltipText": "The user ID is used to authenticate this plugin with the VRPayment API."
}, },
"button": { "button": {
"description": "Click this button to test the VRPayment API", "description": "Click this button to test the VRPayment API",
"label": "API connection test" "label": "API connection test"
}, },
"alert": { "alert": {
"title": "API Test", "title": "API Test",
"successMessage": "The connection was successfully tested.", "successMessage": "The connection was successfully tested.",
"errorMessage": "The connection was failed. Try it again." "errorMessage": "The connection was failed. Try it again."
} }
}, },
"messageSaveSuccess": "VRPayment settings have been saved.", "messageSaveSuccess": "VRPayment settings have been saved.",
"messageOrderDeliveryStateError": "VRPayment OrderDeliveryState could not be saved.", "messageOrderDeliveryStateError": "VRPayment OrderDeliveryState could not be saved.",
"messageOrderDeliveryStateUpdated": "VRPayment OrderDeliveryState has been updated.", "messageOrderDeliveryStateUpdated": "VRPayment OrderDeliveryState has been updated.",
"messagePaymentMethodConfigurationError": "VRPayment PaymentMethodConfiguration could not be saved. Please check your credentials.", "messagePaymentMethodConfigurationError": "VRPayment PaymentMethodConfiguration could not be saved. Please check your credentials.",
"messagePaymentMethodConfigurationUpdated": "VRPayment PaymentMethodConfiguration has been registered.", "messagePaymentMethodConfigurationUpdated": "VRPayment PaymentMethodConfiguration has been registered.",
"messageWebHookError": "VRPayment WebHook could not be saved. Please check your credentials.", "messageWebHookError": "VRPayment WebHook could not be saved. Please check your credentials.",
"messageWebHookUpdated": "VRPayment WebHook has been updated.", "messageWebHookUpdated": "VRPayment WebHook has been updated.",
"options": { "messageHeadlessIntegrationError": "Iframe integration is only supported for Storefront Sales Channels.",
"cardTitle": "Options", "messageGlobalIframeError": "Iframe integration cannot be set globally because you have non-Storefront Sales Channels.",
"emailEnabled": { "options": {
"label": "Send order confirmation email", "cardTitle": "Options",
"tooltipText": "If this setting is enabled your customers will receive an email from your store when their order payment is authorised" "emailEnabled": {
}, "label": "Send order confirmation email",
"integration": { "tooltipText": "If this setting is enabled your customers will receive an email from your store when their order payment is authorised"
"label": "Integration", },
"options": { "integration": {
"iframe": "Iframe", "label": "Integration",
"payment_page": "Payment Page" "options": {
}, "iframe": "Iframe",
"tooltipText": "Integration" "payment_page": "Payment Page"
}, },
"lineItemConsistencyEnabled": { "tooltipText": "Integration"
"label": "Line item consistency", },
"tooltipText": "If this option is enabled line item totals in VRPaymentPayment will always match Shopware order total" "lineItemConsistencyEnabled": {
}, "label": "Line item consistency",
"spaceViewId": { "tooltipText": "If this option is enabled line item totals in VRPaymentPayment will always match Shopware order total"
"label": "Space View ID", },
"tooltipText": "Space View ID" "spaceViewId": {
} "label": "Space View ID",
}, "tooltipText": "Space View ID"
"save": "Save", }
"storefrontOptions": { },
"cardTitle": "Storefront Options", "save": "Save",
"invoiceDownloadEnabled": { "storefrontOptions": {
"label": "Invoice Download", "cardTitle": "Storefront Options",
"tooltipText": "If this setting is enabled your customers will be able to download order invoices from VRPayment" "invoiceDownloadEnabled": {
} "label": "Invoice Download",
}, "tooltipText": "If this setting is enabled your customers will be able to download order invoices from VRPayment"
"advancedOptions": { }
"cardTitle": "Advanced Options", },
"webhooksUpdateEnabled": { "advancedOptions": {
"label": "Webhooks Update", "cardTitle": "Advanced Options",
"tooltipText": "If this setting is enabled webhook update will be triggered when you save settings" "webhooksUpdateEnabled": {
}, "label": "Webhooks Update",
"paymentsUpdateEnabled": { "tooltipText": "If this setting is enabled webhook update will be triggered when you save settings"
"label": "Payments Update", },
"tooltipText": "If this setting is enabled payment methods update will be triggered when you save settings" "paymentsUpdateEnabled": {
} "label": "Payments Update",
}, "tooltipText": "If this setting is enabled payment methods update will be triggered when you save settings"
"titleError": "Error", }
"titleSuccess": "Success" },
} "titleError": "Error",
"titleSuccess": "Success"
}
} }
} }
File diff suppressed because one or more lines are too long
@@ -3,25 +3,170 @@
// noinspection NpmUsedModulesInstalled // noinspection NpmUsedModulesInstalled
import Plugin from 'src/plugin-system/plugin.class'; import Plugin from 'src/plugin-system/plugin.class';
import HttpClient from 'src/service/http-client.service'; import HttpClient from 'src/service/http-client.service';
import DomAccess from 'src/helper/dom-access.helper';
/**
* VRPaymentCheckoutPlugin
*
* This plugin handles the initialization and lifecycle of the WhitelabelMachineName payment iframe
* within the Shopware 6 checkout confirm page.
*/
class VRPaymentCheckoutPlugin extends Plugin { class VRPaymentCheckoutPlugin extends Plugin {
static options = { static options = {
payment_method_tabs: 'ul.vrpayment-payment-panel li', payment_panel_id: 'vrpayment-payment-panel',
payment_method_iframe_prefix: 'iframe_payment_method_', payment_method_iframe_id: 'vrpayment-payment-iframe',
payment_method_iframe_class: '.vrpayment-payment-iframe',
payment_method_handler_name: 'vrpayment_payment_handler',
payment_method_handler_prefix: 'vrpayment_handler_',
payment_method_handler_status: 'input[name="vrpayment_payment_handler_validation_status"]', payment_method_handler_status: 'input[name="vrpayment_payment_handler_validation_status"]',
payment_form: 'confirmOrderForm', payment_form_id: 'confirmOrderForm',
loader_id: 'vrpaymentLoader',
checkout_url_id: 'checkoutUrl',
cart_recreate_url_id: 'cartRecreateUrl',
}; };
/**
* Initializes the plugin, variables, and events.
*/
init() { init() {
// @TODO Move JS to Plugin try {
this._client = new HttpClient(window.accessKey); this._initVariables();
this._registerEvents();
this._getIframe();
} catch (e) {
// Silently fail if elements are not found; this allows the plugin to be loaded on pages where it might be conditionally absent.
}
} }
/**
* Finds and stores references to relevant DOM elements.
* @private
*/
_initVariables() {
this.checkoutUrl = DomAccess.getElement(document, `#${this.options.checkout_url_id}`).value;
this.cartRecreateUrl = DomAccess.getElement(document, `#${this.options.cart_recreate_url_id}`).value;
this.paymentForm = DomAccess.getElement(document, `#${this.options.payment_form_id}`);
this.paymentPanel = this.el;
this.iframeContainer = DomAccess.getElement(this.paymentPanel, `#${this.options.payment_method_iframe_id}`);
this.handler = null;
}
/**
* Registers event listeners for the payment form and browser history.
* @private
*/
_registerEvents() {
this.paymentForm.addEventListener('submit', this._submitPayment.bind(this), false);
// Handle back button/popstate to ensure cart consistency
window.addEventListener('popstate', this._onPopstate.bind(this), false);
window.history.pushState({}, document.title, this.cartRecreateUrl);
window.history.pushState({}, document.title, this.checkoutUrl);
}
/**
* Handles browser back button activity.
* @private
*/
_onPopstate() {
if (window.history.state == null) {
return;
}
window.location.href = this.cartRecreateUrl;
}
/**
* Intercepts the form submission to validate the iframe content first.
* @param {Event} event
* @private
*/
_submitPayment(event) {
this._activateLoader(true);
if (this.handler) {
this.handler.validate();
event.preventDefault();
return false;
}
}
/**
* Initializes the WhitelabelMachineName Iframe handler and creates the iframe.
* @private
*/
_getIframe() {
const paymentMethodConfigurationId = this.paymentPanel.dataset.id;
if (!this.handler) {
// IframeCheckoutHandler is expected to be global from the SDK script
if (typeof window.IframeCheckoutHandler === 'function') {
this.handler = window.IframeCheckoutHandler(paymentMethodConfigurationId);
this.handler.setValidationCallback(this._validationCallBack.bind(this));
this.handler.setInitializeCallback(this._hideLoader.bind(this));
this.handler.setHeightChangeCallback((height) => {
if (height < 1) {
this.handler.submit();
}
});
this.handler.create(this.iframeContainer);
setTimeout(this._hideLoader.bind(this), 10000);
}
}
}
/**
* Callback for iframe validation results.
* @param {Object} validationResult
* @private
*/
_validationCallBack(validationResult) {
const statusInputs = document.querySelectorAll(this.options.payment_method_handler_status);
if (validationResult.success) {
statusInputs.forEach(input => {
input.value = 'true';
});
this.handler.submit();
} else {
statusInputs.forEach(input => {
input.value = 'false';
});
this._activateLoader(false);
if (validationResult.errors) {
this._showErrors(validationResult.errors);
}
}
}
/**
* Enables or disables buttons on the page to indicate loading.
* @param {boolean} activate
* @private
*/
_activateLoader(activate) {
const buttons = document.querySelectorAll('button');
buttons.forEach(button => {
button.disabled = activate;
});
}
/**
* Hides the loader overlay once the iframe is ready.
* @private
*/
_hideLoader() {
const loader = document.getElementById(this.options.loader_id);
if (loader && loader.parentNode) {
loader.parentNode.removeChild(loader);
}
this._activateLoader(false);
}
/**
* Displays validation errors to the user.
* @param {Array} errors
* @private
*/
_showErrors(errors) {
// Fallback to alert if no native Shopware mechanism is easily accessible here
alert(errors.join('\n'));
}
} }
export default VRPaymentCheckoutPlugin; export default VRPaymentCheckoutPlugin;
+3 -1
View File
@@ -13,9 +13,11 @@
<import resource="./services/core/storefront/checkout.xml"/> <import resource="./services/core/storefront/checkout.xml"/>
<import resource="./services/core/checkout.xml"/> <import resource="./services/core/checkout.xml"/>
<import resource="./services/core/checkout_services.xml"/>
<import resource="./services/core/settings.xml"/> <import resource="./services/core/settings.xml"/>
<import resource="./services/core/store_api.xml"/>
<import resource="./services/core/util.xml"/> <import resource="./services/core/util.xml"/>
</imports> </imports>
<services> <services>
</services> </services>
</container> </container>
@@ -58,6 +58,8 @@
<argument type="service" id="service_container"/> <argument type="service" id="service_container"/>
<argument type="service" id="VRPaymentPayment\Core\Util\LocaleCodeProvider"/> <argument type="service" id="VRPaymentPayment\Core\Util\LocaleCodeProvider"/>
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/> <argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<!-- Cache for headless transaction persistence -->
<argument type="service" id="cache.system"/>
<call method="setLogger"> <call method="setLogger">
<argument type="service" id="monolog.logger.vrpayment_payment"/> <argument type="service" id="monolog.logger.vrpayment_payment"/>
</call> </call>
@@ -0,0 +1,47 @@
<?xml version="1.0" ?>
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="VRPaymentPayment\Core\Checkout\Service\TransactionManagementService">
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<!-- Cache for headless transaction persistence -->
<argument type="service" id="cache.system"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\Service\PaymentMethodFilterService">
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService"/>
<argument type="service" id="VRPaymentPayment\Core\Util\PaymentMethodUtil"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\TransactionManagementService"/>
<argument type="service" id="Shopware\Core\Checkout\Cart\SalesChannel\CartService"/>
<call method="setLogger">
<argument type="service" id="monolog.logger.vrpayment_payment"/>
</call>
</service>
<service id="VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService">
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\TransactionManagementService"/>
<argument type="service" id="router"/>
<argument type="service" id="VRPaymentPayment\Core\Util\LocaleCodeProvider"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\Service\CartRecoveryService">
<argument type="service" id="Shopware\Core\Checkout\Cart\SalesChannel\CartService"/>
<argument type="service" id="Shopware\Core\Checkout\Cart\LineItemFactoryRegistry"/>
<argument type="service" id="order.repository"/>
<argument type="service" id="Swag\CustomizedProducts\Core\Checkout\Cart\Route\AddCustomizedProductsToCartRoute" on-invalid="null"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\Service\InvoiceService">
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
</service>
</services>
</container>
@@ -0,0 +1,28 @@
<?xml version="1.0" ?>
<container xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="VRPaymentPayment\Core\Checkout\PaymentMethod\SalesChannel\PaymentMethodRouteDecorator"
decorates="Shopware\Core\Checkout\Payment\SalesChannel\PaymentMethodRoute"
decoration-inner-name="VRPaymentPayment\Core\Checkout\PaymentMethod\SalesChannel\PaymentMethodRouteDecorator.inner">
<argument type="service" id="VRPaymentPayment\Core\Checkout\PaymentMethod\SalesChannel\PaymentMethodRouteDecorator.inner"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\PaymentMethodFilterService"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\StoreApi\Route\WhitelabelMachineNameTransactionInfoRoute">
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\StoreApi\Route\CartRecoveryRoute">
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\CartRecoveryService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
</service>
<service id="VRPaymentPayment\Core\Checkout\StoreApi\Route\InvoiceRoute">
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\InvoiceService"/>
</service>
</services>
</container>
@@ -7,9 +7,9 @@
<services> <services>
<!-- Controllers --> <!-- Controllers -->
<service id="VRPaymentPayment\Core\Storefront\Account\Controller\AccountOrderController" public="true"> <service id="VRPaymentPayment\Core\Storefront\Account\Controller\AccountOrderController" public="true">
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/> <argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<argument type="service" id="Symfony\Component\HttpFoundation\RequestStack"/> <argument type="service" id="Symfony\Component\HttpFoundation\RequestStack"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\InvoiceService"/>
<call method="setLogger"> <call method="setLogger">
<argument type="service" id="monolog.logger.vrpayment_payment"/> <argument type="service" id="monolog.logger.vrpayment_payment"/>
</call> </call>
@@ -7,31 +7,25 @@
<services> <services>
<!-- Controllers --> <!-- Controllers -->
<service id="VRPaymentPayment\Core\Storefront\Checkout\Controller\CheckoutController" public="true"> <service id="VRPaymentPayment\Core\Storefront\Checkout\Controller\CheckoutController" public="true">
<argument type="service" id="Shopware\Core\Checkout\Cart\LineItemFactoryRegistry"/>
<argument type="service" id="Shopware\Core\Checkout\Cart\SalesChannel\CartService"/>
<argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/> <argument type="service" id="VRPaymentPayment\Core\Settings\Service\SettingsService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<argument type="service" id="Shopware\Storefront\Page\GenericPageLoader"/> <argument type="service" id="Shopware\Storefront\Page\GenericPageLoader"/>
<argument type="service" id="Shopware\Core\Checkout\Order\SalesChannel\OrderRoute"/> <argument type="service" id="Shopware\Core\Checkout\Order\SalesChannel\OrderRoute"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\CartRecoveryService"/>
<argument type="service" id="VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService"/>
<argument type="service" id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService"/>
<call method="setLogger"> <call method="setLogger">
<argument type="service" id="monolog.logger.vrpayment_payment"/> <argument type="service" id="monolog.logger.vrpayment_payment"/>
</call> </call>
<call method="setContainer"> <call method="setContainer">
<argument type="service" id="service_container"/> <argument type="service" id="service_container"/>
</call> </call>
<!-- Removed in 6.7 -->
<!-- <call method="setTwig">
<argument type="service" id="twig"/>
</call> -->
</service> </service>
<!-- Subscribers --> <!-- Subscribers -->
<service id="VRPaymentPayment\Core\Storefront\Checkout\Subscriber\CheckoutSubscriber"> <service id="VRPaymentPayment\Core\Storefront\Checkout\Subscriber\CheckoutSubscriber">
<argument id="VRPaymentPayment\Core\Api\PaymentMethodConfiguration\Service\PaymentMethodConfigurationService" type="service"/>
<argument id="VRPaymentPayment\Core\Api\Transaction\Service\TransactionService" type="service"/>
<argument id="VRPaymentPayment\Core\Settings\Service\SettingsService" type="service"/> <argument id="VRPaymentPayment\Core\Settings\Service\SettingsService" type="service"/>
<argument id="VRPaymentPayment\Core\Util\PaymentMethodUtil" type="service"/> <argument id="VRPaymentPayment\Core\Checkout\Service\PaymentMethodFilterService" type="service"/>
<argument id="payment_method.repository" type="service"/> <argument id="VRPaymentPayment\Core\Checkout\Service\PaymentIntegrationService" type="service"/>
<call method="setLogger"> <call method="setLogger">
<argument type="service" id="monolog.logger.vrpayment_payment"/> <argument type="service" id="monolog.logger.vrpayment_payment"/>
</call> </call>
@@ -7,7 +7,7 @@
], ],
"dynamic": [], "dynamic": [],
"js": [ "js": [
"/bundles/vrpaymentpayment/administration/assets/v-r-payment-payment-Cp2eQSV_.js" "/bundles/vrpaymentpayment/administration/assets/v-r-payment-payment-CPfpiGQp.js"
], ],
"legacy": false, "legacy": false,
"preload": [] "preload": []
@@ -1,6 +1,6 @@
{ {
"main.js": { "main.js": {
"file": "assets/v-r-payment-payment-Cp2eQSV_.js", "file": "assets/v-r-payment-payment-CPfpiGQp.js",
"name": "v-r-payment-payment", "name": "v-r-payment-payment",
"src": "main.js", "src": "main.js",
"isEntry": true, "isEntry": true,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{% sw_extends '@Storefront/storefront/component/payment/payment-method.html.twig' %}
{% block component_payment_method_description %}
{{ parent() }}
{# Check if the payment method handler belongs to WhitelabelMachineName #}
{% if payment.handlerIdentifier == 'VRPaymentPayment\\Core\\Checkout\\PaymentHandler\\VRPaymentPaymentHandler' %}
{# Retrieve the runtime-attached configuration from the extension #}
{% set vrpConfig = payment.extensions.vrpayment_config %}
{% if vrpConfig %}
{% set paymentMethodConfigurationId = vrpConfig.paymentMethodConfigurationId %}
{# Include the iframe container template #}
{% sw_include '@VRPaymentPayment/storefront/component/payment/vrpayment_iframe.html.twig' with {
'paymentMethodConfigurationId': paymentMethodConfigurationId
} %}
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,10 @@
<div id="vrpayment-payment-panel"
class="vrpayment-payment-panel"
data-vrpayment-checkout-plugin="true"
data-id="{{ paymentMethodConfigurationId }}">
<div id="vrpaymentLoader"><div></div></div>
<input value="false" type="hidden" name="vrpayment_payment_handler_validation_status"
form="confirmOrderForm">
<div id="vrpayment-payment-iframe"
class="vrpayment-payment-iframe"></div>
</div>
@@ -0,0 +1,26 @@
{% sw_extends '@Storefront/storefront/page/checkout/confirm/index.html.twig' %}
{% block base_body_script %}
{{ parent() }}
{# Provide WhitelabelMachineName integration data if available in page extensions #}
{% if page.extensions.vRPaymentData %}
{% set vrpData = page.extensions.vRPaymentData %}
{# Hidden inputs required by the JS components #}
<input type="hidden" id="cartRecreateUrl" value="{{ vrpData.cartRecreateUrl }}" />
<input type="hidden" id="checkoutUrl" value="{{ vrpData.checkoutUrl }}" />
{# Load WhitelabelMachineName SDK scripts #}
{% if vrpData.deviceJavascriptUrl %}
<script src="{{ vrpData.deviceJavascriptUrl }}" async="async"></script>
{% endif %}
{% if vrpData.javascriptUrl %}
<script src="{{ vrpData.javascriptUrl }}"></script>
{% endif %}
{# We don't need app.js if we use the modern plugin, but we might keep it for now if other pages depend on it.
However, for the confirm page, the modern plugin registered in main.js will take over
whenever it finds [data-vrpayment-checkout-plugin]. #}
{% endif %}
{% endblock %}