diff --git a/src/Components/CardRepository/CardRepository.php b/src/Components/CardRepository/CardRepository.php index 53a0fd38d..0b15eb0b5 100644 --- a/src/Components/CardRepository/CardRepository.php +++ b/src/Components/CardRepository/CardRepository.php @@ -22,6 +22,7 @@ public function __construct(private readonly EntityRepository $cardRepository) public function saveCard( CustomerEntity $customer, + string $cardHolder, string $truncatedCardPan, string $pseudoCardPan, string $cardType, @@ -39,6 +40,7 @@ public function saveCard( $data = [ 'id' => $card === null ? Uuid::randomHex() : $card->getId(), + 'cardHolder' => $cardHolder, 'pseudoCardPan' => $pseudoCardPan, 'truncatedCardPan' => $truncatedCardPan, 'cardType' => $cardType, @@ -96,15 +98,15 @@ public function removeAllCardsForCustomer(CustomerEntity $customer, Context $con } public function getExistingCard( - CustomerEntity $customer, + CustomerEntity|string $customer, string $pseudoCardPan, Context $context ): ?PayonePaymentCardEntity { $criteria = new Criteria(); $criteria->addFilter( - new EqualsFilter('payone_payment_card.pseudoCardPan', $pseudoCardPan), - new EqualsFilter('payone_payment_card.customerId', $customer->getId()) + new EqualsFilter('pseudoCardPan', $pseudoCardPan), + new EqualsFilter('customerId', \is_string($customer) ? $customer : $customer->getId()) ); /** @var PayonePaymentCardEntity|null $card */ @@ -112,4 +114,19 @@ public function getExistingCard( return $card; } + + /** + * TODO-card-holder-requirement: remove this method (please see credit-card handler) + * @deprecated + */ + public function saveMissingCardHolder(string $cardId, string $customerId, mixed $cardHolder, Context $context): void + { + $this->cardRepository->upsert([ + [ + 'id' => $cardId, + 'customerId' => $customerId, + 'cardHolder' => $cardHolder, + ], + ], $context); + } } diff --git a/src/Components/CardRepository/CardRepositoryInterface.php b/src/Components/CardRepository/CardRepositoryInterface.php index 8a30a7bde..6e1b4ee3a 100644 --- a/src/Components/CardRepository/CardRepositoryInterface.php +++ b/src/Components/CardRepository/CardRepositoryInterface.php @@ -13,6 +13,7 @@ interface CardRepositoryInterface { public function saveCard( CustomerEntity $customer, + string $cardHolder, string $truncatedCardPan, string $pseudoCardPan, string $cardType, @@ -37,8 +38,14 @@ public function removeAllCardsForCustomer( ): void; public function getExistingCard( - CustomerEntity $customer, + CustomerEntity|string $customer, string $pseudoCardPan, Context $context ): ?PayonePaymentCardEntity; + + /** + * TODO-card-holder-requirement: remove this method (please see credit-card handler) + * @deprecated + */ + public function saveMissingCardHolder(string $cardId, string $customerId, mixed $cardHolder, Context $context): void; } diff --git a/src/Components/GenericExpressCheckout/PaymentHandler/AbstractGenericExpressCheckoutPaymentHandler.php b/src/Components/GenericExpressCheckout/PaymentHandler/AbstractGenericExpressCheckoutPaymentHandler.php index 862dd02ac..a9e5c61a6 100644 --- a/src/Components/GenericExpressCheckout/PaymentHandler/AbstractGenericExpressCheckoutPaymentHandler.php +++ b/src/Components/GenericExpressCheckout/PaymentHandler/AbstractGenericExpressCheckoutPaymentHandler.php @@ -10,6 +10,7 @@ use Shopware\Core\Checkout\Payment\Cart\AsyncPaymentTransactionStruct; use Shopware\Core\Checkout\Payment\Exception\AsyncPaymentProcessException; use Shopware\Core\Checkout\Payment\PaymentException; +use Shopware\Core\Framework\Validation\DataBag\DataBag; use Shopware\Core\Framework\Validation\DataBag\RequestDataBag; use Shopware\Core\System\SalesChannel\SalesChannelContext; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -26,9 +27,9 @@ public static function isCapturable(array $transactionData, array $payoneTransAc return static::isTransactionAppointedAndCompleted($transactionData) || static::matchesIsCapturableDefaults($transactionData); } - public function getValidationDefinitions(SalesChannelContext $salesChannelContext): array + public function getValidationDefinitions(DataBag $dataBag, SalesChannelContext $salesChannelContext): array { - return array_merge(parent::getValidationDefinitions($salesChannelContext), [ + return array_merge(parent::getValidationDefinitions($dataBag, $salesChannelContext), [ RequestConstants::WORK_ORDER_ID => [new NotBlank()], RequestConstants::CART_HASH => [new NotBlank()], ]); diff --git a/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardDefinition.php b/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardDefinition.php index 8231c4234..fe63e4000 100644 --- a/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardDefinition.php +++ b/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardDefinition.php @@ -49,6 +49,7 @@ protected function defineFields(): FieldCollection (new IdField('id', 'id'))->setFlags(new PrimaryKey(), new Required()), (new FkField('customer_id', 'customerId', CustomerDefinition::class))->addFlags(new Required()), + (new StringField('card_holder', 'cardHolder'))->setFlags(new Required()), $pseudoCardPanField, (new StringField('truncated_card_pan', 'truncatedCardPan'))->setFlags(new Required()), diff --git a/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardEntity.php b/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardEntity.php index 8965db62e..bae5001bb 100644 --- a/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardEntity.php +++ b/src/DataAbstractionLayer/Entity/Card/PayonePaymentCardEntity.php @@ -24,6 +24,11 @@ class PayonePaymentCardEntity extends Entity protected string $customerId; + /** + * @since 6.2.0 + */ + protected string $cardHolder; + public function getPseudoCardPan(): string { return $this->pseudoCardPan; @@ -83,4 +88,9 @@ public function getCustomerId(): string { return $this->customerId; } + + public function getCardHolder(): string + { + return $this->cardHolder; + } } diff --git a/src/DependencyInjection/requestParameter/builder.xml b/src/DependencyInjection/requestParameter/builder.xml index 355fad2fe..0beee6860 100644 --- a/src/DependencyInjection/requestParameter/builder.xml +++ b/src/DependencyInjection/requestParameter/builder.xml @@ -160,7 +160,7 @@ + parent="PayonePayment\Payone\RequestParameter\Builder\AbstractRequestParameterBuilder" autowire="true"> diff --git a/src/Migration/Migration1718642595CreditCardAddCardHolder.php b/src/Migration/Migration1718642595CreditCardAddCardHolder.php new file mode 100644 index 000000000..5737c8be6 --- /dev/null +++ b/src/Migration/Migration1718642595CreditCardAddCardHolder.php @@ -0,0 +1,30 @@ +executeStatement( + <<get(self::REQUEST_PARAM_SAVED_PSEUDO_CARD_PAN))) { + $definitions[self::REQUEST_PARAM_PSEUDO_CARD_PAN] = [new NotBlank()]; + $definitions[self::REQUEST_PARAM_TRUNCATED_CARD_PAN] = [new NotBlank()]; + $definitions[self::REQUEST_PARAM_CARD_EXPIRE_DATE] = [new NotBlank()]; + $definitions[self::REQUEST_PARAM_CARD_TYPE] = [new NotBlank()]; + } + + return $definitions; + } + public static function isCapturable(array $transactionData, array $payoneTransActionData): bool { if (static::isNeverCapturable($payoneTransActionData)) { return false; } - $txAction = isset($transactionData['txaction']) ? strtolower((string) $transactionData['txaction']) : null; + $txAction = isset($transactionData['txaction']) ? strtolower((string)$transactionData['txaction']) : null; if ($txAction === TransactionStatusService::ACTION_APPOINTED) { return true; @@ -106,6 +133,9 @@ protected function handleResponse( $customer = $salesChannelContext->getCustomer(); $savedPseudoCardPan = $dataBag->get(self::REQUEST_PARAM_SAVED_PSEUDO_CARD_PAN); + // TODO-card-holder-requirement: move the next line into the if-block if the $savedPseudoCardPan is empty (please see credit-card handler) + $cardHolder = $dataBag->get(self::REQUEST_PARAM_CARD_HOLDER); + if (empty($savedPseudoCardPan)) { $truncatedCardPan = $dataBag->get(self::REQUEST_PARAM_TRUNCATED_CARD_PAN); $cardExpireDate = $dataBag->get(self::REQUEST_PARAM_CARD_EXPIRE_DATE); @@ -117,6 +147,7 @@ protected function handleResponse( if (!empty($expiresAt) && $customer !== null && $saveCreditCard) { $this->cardRepository->saveCard( $customer, + $cardHolder, $truncatedCardPan, $pseudoCardPan, $cardType, @@ -134,6 +165,11 @@ protected function handleResponse( $salesChannelContext->getContext() ); + // TODO-card-holder-requirement: remove this if-statement incl. content (please see credit-card handler) + if ($savedCard instanceof PayonePaymentCardEntity && empty($savedCard->getCardHolder())) { + $this->cardRepository->saveMissingCardHolder($savedCard->getId(), $customer->getId(), $cardHolder, $salesChannelContext->getContext()); + } + $cardType = $savedCard ? $savedCard->getCardType() : ''; } } @@ -152,7 +188,7 @@ protected function handleResponse( protected function getRedirectResponse(SalesChannelContext $context, array $request, array $response): RedirectResponse { - if (strtolower((string) $response['status']) === 'redirect') { + if (strtolower((string)$response['status']) === 'redirect') { return new RedirectResponse($response['redirecturl']); } diff --git a/src/Payone/RequestParameter/Builder/CreditCard/AuthorizeRequestParameterBuilder.php b/src/Payone/RequestParameter/Builder/CreditCard/AuthorizeRequestParameterBuilder.php index f44a5a860..5d7359842 100644 --- a/src/Payone/RequestParameter/Builder/CreditCard/AuthorizeRequestParameterBuilder.php +++ b/src/Payone/RequestParameter/Builder/CreditCard/AuthorizeRequestParameterBuilder.php @@ -4,24 +4,41 @@ namespace PayonePayment\Payone\RequestParameter\Builder\CreditCard; +use PayonePayment\Components\CardRepository\CardRepository; use PayonePayment\PaymentHandler\PayoneCreditCardPaymentHandler; use PayonePayment\Payone\RequestParameter\Builder\AbstractRequestParameterBuilder; +use PayonePayment\Payone\RequestParameter\Builder\RequestBuilderServiceAccessor; use PayonePayment\Payone\RequestParameter\Struct\AbstractRequestParameterStruct; use PayonePayment\Payone\RequestParameter\Struct\PaymentTransactionStruct; class AuthorizeRequestParameterBuilder extends AbstractRequestParameterBuilder { + public function __construct( + RequestBuilderServiceAccessor $serviceAccessor, + private readonly CardRepository $cardRepository + ) { + parent::__construct($serviceAccessor); + } + /** * @param PaymentTransactionStruct $arguments */ public function getRequestParameter(AbstractRequestParameterStruct $arguments): array { + $cardHolder = $arguments->getRequestData()->get(PayoneCreditCardPaymentHandler::REQUEST_PARAM_CARD_HOLDER); $cardType = $arguments->getRequestData()->get(PayoneCreditCardPaymentHandler::REQUEST_PARAM_CARD_TYPE); $pseudoCardPan = $arguments->getRequestData()->get(PayoneCreditCardPaymentHandler::REQUEST_PARAM_PSEUDO_CARD_PAN); $savedPseudoCardPan = $arguments->getRequestData()->get(PayoneCreditCardPaymentHandler::REQUEST_PARAM_SAVED_PSEUDO_CARD_PAN); - if (!empty($savedPseudoCardPan)) { - $pseudoCardPan = $savedPseudoCardPan; + $salesChannelContext = $arguments->getSalesChannelContext(); + if (!empty($savedPseudoCardPan) && !empty($salesChannelContext->getCustomerId())) { + $savedCard = $this->cardRepository->getExistingCard( + $salesChannelContext->getCustomerId(), + $savedPseudoCardPan, + $salesChannelContext->getContext() + ); + $pseudoCardPan = $savedCard?->getPseudoCardPan() ?: ''; + $cardHolder = $savedCard?->getCardHolder() ?: $cardHolder; } return [ @@ -29,6 +46,7 @@ public function getRequestParameter(AbstractRequestParameterStruct $arguments): 'request' => $arguments->getAction(), 'pseudocardpan' => $pseudoCardPan, 'cardtype' => $cardType, + 'cardholder' => $cardHolder, ]; } @@ -39,6 +57,6 @@ public function supports(AbstractRequestParameterStruct $arguments): bool } return $arguments->getPaymentMethod() === PayoneCreditCardPaymentHandler::class - && in_array($arguments->getAction(), [self::REQUEST_ACTION_PREAUTHORIZE, self::REQUEST_ACTION_AUTHORIZE]); + && \in_array($arguments->getAction(), [self::REQUEST_ACTION_PREAUTHORIZE, self::REQUEST_ACTION_AUTHORIZE], true); } } diff --git a/src/Resources/app/storefront/cypress/e2e/payment-method/creditcard.cy.js b/src/Resources/app/storefront/cypress/e2e/payment-method/creditcard.cy.js index 5e0f1638a..8eecf64cb 100644 --- a/src/Resources/app/storefront/cypress/e2e/payment-method/creditcard.cy.js +++ b/src/Resources/app/storefront/cypress/e2e/payment-method/creditcard.cy.js @@ -41,13 +41,14 @@ describe('Credit Card', () => { cy.checkoutConfirmAndComplete( () => { cy.get('@creditCardPan').then((storedPan) => { - cy.get('#savedpseudocardpan').select(storedPan) + cy.get('#savedpseudocardpan').select(storedPan); cy.get('.credit-card-input').should('not.be.visible'); }) }); }); function fillIframe(iban = '4111111111111111') { + cy.get('#creditCardHolder').type('credit card holder'); setIframeValue('cardpan', 'input', iban); setIframeValue('cardcvc2', 'input', '123'); setIframeValue('cardexpiremonth', 'select', 5); diff --git a/src/Resources/app/storefront/dist/storefront/js/payone-payment/payone-payment.js b/src/Resources/app/storefront/dist/storefront/js/payone-payment/payone-payment.js index 9151d0238..ca5c5abe6 100644 --- a/src/Resources/app/storefront/dist/storefront/js/payone-payment/payone-payment.js +++ b/src/Resources/app/storefront/dist/storefront/js/payone-payment/payone-payment.js @@ -1 +1 @@ -(()=>{"use strict";var e={857:e=>{var t=function(e){var t;return!!e&&"object"==typeof e&&"[object RegExp]"!==(t=Object.prototype.toString.call(e))&&"[object Date]"!==t&&e.$$typeof!==n},n="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function r(e,t){return!1!==t.clone&&t.isMergeableObject(e)?s(Array.isArray(e)?[]:{},e,t):e}function a(e,t,n){return e.concat(t).map(function(e){return r(e,n)})}function i(e){return Object.keys(e).concat(Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(e).filter(function(t){return Object.propertyIsEnumerable.call(e,t)}):[])}function o(e,t){try{return t in e}catch(e){return!1}}function s(e,n,l){(l=l||{}).arrayMerge=l.arrayMerge||a,l.isMergeableObject=l.isMergeableObject||t,l.cloneUnlessOtherwiseSpecified=r;var d,c,u=Array.isArray(n);return u!==Array.isArray(e)?r(n,l):u?l.arrayMerge(e,n,l):(c={},(d=l).isMergeableObject(e)&&i(e).forEach(function(t){c[t]=r(e[t],d)}),i(n).forEach(function(t){(!o(e,t)||Object.hasOwnProperty.call(e,t)&&Object.propertyIsEnumerable.call(e,t))&&(o(e,t)&&d.isMergeableObject(n[t])?c[t]=(function(e,t){if(!t.customMerge)return s;var n=t.customMerge(e);return"function"==typeof n?n:s})(t,d)(e[t],n[t],d):c[t]=r(n[t],d))}),c)}s.all=function(e,t){if(!Array.isArray(e))throw Error("first argument should be an array");return e.reduce(function(e,n){return s(e,n,t)},{})},e.exports=s}},t={};function n(r){var a=t[r];if(void 0!==a)return a.exports;var i=t[r]={exports:{}};return e[r](i,i.exports,n),i.exports}(()=>{n.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t}})(),(()=>{n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})}})(),(()=>{n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t)})(),(()=>{var e=n(857),t=n.n(e);class r{static ucFirst(e){return e.charAt(0).toUpperCase()+e.slice(1)}static lcFirst(e){return e.charAt(0).toLowerCase()+e.slice(1)}static toDashCase(e){return e.replace(/([A-Z])/g,"-$1").replace(/^-/,"").toLowerCase()}static toLowerCamelCase(e,t){let n=r.toUpperCamelCase(e,t);return r.lcFirst(n)}static toUpperCamelCase(e,t){return t?e.split(t).map(e=>r.ucFirst(e.toLowerCase())).join(""):r.ucFirst(e.toLowerCase())}static parsePrimitive(e){try{return/^\d+(.|,)\d+$/.test(e)&&(e=e.replace(",",".")),JSON.parse(e)}catch(t){return e.toString()}}}class a{static isNode(e){return"object"==typeof e&&null!==e&&(e===document||e===window||e instanceof Node)}static hasAttribute(e,t){if(!a.isNode(e))throw Error("The element must be a valid HTML Node!");return"function"==typeof e.hasAttribute&&e.hasAttribute(t)}static getAttribute(e,t){let n=!(arguments.length>2)||void 0===arguments[2]||arguments[2];if(n&&!1===a.hasAttribute(e,t))throw Error('The required property "'.concat(t,'" does not exist!'));if("function"!=typeof e.getAttribute){if(n)throw Error("This node doesn't support the getAttribute function!");return}return e.getAttribute(t)}static getDataAttribute(e,t){let n=!(arguments.length>2)||void 0===arguments[2]||arguments[2],i=t.replace(/^data(|-)/,""),o=r.toLowerCamelCase(i,"-");if(!a.isNode(e)){if(n)throw Error("The passed node is not a valid HTML Node!");return}if(void 0===e.dataset){if(n)throw Error("This node doesn't support the dataset attribute!");return}let s=e.dataset[o];if(void 0===s){if(n)throw Error('The required data attribute "'.concat(t,'" does not exist on ').concat(e,"!"));return s}return r.parsePrimitive(s)}static querySelector(e,t){let n=!(arguments.length>2)||void 0===arguments[2]||arguments[2];if(n&&!a.isNode(e))throw Error("The parent node is not a valid HTML Node!");let r=e.querySelector(t)||!1;if(n&&!1===r)throw Error('The required element "'.concat(t,'" does not exist in parent node!'));return r}static querySelectorAll(e,t){let n=!(arguments.length>2)||void 0===arguments[2]||arguments[2];if(n&&!a.isNode(e))throw Error("The parent node is not a valid HTML Node!");let r=e.querySelectorAll(t);if(0===r.length&&(r=!1),n&&!1===r)throw Error('At least one item of "'.concat(t,'" must exist in parent node!'));return r}}class i{publish(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],r=new CustomEvent(e,{detail:t,cancelable:n});return this.el.dispatchEvent(r),r}subscribe(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=this,a=e.split("."),i=n.scope?t.bind(n.scope):t;if(n.once&&!0===n.once){let t=i;i=function(n){r.unsubscribe(e),t(n)}}return this.el.addEventListener(a[0],i),this.listeners.push({splitEventName:a,opts:n,cb:i}),!0}unsubscribe(e){let t=e.split(".");return this.listeners=this.listeners.reduce((e,n)=>([...n.splitEventName].sort().toString()===t.sort().toString()?this.el.removeEventListener(n.splitEventName[0],n.cb):e.push(n),e),[]),!0}reset(){return this.listeners.forEach(e=>{this.el.removeEventListener(e.splitEventName[0],e.cb)}),this.listeners=[],!0}get el(){return this._el}set el(e){this._el=e}get listeners(){return this._listeners}set listeners(e){this._listeners=e}constructor(e=document){this._el=e,e.$emitter=this,this._listeners=[]}}class o{init(){throw Error('The "init" method for the plugin "'.concat(this._pluginName,'" is not defined.'))}update(){}_init(){this._initialized||(this.init(),this._initialized=!0)}_update(){this._initialized&&this.update()}_mergeOptions(e){let n=r.toDashCase(this._pluginName),i=a.getDataAttribute(this.el,"data-".concat(n,"-config"),!1),o=a.getAttribute(this.el,"data-".concat(n,"-options"),!1),s=[this.constructor.options,this.options,e];i&&s.push(window.PluginConfigManager.get(this._pluginName,i));try{o&&s.push(JSON.parse(o))}catch(e){throw console.error(this.el),Error('The data attribute "data-'.concat(n,'-options" could not be parsed to json: ').concat(e.message))}return t().all(s.filter(e=>e instanceof Object&&!(e instanceof Array)).map(e=>e||{}))}_registerInstance(){window.PluginManager.getPluginInstancesFromElement(this.el).set(this._pluginName,this),window.PluginManager.getPlugin(this._pluginName,!1).get("instances").push(this)}_getPluginName(e){return e||(e=this.constructor.name),e}constructor(e,t={},n=!1){if(!a.isNode(e))throw Error("There is no valid element given.");this.el=e,this.$emitter=new i(this.el),this._pluginName=this._getPluginName(n),this.options=this._mergeOptions(t),this._initialized=!1,this._registerInstance(),this._init()}}class s{static iterate(e,t){if(e instanceof Map||Array.isArray(e))return e.forEach(t);if(e instanceof FormData){for(var n of e.entries())t(n[1],n[0]);return}if(e instanceof NodeList)return e.forEach(t);if(e instanceof HTMLCollection)return Array.from(e).forEach(t);if(e instanceof Object)return Object.keys(e).forEach(n=>{t(e[n],n)});throw Error("The element type ".concat(typeof e," is not iterable!"))}}let l="loader",d={BEFORE:"before",INNER:"inner"};class c{create(){if(!this.exists()){if(this.position===d.INNER){this.parent.innerHTML=c.getTemplate();return}this.parent.insertAdjacentHTML(this._getPosition(),c.getTemplate())}}remove(){let e=this.parent.querySelectorAll(".".concat(l));s.iterate(e,e=>e.remove())}exists(){return this.parent.querySelectorAll(".".concat(l)).length>0}_getPosition(){return this.position===d.BEFORE?"afterbegin":"beforeend"}static getTemplate(){return'
\n Loading...\n
')}static SELECTOR_CLASS(){return l}constructor(e,t=d.BEFORE){this.parent=e instanceof Element?e:document.body.querySelector(e),this.position=t}}class u extends c{create(){super.create(),this.parent.disabled=!0}remove(){super.remove(),this.parent.disabled=!1}_isButtonElement(){return"button"===this.parent.tagName.toLowerCase()}constructor(e,t="before"){if(super(e,t),!1===this._isButtonElement())throw Error("Parent element is not of type