diff --git a/AGENTS.md b/AGENTS.md index 73da689..afeca1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,12 +147,19 @@ source of truth. fails saves only on definitive rejections (401/403/404 from /v4/processing_accounts); transport errors and 5xx never block - trust admin data when it cannot be verified. -- The in-shop method grid is opt-in via gateway config `display_mode` - (`EveryPayGateway::DISPLAY_MODE_*`); the capture handler stores sanitized - `payment_methods` (only entries with a per-method payment_link) in the - payment request responseData, and EveryPayHttpResponseProvider renders - templates/shop/method_grid.html.twig instead of redirecting. Always keep - the redirect fallback - EveryPay may return no per-method links. +- The in-shop method grid and the embedded checkout are opt-in via gateway + config `display_mode` (`EveryPayGateway::DISPLAY_MODE_*`); the capture + handler stores sanitized `payment_methods` (only entries with a per-method + payment_link) - and, for the embedded checkout, a `payment_elements` blob + (mobile_access_token + SDK options) - in the payment request responseData, + and EveryPayHttpResponseProvider renders + templates/shop/method_grid.html.twig or + templates/shop/payment_elements.html.twig instead of redirecting. Always + keep the redirect fallback - EveryPay may return no per-method links and + no mobile_access_token. The Payment Elements mode is **experimental**: the + SDK is undocumented for custom integrations; the reverse-engineered + contract lives in docs/everypay-api.md and must not be extended without + re-verifying against EveryPay's own WooCommerce plugin or their answer. - `EveryPayGateway` holds all shared constants (factory name, config keys, base URLs, payment-details helpers). Don't scatter string literals. - **The after-pay URL is a seam** (`Provider/AfterPayUrlProviderInterface`): @@ -200,7 +207,9 @@ source of truth. ## Roadmap (see README) Partial refunds via `sylius/refund-plugin` (adoption path documented in -`docs/architecture.md` - the workflow listener must be guarded when adopted), -tokenized/CIT payments, and embedded in-shop checkout via the EveryPay -Payment Elements JS SDK (blocked on EveryPay confirming/documenting it for -custom integrations - see docs/everypay-api.md). +`docs/architecture.md` - the workflow listener must be guarded when adopted) +and tokenized/CIT payments. The embedded in-shop checkout via the EveryPay +Payment Elements JS SDK is implemented as the experimental `payment_elements` +display mode - built against the reverse-engineered contract of EveryPay's +own WooCommerce 2.x plugin, awaiting EveryPay's confirmation for custom +integrations (see docs/everypay-api.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 21450e9..8148e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Experimental embedded in-shop checkout via the EveryPay Payment Elements + JS SDK: a third "Checkout appearance" choice renders the card / bank / + wallet form on a page of the shop (`mobile_payment` one-offs; the card + fields stay in EveryPay's iframe, keeping the integration SAQ A). Falls + back to the hosted page redirect whenever EveryPay returns no + `mobile_access_token` or the SDK fails to load. Built against the SDK + contract of EveryPay's own WooCommerce 2.x plugin - the SDK is not yet + documented for custom integrations, so the mode is marked experimental. + ## [0.5.0] - 2026-08-04 ### Added diff --git a/README.md b/README.md index 6a63927..6ecfaca 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ In the Sylius admin: *Payment methods -> Create*, choose the **EveryPay | API username / API secret | EveryPay merchant portal -> *Merchant settings -> General* | | Processing account | e.g. `EUR3D1` - shown in the portal; fixes the currency and available methods | | Environment | Demo (`igw-demo.every-pay.com`) or Live (`pay.every-pay.eu`) | -| Checkout appearance | Redirect to EveryPay (default), or show the payment method buttons in the shop | +| Checkout appearance | Redirect to EveryPay (default), show the payment method buttons in the shop, or the experimental embedded checkout (see the [roadmap](#roadmap)) | | Merchant portal address | Optional. Banks white-label the merchant portal under their own address (e.g. SEB: `portal.ecommerce.sebgroup.com`); admin pages link there instead of `portal.every-pay.eu` | ![EveryPay gateway configuration form](docs/images/admin-gateway-config.png) @@ -206,7 +206,10 @@ This repository is set up for AI-agent-assisted development - see - Embedded in-shop checkout via the EveryPay **Payment Elements** JS SDK - the element EveryPay's own platform plugins mount in-page (`mobile_payment` one-offs, hosted iframe card form, SAQ A per EveryPay's PCI classification). - Blocked until EveryPay confirms/documents the SDK for custom integrations - + **Implemented as the experimental `payment_elements` display mode**, built + against the reverse-engineered contract of EveryPay's own WooCommerce 2.x + plugin; stays experimental (with an automatic hosted-page fallback) until + EveryPay confirms/documents the SDK for custom integrations - see [docs/everypay-api.md](docs/everypay-api.md#payment-elements-embedded-checkout) - In-shop **Apple Pay / Google Pay buttons** - both wallets already work on the hosted payment page with no plugin changes (enable them on the processing diff --git a/docs/architecture.md b/docs/architecture.md index 0726f3d..3c8ab47 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ command on the synchronous `sylius.payment_request.command_bus`. | Flow | Trigger | Chain | |---|---|---| -| **Capture** | customer completes checkout -> `GET /order/{token}/pay` -> Sylius creates PR(action=capture) -> redirect `/payment-request/pay/{hash}` | `EveryPayCommandProvider` -> `CaptureEveryPayPaymentHandler`: first takes a **pessimistic row lock** on the PR (`$em->refresh($pr, PESSIMISTIC_WRITE)`, transaction supplied by the bus' `doctrine_transaction` middleware) - concurrent `/pay/{hash}` requests (double-click, browser retry) serialize instead of both creating an EveryPay payment for the same `order_reference` and the loser wrongly failing the payment. Then: builds the oneoff payload (`EveryPayOneOffPayloadFactory`), `POST /v4/payments/oneoff`, stores `payment_reference`+`payment_link` in `payment.details['everypay']` and PR responseData, PR -> processing. Then `EveryPayHttpResponseProvider` redirects the customer to `payment_link` (303). API failure: PR + payment -> failed, **no exception** - Sylius falls back to after-pay, the customer gets a failed flash and Sylius auto-creates a fresh `new` payment for retry. | +| **Capture** | customer completes checkout -> `GET /order/{token}/pay` -> Sylius creates PR(action=capture) -> redirect `/payment-request/pay/{hash}` | `EveryPayCommandProvider` -> `CaptureEveryPayPaymentHandler`: first takes a **pessimistic row lock** on the PR (`$em->refresh($pr, PESSIMISTIC_WRITE)`, transaction supplied by the bus' `doctrine_transaction` middleware) - concurrent `/pay/{hash}` requests (double-click, browser retry) serialize instead of both creating an EveryPay payment for the same `order_reference` and the loser wrongly failing the payment. Then: builds the oneoff payload (`EveryPayOneOffPayloadFactory`; with the embedded checkout configured, `mobile_payment: true` is added), `POST /v4/payments/oneoff`, stores `payment_reference`+`payment_link` in `payment.details['everypay']` and PR responseData (plus, for the embedded checkout, a `payment_elements` blob with the `mobile_access_token` and the SDK options mirroring the payload), PR -> processing. Then `EveryPayHttpResponseProvider` redirects the customer to `payment_link` (303) - or renders the method grid / the embedded checkout, per the configured display mode. API failure: PR + payment -> failed, **no exception** - Sylius falls back to after-pay, the customer gets a failed flash and Sylius auto-creates a fresh `new` payment for retry. | | **Status** (customer return) | EveryPay redirects to `customer_url` = `/order/after-pay/{hash}` -> Sylius clones the PR as action=status | `StatusEveryPayPaymentHandler` -> `EveryPayPaymentSynchronizer` (below). An API failure is swallowed (PR -> failed): the payment stays processing, callbacks settle it later. | | **Notify** (server callback) | EveryPay hits `/payment-methods/{code}?payment_reference=...&event_name=...` (static URL configured in the merchant portal) | Sylius `PaymentMethodNotifyAction` -> `EveryPayNotifyPaymentProvider` resolves the Payment (`details LIKE '%%'`, scoped to the method; malformed -> 400, unknown -> 404, both side-effect-free) -> PR(action=notify) -> `NotifyEveryPayPaymentHandler` -> synchronizer. An API failure **propagates** -> non-2xx response -> EveryPay redelivers (6 retries / 72 h). Success -> 204. | | **Refund** (admin) | Core admin Refund button applies the `sylius_payment` `refund` transition -> `workflow.sylius_payment.completed.refund` event | `RefundEveryPayPaymentListener` (guards: core payment, everypay factory, not synchronizer-initiated) wraps *create PR(refund) + announce* in an explicit DBAL transaction. `RefundEveryPayPaymentHandler` calls `POST /v4/payments/refund` (full amount). Failure -> rollback + `UpdateHandlingException('everypay_refund_failed')` -> the resource controller shows the error flash and **never flushes** - the DB keeps the payment `completed`. | @@ -85,10 +85,14 @@ src/ | |-- PayloadAfterPayUrlProvider.php headless default: URL from the payload only | |-- SyliusShopAfterPayUrlProvider.php payload first, shop after-pay route fallback | | (wired only when SyliusShopBundle is present) -| |-- EveryPayHttpResponseProvider.php redirect to payment_link or render the method -| | grid (guards: capture action, PR processing, -| | payment new/processing) +| |-- EveryPayHttpResponseProvider.php redirect to payment_link, or render the method +| | grid / embedded checkout (guards: capture action, +| | PR processing, payment new/processing; missing +| | grid links or elements data -> redirect fallback) | |-- MethodGridViewFactory.php oneoff payment_methods -> grid options/groups +| |-- PaymentElementsViewFactory.php responseData + gateway config -> embedded +| | checkout template context (SDK URL, setup, +| | confirm() payment intent); null -> redirect | `-- EveryPayNotifyPaymentProvider.php callback -> Payment resolution |-- Form/EveryPayGatewayConfigurationType.php admin config form (5 fields) |-- Validator/Constraints/ValidEveryPayCredentials{,Validator}.php credential check on save @@ -103,6 +107,8 @@ templates/admin/payment_method/... gateway credential fields partial templates/admin/order/.../everypay.html.twig per-payment panel on the admin order page (raw EveryPay state, reference, portal link) templates/shop/method_grid.html.twig in-shop payment method grid +templates/shop/payment_elements.html.twig embedded checkout (Payment Elements SDK; + experimental - see everypay-api.md) translations/{messages,flashes,validators}.{en,lt,et,lv}.yaml tests/ unit, functional and Behat suites (layout in AGENTS.md) @@ -140,10 +146,16 @@ No DB migration: only core entities (`sylius_payment`, - One command set, display modes as data: the Stripe plugin splits Checkout/WebElements into separate command namespaces because those are - different API flows. EveryPay's redirect and method grid (and the planned - Payment Elements mode) share the same oneoff flow and differ only in how - the customer reaches the payment page - so `display_mode` stays gateway - config, not a namespace. + different API flows. EveryPay's redirect, method grid and Payment Elements + modes share the same oneoff flow and differ only in how the customer + reaches the payment page - so `display_mode` stays gateway config, not a + namespace. +- The Payment Elements mode is **experimental**: the SDK is undocumented for + custom integrations (docs/everypay-api.md#payment-elements-embedded-checkout + records the reverse-engineered contract; EveryPay's WooCommerce 2.x plugin + is the reference). Until EveryPay confirms it, its template/JS and the + `payment_elements` responseData blob are exempt from the stability + contract below, and every render path keeps the hosted redirect fallback. - Notify resolution keeps the DQL `LIKE`: `JSON_EXTRACT` is not portable across MySQL/MariaDB/PostgreSQL/SQLite in DQL, and a dedicated indexed column would force the migration this plugin deliberately avoids. The diff --git a/docs/everypay-api.md b/docs/everypay-api.md index 3327a3b..10c7b65 100644 --- a/docs/everypay-api.md +++ b/docs/everypay-api.md @@ -121,22 +121,46 @@ Notes: EveryPay also has an embedded web checkout - the **Payment Elements** JS SDK its own platform plugins (WooCommerce 2.x, Magento, PrestaShop) mount in-page: -`{base host}/payment_elements/everypay-sdk-v1-0-0.umd.js` (global `EveryPay`; -`secureElements(...)` -> `build({element: 'payment'})` -> `mount()` / `submit()` / -`confirm()`). It rides on a oneoff created with `mobile_payment: true`, whose -response adds a `mobile_access_token` consumed by the element's hosted iframe - -the card form itself stays on EveryPay's servers, and EveryPay's -[PCI DSS SAQ article](https://support.every-pay.com/en/articles/11163626-pci-dss-self-assessment-questionnaires) -classifies the "Payment Elements" integration type as **SAQ A** (their -"SDK(s) -> SAQ A-EP" row refers to the mobile app SDKs). - -Status 2026-07: **no public integration documentation exists** - the help -center's Custom Integration / SDKs / Plugin Integration collections contain -none (the SDKs collection is mobile-app only) - and the SDK contents changed -under the same `v1-0-0` URL within days. Do not build on it before -support@every-pay.com confirms availability for custom integrations; until -then the documented custom-web patterns are the hosted redirect and the -`method_source` method selection this plugin already implements. +`{base host}/payment_elements/everypay-sdk-v1-0-0.umd.js` (UMD, global +`EveryPay`). This plugin implements it as the **experimental +`payment_elements` display mode**. No public documentation exists; the +de-facto contract below was extracted from EveryPay's own WooCommerce 2.0.4 +plugin and the SDK bundle itself (build hash `74a259a625`, byte-identical on +the demo and production hosts as of 2026-07): + +- Create a normal oneoff with the extra field **`mobile_payment: true`** - + the response then carries a **`mobile_access_token`** (used by the SDK as + a Bearer token for the Apple/Google Pay `payment_data` endpoints; card and + bank flows work without it, but the SDK requires it in the intent). +- In the page: `new EveryPay({account, username})` -> `.secureElements({ + amount, locale, environment: 'demo'|'production', preferredCountry?, + email?, stylingOptions: {theme: 'light', layout: 'tabs'|'accordion'}})` + -> `.build({element: 'payment'})` -> `await element.mount('#selector')`. + The element is an iframe of `{base host}/el/v3` (postMessage protocol, + auto-resizing); it lists methods via the unauthenticated + `GET /v4/sdk/payment_methods/{account_name}`. +- `element.submit()` validates/collects inside the iframe and resolves + `{error: string|null}`. Card data never touches the shop page - EveryPay's + [PCI DSS SAQ article](https://support.every-pay.com/en/articles/11163626-pci-dss-self-assessment-questionnaires) + classifies Payment Elements as **SAQ A** (their "SDK(s) -> SAQ A-EP" row + refers to the mobile app SDKs). +- `element.confirm({accountName, apiUsername, bearerToken, orderReference, + paymentLink, returnURL, paymentReference})` finalizes and **always ends in + a top-window redirect**: to `returnURL?payment_reference=...` (the normal + customer return), into a 3DS challenge on the hosted page first, or - for + bank methods - to `payment_link?method_source={bank}` exactly like the + method grid. Nothing changes server-side: the return/callback/status flow + stays authoritative. +- There is also a `managed` element variant (the iframe renders its own pay + button, driven via an `onPaymentConfirmed` hook); no known production + integration uses it, so this plugin sticks to `payment`. + +Status 2026-07: **still no public integration documentation** - the help +center collections contain none, the bundle has changed under the same +`v1-0-0` URL within days, and `/el/v3` + `/v4/sdk/*` are undocumented. +support@every-pay.com has not yet confirmed the SDK for custom integrations - +which is why the display mode ships marked experimental, and why the hosted +page redirect fallback (no `mobile_access_token` -> redirect) must stay. ## Merchant portal setup checklist diff --git a/features/paying_with_everypay.feature b/features/paying_with_everypay.feature index 93329c5..74aa074 100644 --- a/features/paying_with_everypay.feature +++ b/features/paying_with_everypay.feature @@ -59,3 +59,16 @@ Feature: Paying with EveryPay Then the customer sees the bank buttons instead of being redirected And the payment page reads as part of the checkout And the bank buttons are grouped by country, the customer's country first + + Scenario: Paying inside the shop with the embedded checkout + Given the EveryPay payment method uses the embedded checkout + And EveryPay will accept the payment creation with a mobile access token + When the customer proceeds to pay + Then the customer sees the embedded checkout instead of being redirected + And the payment page reads as part of the checkout + + Scenario: The embedded checkout falls back to the hosted page without a token + Given the EveryPay payment method uses the embedded checkout + And EveryPay will accept the payment creation + When the customer proceeds to pay + Then the customer is redirected to the EveryPay payment page diff --git a/src/CommandHandler/CaptureEveryPayPaymentHandler.php b/src/CommandHandler/CaptureEveryPayPaymentHandler.php index d2c4bc5..4b093ef 100644 --- a/src/CommandHandler/CaptureEveryPayPaymentHandler.php +++ b/src/CommandHandler/CaptureEveryPayPaymentHandler.php @@ -84,11 +84,18 @@ public function __invoke(CaptureEveryPayPayment $command): void ]); } + $displayMode = EveryPayGateway::displayModeFor($paymentRequest); + + $payload = $this->payloadFactory->create($payment, $customerUrl); + if (EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS === $displayMode) { + // The embedded checkout rides on the same oneoff: mobile_payment + // makes the response carry the mobile_access_token the Payment + // Elements SDK consumes. + $payload['mobile_payment'] = true; + } + try { - $response = $this->apiClient->createOneOffPayment( - $credentials, - $this->payloadFactory->create($payment, $customerUrl), - ); + $response = $this->apiClient->createOneOffPayment($credentials, $payload); } catch (EveryPayApiException $e) { $this->logger->error('EveryPay one-off payment creation failed.', [ 'payment_id' => $payment->getId(), @@ -123,13 +130,39 @@ public function __invoke(CaptureEveryPayPayment $command): void ]; $payment->setDetails($details); - $paymentRequest->setResponseData([ + $responseData = [ 'payment_link' => $paymentLink, 'payment_reference' => $paymentReference, // Per-method direct links (bank buttons) for the optional // in-shop method grid; empty when EveryPay returns none. 'payment_methods' => $this->methodGrid->optionsFrom($response['payment_methods'] ?? null), - ]); + ]; + + if (EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS === $displayMode) { + $mobileAccessToken = $response['mobile_access_token'] ?? null; + if (is_string($mobileAccessToken) && '' !== $mobileAccessToken) { + // Everything the embedded checkout page needs beyond the + // gateway config: the SDK options mirror the oneoff payload + // just sent, the rest feeds confirm()'s payment intent. + $responseData['payment_elements'] = [ + 'mobile_access_token' => $mobileAccessToken, + 'customer_url' => $customerUrl, + 'order_reference' => $payload['order_reference'], + 'amount' => $payload['amount'], + 'locale' => $payload['locale'], + 'email' => $payload['email'] ?? null, + 'preferred_country' => $payload['preferred_country'] ?? null, + ]; + } else { + // Keep the attempt alive - EveryPayHttpResponseProvider falls + // back to the hosted page redirect without this key. + $this->logger->warning('EveryPay returned no mobile_access_token for the embedded checkout - falling back to the hosted page redirect.', [ + 'payment_id' => $payment->getId(), + ]); + } + } + + $paymentRequest->setResponseData($responseData); // The payment intentionally stays in `new`: PaymentToPayResolver only // re-enters the pay flow for `new` payments, so a customer who bounces diff --git a/src/EveryPayGateway.php b/src/EveryPayGateway.php index 447d77a..e3d9ccd 100644 --- a/src/EveryPayGateway.php +++ b/src/EveryPayGateway.php @@ -5,6 +5,7 @@ namespace Pkg\SyliusEveryPayPlugin; use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; /** @@ -42,6 +43,14 @@ final class EveryPayGateway /** Show the payment methods (bank buttons) inside the shop first. */ public const DISPLAY_MODE_METHOD_GRID = 'method_grid'; + /** + * Render the EveryPay Payment Elements embedded checkout inside the shop. + * Experimental: the SDK is undocumented for custom integrations - the + * de-facto contract is EveryPay's own WooCommerce 2.x plugin (see + * docs/everypay-api.md#payment-elements-embedded-checkout). + */ + public const DISPLAY_MODE_PAYMENT_ELEMENTS = 'payment_elements'; + public const ENVIRONMENT_DEMO = 'demo'; public const ENVIRONMENT_LIVE = 'live'; @@ -51,6 +60,18 @@ final class EveryPayGateway self::ENVIRONMENT_LIVE => 'https://pay.every-pay.eu/api', ]; + /** The Payment Elements browser SDK bundle, served from the gateway host. */ + public const ELEMENTS_SDK_URLS = [ + self::ENVIRONMENT_DEMO => 'https://igw-demo.every-pay.com/payment_elements/everypay-sdk-v1-0-0.umd.js', + self::ENVIRONMENT_LIVE => 'https://pay.every-pay.eu/payment_elements/everypay-sdk-v1-0-0.umd.js', + ]; + + /** Gateway environment -> the value of the SDK's `environment` option. */ + public const ELEMENTS_SDK_ENVIRONMENTS = [ + self::ENVIRONMENT_DEMO => 'demo', + self::ENVIRONMENT_LIVE => 'production', + ]; + /** Key inside Payment::getDetails() holding the EveryPay payment snapshot. */ public const DETAILS_KEY = 'everypay'; @@ -96,11 +117,32 @@ public static function paymentReferenceFrom(array $paymentDetails): ?string */ public static function displayModeFrom(array $config): string { - return self::DISPLAY_MODE_METHOD_GRID === ($config[self::CONFIG_DISPLAY_MODE] ?? null) - ? self::DISPLAY_MODE_METHOD_GRID + $mode = $config[self::CONFIG_DISPLAY_MODE] ?? null; + + return in_array($mode, [self::DISPLAY_MODE_METHOD_GRID, self::DISPLAY_MODE_PAYMENT_ELEMENTS], true) + ? $mode : self::DISPLAY_MODE_REDIRECT; } + public static function displayModeFor(PaymentRequestInterface $paymentRequest): string + { + $gatewayConfig = $paymentRequest->getMethod()->getGatewayConfig(); + + return $gatewayConfig instanceof GatewayConfigInterface + ? self::displayModeFrom($gatewayConfig->getConfig()) + : self::DISPLAY_MODE_REDIRECT; + } + + /** + * @param array $config + */ + public static function environmentFrom(array $config): string + { + return self::ENVIRONMENT_LIVE === ($config[self::CONFIG_ENVIRONMENT] ?? null) + ? self::ENVIRONMENT_LIVE + : self::ENVIRONMENT_DEMO; + } + public static function corePaymentFrom(PaymentRequestInterface $paymentRequest): PaymentInterface { $payment = $paymentRequest->getPayment(); diff --git a/src/Form/EveryPayGatewayConfigurationType.php b/src/Form/EveryPayGatewayConfigurationType.php index 70bbbbf..224f64a 100644 --- a/src/Form/EveryPayGatewayConfigurationType.php +++ b/src/Form/EveryPayGatewayConfigurationType.php @@ -73,6 +73,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => [ 'pkg_everypay.ui.display_mode_redirect' => EveryPayGateway::DISPLAY_MODE_REDIRECT, 'pkg_everypay.ui.display_mode_method_grid' => EveryPayGateway::DISPLAY_MODE_METHOD_GRID, + 'pkg_everypay.ui.display_mode_payment_elements' => EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS, ], 'empty_data' => EveryPayGateway::DISPLAY_MODE_REDIRECT, ]) diff --git a/src/Provider/EveryPayHttpResponseProvider.php b/src/Provider/EveryPayHttpResponseProvider.php index 9908073..e96d6b6 100644 --- a/src/Provider/EveryPayHttpResponseProvider.php +++ b/src/Provider/EveryPayHttpResponseProvider.php @@ -7,7 +7,6 @@ use Pkg\SyliusEveryPayPlugin\EveryPayGateway; use Sylius\Bundle\PaymentBundle\Provider\HttpResponseProviderInterface; use Sylius\Bundle\ResourceBundle\Controller\RequestConfiguration; -use Sylius\Component\Payment\Model\GatewayConfigInterface; use Sylius\Component\Payment\Model\PaymentInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; @@ -17,11 +16,14 @@ /** * After the capture handler created the EveryPay payment, sends the customer - * to the hosted payment page - or, when the gateway is configured with the - * method grid, renders the payment method buttons (per-method direct links) - * inside the shop first. When it does not support the request (capture - * failed, or the payment already reached a final state), Sylius falls back - * to /after-pay/{hash} -> status check -> thank-you/retry. + * to the hosted payment page - or, depending on the configured display mode, + * renders the payment method buttons (per-method direct links) or the + * embedded Payment Elements checkout inside the shop instead. Whenever the + * configured mode cannot render (no per-method links, no mobile access + * token), the hosted page redirect stays as the fallback. When it does not + * support the request (capture failed, or the payment already reached a + * final state), Sylius falls back to /after-pay/{hash} -> status check -> + * thank-you/retry. */ #[AutoconfigureTag('sylius.payment_request.provider.http_response', ['gateway_factory' => EveryPayGateway::FACTORY_NAME])] final readonly class EveryPayHttpResponseProvider implements HttpResponseProviderInterface @@ -34,6 +36,7 @@ public function __construct( private Environment $twig, private MethodGridViewFactory $methodGrid, + private PaymentElementsViewFactory $paymentElements, ) { } @@ -68,8 +71,18 @@ public function getResponse( /** @var string $paymentLink */ $paymentLink = $responseData['payment_link']; + $displayMode = EveryPayGateway::displayModeFor($paymentRequest); + + if (EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS === $displayMode) { + $view = $this->paymentElements->viewFrom($paymentRequest); + if (null !== $view) { + return new Response($this->twig->render('@PkgSyliusEveryPayPlugin/shop/payment_elements.html.twig', $view)); + } + // EveryPay returned no usable elements data: redirect below. + } + $methodOptions = $this->methodGrid->optionsFrom($responseData['payment_methods'] ?? null); - if ([] !== $methodOptions && EveryPayGateway::DISPLAY_MODE_METHOD_GRID === $this->displayModeFor($paymentRequest)) { + if ([] !== $methodOptions && EveryPayGateway::DISPLAY_MODE_METHOD_GRID === $displayMode) { $payment = EveryPayGateway::corePaymentFrom($paymentRequest); $preferredCountry = $payment->getOrder()?->getBillingAddress()?->getCountryCode(); @@ -83,14 +96,4 @@ public function getResponse( return new RedirectResponse($paymentLink, Response::HTTP_SEE_OTHER); } - - private function displayModeFor(PaymentRequestInterface $paymentRequest): string - { - $gatewayConfig = $paymentRequest->getMethod()->getGatewayConfig(); - if (!$gatewayConfig instanceof GatewayConfigInterface) { - return EveryPayGateway::DISPLAY_MODE_REDIRECT; - } - - return EveryPayGateway::displayModeFrom($gatewayConfig->getConfig()); - } } diff --git a/src/Provider/PaymentElementsViewFactory.php b/src/Provider/PaymentElementsViewFactory.php new file mode 100644 index 0000000..784d55e --- /dev/null +++ b/src/Provider/PaymentElementsViewFactory.php @@ -0,0 +1,98 @@ +|null + */ + public function viewFrom(PaymentRequestInterface $paymentRequest): ?array + { + $responseData = $paymentRequest->getResponseData(); + + $paymentLink = $responseData['payment_link'] ?? null; + $paymentReference = $responseData['payment_reference'] ?? null; + $elements = $responseData['payment_elements'] ?? null; + if ( + !is_array($elements) || + !is_string($paymentLink) || '' === $paymentLink || + !is_string($paymentReference) || '' === $paymentReference + ) { + return null; + } + + // The SDK hard-requires every confirm() field - render nothing + // rather than an element that throws on submit. + $mobileAccessToken = $elements['mobile_access_token'] ?? null; + $customerUrl = $elements['customer_url'] ?? null; + $orderReference = $elements['order_reference'] ?? null; + if ( + !is_string($mobileAccessToken) || '' === $mobileAccessToken || + !is_string($customerUrl) || '' === $customerUrl || + !is_string($orderReference) || '' === $orderReference + ) { + return null; + } + + $gatewayConfig = $paymentRequest->getMethod()->getGatewayConfig(); + if (!$gatewayConfig instanceof GatewayConfigInterface) { + return null; + } + $config = $gatewayConfig->getConfig(); + $credentials = EveryPayCredentials::fromConfig($config); + if ('' === $credentials->apiUsername || '' === $credentials->accountName) { + return null; + } + + $payment = EveryPayGateway::corePaymentFrom($paymentRequest); + $environment = EveryPayGateway::environmentFrom($config); + + $amount = $elements['amount'] ?? null; + $locale = $elements['locale'] ?? null; + $email = $elements['email'] ?? null; + $preferredCountry = $elements['preferred_country'] ?? null; + + return [ + 'payment' => $payment, + 'payment_request' => $paymentRequest, + 'payment_link' => $paymentLink, + 'sdk_url' => EveryPayGateway::ELEMENTS_SDK_URLS[$environment], + // Serialized into the page as the JSON the checkout script reads. + 'elements' => [ + 'setup' => [ + 'account_name' => $credentials->accountName, + 'api_username' => $credentials->apiUsername, + 'environment' => EveryPayGateway::ELEMENTS_SDK_ENVIRONMENTS[$environment], + 'amount' => is_numeric($amount) ? (float) $amount : EveryPayGateway::amountToDecimal((int) $payment->getAmount()), + 'locale' => is_string($locale) && '' !== $locale ? $locale : 'en', + 'email' => is_string($email) && '' !== $email ? $email : null, + 'preferred_country' => is_string($preferredCountry) && '' !== $preferredCountry ? $preferredCountry : null, + ], + 'payment_intent' => [ + 'accountName' => $credentials->accountName, + 'apiUsername' => $credentials->apiUsername, + 'bearerToken' => $mobileAccessToken, + 'orderReference' => $orderReference, + 'paymentLink' => $paymentLink, + 'returnURL' => $customerUrl, + 'paymentReference' => $paymentReference, + ], + ], + ]; + } +} diff --git a/templates/shop/payment_elements.html.twig b/templates/shop/payment_elements.html.twig new file mode 100644 index 0000000..96fbeb5 --- /dev/null +++ b/templates/shop/payment_elements.html.twig @@ -0,0 +1,118 @@ +{% extends '@SyliusShop/shared/layout/base.html.twig' %} + +{% import '@SyliusShop/shared/macro/money.html.twig' as money %} + +{% block title %}{{ 'pkg_everypay.ui.choose_payment_method'|trans }} | {{ parent() }}{% endblock %} + +{% block content %} + {% set order = payment.order %} +
+
+

{{ 'pkg_everypay.ui.choose_payment_method'|trans }}

+

+ {{ 'pkg_everypay.ui.pay_for_order'|trans({'%number%': order.number}) }} +

+

+ {{ 'pkg_everypay.ui.amount_due'|trans }}: {{ money.format(payment.amount, payment.currencyCode) }} +

+
+ + {# The EveryPay iframe (card form, bank list, wallet buttons) mounts here. #} +
+ + + + + +

{{ 'pkg_everypay.ui.after_payment_note'|trans }}

+

+ + {{ 'pkg_everypay.ui.all_payment_options'|trans }} + +

+

+ + {{ 'pkg_everypay.ui.back_to_order'|trans }} + +

+
+ + {# The HEX flags keep '' and quote characters impossible inside the JSON island. #} + + + +{% endblock %} diff --git a/tests/Behat/Context/EveryPayShopContext.php b/tests/Behat/Context/EveryPayShopContext.php index 10f896a..bf44f26 100644 --- a/tests/Behat/Context/EveryPayShopContext.php +++ b/tests/Behat/Context/EveryPayShopContext.php @@ -34,6 +34,8 @@ final class EveryPayShopContext implements Context private const PAYMENT_LINK = 'https://igw-demo.every-pay.com/lp/x419d7/3HeCGV01'; + private const MOBILE_ACCESS_TOKEN = 'mobile-access-token-123'; + private ?ChannelInterface $channel = null; private ?PaymentMethodInterface $paymentMethod = null; @@ -152,6 +154,45 @@ public function everyPayWillAcceptThePaymentCreationWithAListOfBankMethods(): vo ], 201); } + #[Given('the EveryPay payment method uses the embedded checkout')] + public function theEveryPayPaymentMethodUsesTheEmbeddedCheckout(): void + { + Assert::notNull($this->paymentMethod); + $gatewayConfig = $this->paymentMethod->getGatewayConfig(); + Assert::notNull($gatewayConfig); + $gatewayConfig->setConfig(array_merge($gatewayConfig->getConfig(), [ + EveryPayGateway::CONFIG_DISPLAY_MODE => EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS, + ])); + $this->entityManager->flush(); + } + + #[Given('EveryPay will accept the payment creation with a mobile access token')] + public function everyPayWillAcceptThePaymentCreationWithAMobileAccessToken(): void + { + $this->everyPayApi->queueJson([ + 'payment_reference' => self::PAYMENT_REFERENCE, + 'payment_link' => self::PAYMENT_LINK, + 'payment_state' => 'initial', + 'order_reference' => '000000001-1', + 'account_name' => 'EUR3D1', + 'currency' => 'EUR', + 'mobile_access_token' => self::MOBILE_ACCESS_TOKEN, + ], 201); + } + + #[Then('the customer sees the embedded checkout instead of being redirected')] + public function theCustomerSeesTheEmbeddedCheckoutInsteadOfBeingRedirected(): void + { + Assert::null($this->externalRedirectUrl, 'Expected the embedded checkout, got an external redirect.'); + Assert::true($this->client->getResponse()->isSuccessful()); + + $content = (string) $this->client->getResponse()->getContent(); + Assert::contains($content, 'data-test-everypay-payment-elements'); + Assert::contains($content, '/payment_elements/everypay-sdk-v1-0-0.umd.js'); + Assert::contains($content, self::MOBILE_ACCESS_TOKEN); + Assert::contains($content, 'data-test-everypay-hosted-page-link'); + } + #[Then('the customer sees the bank buttons instead of being redirected')] public function theCustomerSeesTheBankButtonsInsteadOfBeingRedirected(): void { diff --git a/tests/Functional/AdminGatewayConfigurationFormTest.php b/tests/Functional/AdminGatewayConfigurationFormTest.php index 051668f..be123ce 100644 --- a/tests/Functional/AdminGatewayConfigurationFormTest.php +++ b/tests/Functional/AdminGatewayConfigurationFormTest.php @@ -31,7 +31,7 @@ public function testGatewayCredentialFieldsRenderOnTheCreateForm(): void self::assertResponseIsSuccessful(); $content = (string) $client->getResponse()->getContent(); - foreach (['api_username', 'api_secret', 'account_name', 'environment', 'merchant_portal_url'] as $field) { + foreach (['api_username', 'api_secret', 'account_name', 'environment', 'display_mode', 'merchant_portal_url'] as $field) { self::assertStringContainsString( sprintf('[gatewayConfig][config][%s]', $field), $content, @@ -39,6 +39,11 @@ public function testGatewayCredentialFieldsRenderOnTheCreateForm(): void ); } + // All three checkout appearances are offered. + foreach (['redirect', 'method_grid', 'payment_elements'] as $displayMode) { + self::assertStringContainsString(sprintf('value="%s"', $displayMode), $content); + } + // Labels prove the plugin translations are wired into the form. self::assertStringContainsString('Processing account', $content); self::assertStringContainsString('Merchant portal address', $content); diff --git a/tests/Functional/CapturePaymentRequestTest.php b/tests/Functional/CapturePaymentRequestTest.php index 8dd8a79..f6df4c9 100644 --- a/tests/Functional/CapturePaymentRequestTest.php +++ b/tests/Functional/CapturePaymentRequestTest.php @@ -78,6 +78,32 @@ public function testAnExplicitPayloadUrlIsSentAsTheCustomerUrl(): void self::assertSame('https://spa.example/checkout/thank-you', $this->customerUrlFrom((string) $requests[0]['body'])); } + public function testElementsModeCaptureSendsMobilePaymentAndStoresTheToken(): void + { + static::bootKernel(); + $this->prepareDatabase(); + $paymentRequest = $this->createCapturePaymentRequest(displayMode: EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS); + + $this->everyPayHttpMock()->queueJson([ + 'payment_reference' => self::PAYMENT_REFERENCE, + 'payment_link' => self::PAYMENT_LINK, + 'payment_state' => 'initial', + 'mobile_access_token' => 'mobile-access-token-123', + ], 201); + + $this->commandBus()->dispatch(new CaptureEveryPayPayment($paymentRequest->getId())); + + $requests = $this->everyPayHttpMock()->recordedRequests(); + self::assertCount(1, $requests); + self::assertStringContainsString('"mobile_payment":true', (string) $requests[0]['body']); + + self::assertSame(PaymentRequestInterface::STATE_PROCESSING, $paymentRequest->getState()); + $elements = $paymentRequest->getResponseData()['payment_elements'] ?? null; + self::assertIsArray($elements); + self::assertSame('mobile-access-token-123', $elements['mobile_access_token']); + self::assertSame($this->customerUrlFrom((string) $requests[0]['body']), $elements['customer_url']); + } + public function testApiFailureFailsPaymentRequestAndPaymentWithoutThrowing(): void { static::bootKernel(); @@ -96,10 +122,17 @@ public function testApiFailureFailsPaymentRequestAndPaymentWithoutThrowing(): vo self::assertArrayHasKey('error', $paymentRequest->getResponseData()); } - private function createCapturePaymentRequest(): PaymentRequestInterface + private function createCapturePaymentRequest(?string $displayMode = null): PaymentRequestInterface { $channel = $this->createShopEnvironment(); $method = $this->createEveryPayPaymentMethod($channel); + if (null !== $displayMode) { + $gatewayConfig = $method->getGatewayConfig(); + self::assertNotNull($gatewayConfig); + $gatewayConfig->setConfig(array_merge($gatewayConfig->getConfig(), [ + EveryPayGateway::CONFIG_DISPLAY_MODE => $displayMode, + ])); + } $payment = $this->createOrderWithPayment($channel, $method); /** @var PaymentRequestFactoryInterface $factory */ diff --git a/tests/Unit/CommandHandler/CaptureEveryPayPaymentHandlerTest.php b/tests/Unit/CommandHandler/CaptureEveryPayPaymentHandlerTest.php index 402f54e..8067e47 100644 --- a/tests/Unit/CommandHandler/CaptureEveryPayPaymentHandlerTest.php +++ b/tests/Unit/CommandHandler/CaptureEveryPayPaymentHandlerTest.php @@ -149,7 +149,73 @@ public function testDoesNotWarnWhenTheCurrenciesMatch(): void self::assertSame([], $logger->messages('warning')); } - private function paymentRequest(?string $paymentCurrency = null): PaymentRequest + public function testElementsModeRequestsAMobilePaymentAndStoresTheElementsData(): void + { + $paymentRequest = $this->paymentRequest(displayMode: EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS); + $apiResponse = new MockResponse(json_encode([ + 'payment_reference' => self::PAYMENT_REFERENCE, + 'payment_link' => self::PAYMENT_LINK, + 'payment_state' => 'initial', + 'mobile_access_token' => 'mobile-access-token-123', + ], \JSON_THROW_ON_ERROR), ['http_code' => 201]); + $handler = $this->handler($paymentRequest, $apiResponse); + + $handler(new CaptureEveryPayPayment('hash')); + + $sentBody = json_decode((string) $apiResponse->getRequestOptions()['body'], true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($sentBody); + self::assertTrue($sentBody['mobile_payment'] ?? null); + + $elements = $paymentRequest->getResponseData()['payment_elements'] ?? null; + self::assertIsArray($elements); + self::assertSame('mobile-access-token-123', $elements['mobile_access_token']); + self::assertSame('https://shop.example/after-pay/hash', $elements['customer_url']); + self::assertSame($sentBody['order_reference'], $elements['order_reference']); + self::assertSame(25.99, $elements['amount']); + self::assertSame('lt', $elements['locale']); + } + + public function testElementsModeWithoutATokenStillStoresTheHostedPageLink(): void + { + $logger = new RecordingLogger(); + $paymentRequest = $this->paymentRequest(paymentCurrency: 'EUR', displayMode: EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS); + $handler = $this->handler($paymentRequest, new MockResponse(json_encode([ + 'payment_reference' => self::PAYMENT_REFERENCE, + 'payment_link' => self::PAYMENT_LINK, + 'payment_state' => 'initial', + ], \JSON_THROW_ON_ERROR), ['http_code' => 201]), $logger); + + $handler(new CaptureEveryPayPayment('hash')); + + // No token, no embedded checkout - but the attempt stays alive and + // the response provider falls back to the hosted page redirect. + $responseData = $paymentRequest->getResponseData(); + self::assertArrayNotHasKey('payment_elements', $responseData); + self::assertSame(self::PAYMENT_LINK, $responseData['payment_link']); + + $warnings = $logger->messages('warning'); + self::assertCount(1, $warnings); + self::assertStringContainsString('mobile_access_token', $warnings[0]); + } + + public function testRedirectModeDoesNotRequestAMobilePayment(): void + { + $paymentRequest = $this->paymentRequest(); + $apiResponse = new MockResponse(json_encode([ + 'payment_reference' => self::PAYMENT_REFERENCE, + 'payment_link' => self::PAYMENT_LINK, + ], \JSON_THROW_ON_ERROR), ['http_code' => 201]); + $handler = $this->handler($paymentRequest, $apiResponse); + + $handler(new CaptureEveryPayPayment('hash')); + + $sentBody = json_decode((string) $apiResponse->getRequestOptions()['body'], true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($sentBody); + self::assertArrayNotHasKey('mobile_payment', $sentBody); + self::assertArrayNotHasKey('payment_elements', $paymentRequest->getResponseData()); + } + + private function paymentRequest(?string $paymentCurrency = null, ?string $displayMode = null): PaymentRequest { $order = $this->createStub(OrderInterface::class); $order->method('getNumber')->willReturn('000123'); @@ -166,12 +232,16 @@ private function paymentRequest(?string $paymentCurrency = null): PaymentRequest $payment->setCurrencyCode($paymentCurrency); } - $gatewayConfig = $this->createStub(GatewayConfigInterface::class); - $gatewayConfig->method('getConfig')->willReturn([ + $config = [ EveryPayGateway::CONFIG_API_USERNAME => 'a04e7ce1060e7024', EveryPayGateway::CONFIG_API_SECRET => 'secret', EveryPayGateway::CONFIG_ACCOUNT_NAME => 'EUR3D1', - ]); + ]; + if (null !== $displayMode) { + $config[EveryPayGateway::CONFIG_DISPLAY_MODE] = $displayMode; + } + $gatewayConfig = $this->createStub(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn($config); $method = $this->createStub(PaymentMethodInterface::class); $method->method('getGatewayConfig')->willReturn($gatewayConfig); diff --git a/tests/Unit/EveryPayGatewayTest.php b/tests/Unit/EveryPayGatewayTest.php index 762b474..d9d73a2 100644 --- a/tests/Unit/EveryPayGatewayTest.php +++ b/tests/Unit/EveryPayGatewayTest.php @@ -14,6 +14,14 @@ public function testDisplayModeDefaultsToRedirect(): void self::assertSame(EveryPayGateway::DISPLAY_MODE_REDIRECT, EveryPayGateway::displayModeFrom([])); self::assertSame(EveryPayGateway::DISPLAY_MODE_REDIRECT, EveryPayGateway::displayModeFrom(['display_mode' => 'nonsense'])); self::assertSame(EveryPayGateway::DISPLAY_MODE_METHOD_GRID, EveryPayGateway::displayModeFrom(['display_mode' => 'method_grid'])); + self::assertSame(EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS, EveryPayGateway::displayModeFrom(['display_mode' => 'payment_elements'])); + } + + public function testEnvironmentDefaultsToDemo(): void + { + self::assertSame(EveryPayGateway::ENVIRONMENT_DEMO, EveryPayGateway::environmentFrom([])); + self::assertSame(EveryPayGateway::ENVIRONMENT_DEMO, EveryPayGateway::environmentFrom(['environment' => 'nonsense'])); + self::assertSame(EveryPayGateway::ENVIRONMENT_LIVE, EveryPayGateway::environmentFrom(['environment' => 'live'])); } public function testAmountToDecimalConvertsCents(): void diff --git a/tests/Unit/Provider/EveryPayHttpResponseProviderTest.php b/tests/Unit/Provider/EveryPayHttpResponseProviderTest.php index 669536f..ad699e8 100644 --- a/tests/Unit/Provider/EveryPayHttpResponseProviderTest.php +++ b/tests/Unit/Provider/EveryPayHttpResponseProviderTest.php @@ -8,6 +8,7 @@ use Pkg\SyliusEveryPayPlugin\EveryPayGateway; use Pkg\SyliusEveryPayPlugin\Provider\EveryPayHttpResponseProvider; use Pkg\SyliusEveryPayPlugin\Provider\MethodGridViewFactory; +use Pkg\SyliusEveryPayPlugin\Provider\PaymentElementsViewFactory; use Sylius\Bundle\ResourceBundle\Controller\RequestConfiguration; use Sylius\Component\Core\Model\AddressInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -42,7 +43,7 @@ public function testFallsBackToRedirectWhenTheGridHasNoPerMethodLinks(): void { $twig = $this->createMock(Environment::class); $twig->expects(self::never())->method('render'); - $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory()); + $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory(), new PaymentElementsViewFactory()); // Grid configured, but EveryPay returned no per-method links: the // hosted page redirect must stay as the fallback. @@ -78,7 +79,7 @@ public function testRendersTheMethodGridGroupedAroundTheBillingCountry(): void }), ) ->willReturn(''); - $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory()); + $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory(), new PaymentElementsViewFactory()); $paymentRequest = $this->paymentRequest( displayMode: EveryPayGateway::DISPLAY_MODE_METHOD_GRID, @@ -97,6 +98,67 @@ public function testRendersTheMethodGridGroupedAroundTheBillingCountry(): void self::assertSame('', $response->getContent()); } + public function testRendersTheEmbeddedCheckoutWhenConfigured(): void + { + $twig = $this->createMock(Environment::class); + $twig + ->expects(self::once()) + ->method('render') + ->with( + '@PkgSyliusEveryPayPlugin/shop/payment_elements.html.twig', + self::callback(static function (array $context): bool { + self::assertSame(self::PAYMENT_LINK, $context['payment_link']); + self::assertSame(EveryPayGateway::ELEMENTS_SDK_URLS[EveryPayGateway::ENVIRONMENT_DEMO], $context['sdk_url']); + + $elements = $context['elements']; + self::assertIsArray($elements); + self::assertSame('mobile-access-token-123', $elements['payment_intent']['bearerToken'] ?? null); + + return true; + }), + ) + ->willReturn(''); + $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory(), new PaymentElementsViewFactory()); + + $paymentRequest = $this->paymentRequest( + displayMode: EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS, + responseData: [ + 'payment_link' => self::PAYMENT_LINK, + 'payment_reference' => 'ref-123', + 'payment_elements' => [ + 'mobile_access_token' => 'mobile-access-token-123', + 'customer_url' => 'https://shop.example/after-pay/hash', + 'order_reference' => '000123-7', + 'amount' => 25.99, + 'locale' => 'lt', + ], + ], + ); + + $response = $provider->getResponse($this->requestConfiguration(), $paymentRequest); + + self::assertSame('', $response->getContent()); + } + + public function testFallsBackToRedirectWhenTheElementsDataIsMissing(): void + { + $twig = $this->createMock(Environment::class); + $twig->expects(self::never())->method('render'); + $provider = new EveryPayHttpResponseProvider($twig, new MethodGridViewFactory(), new PaymentElementsViewFactory()); + + // Embedded checkout configured, but EveryPay returned no + // mobile_access_token: the hosted page redirect must stay. + $paymentRequest = $this->paymentRequest( + displayMode: EveryPayGateway::DISPLAY_MODE_PAYMENT_ELEMENTS, + responseData: ['payment_link' => self::PAYMENT_LINK, 'payment_reference' => 'ref-123'], + ); + + $response = $provider->getResponse($this->requestConfiguration(), $paymentRequest); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame(self::PAYMENT_LINK, $response->getTargetUrl()); + } + public function testDoesNotSupportOtherActionsThanCapture(): void { $paymentRequest = $this->paymentRequest(); @@ -129,7 +191,7 @@ public function testDoesNotSupportRequestsWithoutAPaymentLink(): void private function provider(): EveryPayHttpResponseProvider { - return new EveryPayHttpResponseProvider($this->createMock(Environment::class), new MethodGridViewFactory()); + return new EveryPayHttpResponseProvider($this->createMock(Environment::class), new MethodGridViewFactory(), new PaymentElementsViewFactory()); } private function requestConfiguration(): RequestConfiguration @@ -148,6 +210,8 @@ private function paymentRequest( ): PaymentRequest { $gatewayConfig = $this->createStub(GatewayConfigInterface::class); $gatewayConfig->method('getConfig')->willReturn([ + EveryPayGateway::CONFIG_API_USERNAME => 'a04e7ce1060e7024', + EveryPayGateway::CONFIG_ACCOUNT_NAME => 'EUR3D1', EveryPayGateway::CONFIG_DISPLAY_MODE => $displayMode, ]); diff --git a/tests/Unit/Provider/PaymentElementsViewFactoryTest.php b/tests/Unit/Provider/PaymentElementsViewFactoryTest.php new file mode 100644 index 0000000..156558b --- /dev/null +++ b/tests/Unit/Provider/PaymentElementsViewFactoryTest.php @@ -0,0 +1,152 @@ + self::PAYMENT_LINK, + 'payment_reference' => 'abc123def456', + 'payment_elements' => [ + 'mobile_access_token' => 'mobile-access-token-123', + 'customer_url' => 'https://shop.example/after-pay/hash', + 'order_reference' => '000123-7', + 'amount' => 25.99, + 'locale' => 'lt', + 'email' => 'customer@example.com', + 'preferred_country' => 'LT', + ], + ]; + + public function testShapesTheSdkSetupAndThePaymentIntent(): void + { + $view = (new PaymentElementsViewFactory())->viewFrom($this->paymentRequest()); + + self::assertNotNull($view); + self::assertSame(self::PAYMENT_LINK, $view['payment_link']); + self::assertSame(EveryPayGateway::ELEMENTS_SDK_URLS[EveryPayGateway::ENVIRONMENT_DEMO], $view['sdk_url']); + self::assertSame([ + 'setup' => [ + 'account_name' => 'EUR3D1', + 'api_username' => 'a04e7ce1060e7024', + 'environment' => 'demo', + 'amount' => 25.99, + 'locale' => 'lt', + 'email' => 'customer@example.com', + 'preferred_country' => 'LT', + ], + 'payment_intent' => [ + 'accountName' => 'EUR3D1', + 'apiUsername' => 'a04e7ce1060e7024', + 'bearerToken' => 'mobile-access-token-123', + 'orderReference' => '000123-7', + 'paymentLink' => self::PAYMENT_LINK, + 'returnURL' => 'https://shop.example/after-pay/hash', + 'paymentReference' => 'abc123def456', + ], + ], $view['elements']); + } + + public function testTheLiveEnvironmentUsesTheProductionSdk(): void + { + $view = (new PaymentElementsViewFactory())->viewFrom( + $this->paymentRequest(environment: EveryPayGateway::ENVIRONMENT_LIVE), + ); + + self::assertNotNull($view); + self::assertSame(EveryPayGateway::ELEMENTS_SDK_URLS[EveryPayGateway::ENVIRONMENT_LIVE], $view['sdk_url']); + $elements = $view['elements']; + self::assertIsArray($elements); + self::assertSame('production', $elements['setup']['environment'] ?? null); + } + + public function testFallsBackToPaymentAmountAndEnglishWhenTheBlobIsSparse(): void + { + $responseData = self::RESPONSE_DATA; + unset( + $responseData['payment_elements']['amount'], + $responseData['payment_elements']['locale'], + $responseData['payment_elements']['email'], + $responseData['payment_elements']['preferred_country'], + ); + + $view = (new PaymentElementsViewFactory())->viewFrom($this->paymentRequest($responseData)); + + self::assertNotNull($view); + $elements = $view['elements']; + self::assertIsArray($elements); + $setup = $elements['setup']; + self::assertIsArray($setup); + self::assertSame(25.99, $setup['amount']); + self::assertSame('en', $setup['locale']); + self::assertNull($setup['email']); + self::assertNull($setup['preferred_country']); + } + + public function testReturnsNullWithoutTheElementsBlob(): void + { + $responseData = self::RESPONSE_DATA; + unset($responseData['payment_elements']); + + self::assertNull((new PaymentElementsViewFactory())->viewFrom($this->paymentRequest($responseData))); + } + + public function testReturnsNullWhenAConfirmFieldIsMissing(): void + { + foreach (['mobile_access_token', 'customer_url', 'order_reference'] as $field) { + $responseData = self::RESPONSE_DATA; + unset($responseData['payment_elements'][$field]); + + self::assertNull( + (new PaymentElementsViewFactory())->viewFrom($this->paymentRequest($responseData)), + sprintf('Expected no view without "%s" - the SDK cannot confirm() without it.', $field), + ); + } + } + + public function testReturnsNullWhenTheGatewayConfigLacksCredentials(): void + { + self::assertNull( + (new PaymentElementsViewFactory())->viewFrom($this->paymentRequest(apiUsername: '')), + ); + } + + /** + * @param array|null $responseData + */ + private function paymentRequest( + ?array $responseData = null, + string $environment = EveryPayGateway::ENVIRONMENT_DEMO, + string $apiUsername = 'a04e7ce1060e7024', + ): PaymentRequest { + $gatewayConfig = $this->createStub(GatewayConfigInterface::class); + $gatewayConfig->method('getConfig')->willReturn([ + EveryPayGateway::CONFIG_API_USERNAME => $apiUsername, + EveryPayGateway::CONFIG_ACCOUNT_NAME => 'EUR3D1', + EveryPayGateway::CONFIG_ENVIRONMENT => $environment, + ]); + + $method = $this->createStub(PaymentMethodInterface::class); + $method->method('getGatewayConfig')->willReturn($gatewayConfig); + + $payment = new Payment(); + $payment->setAmount(2599); + + $paymentRequest = new PaymentRequest($payment, $method); + $paymentRequest->setResponseData($responseData ?? self::RESPONSE_DATA); + + return $paymentRequest; + } +} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 767ebcd..d830951 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -10,9 +10,10 @@ pkg_everypay: environment_demo: 'Demo (test)' environment_live: 'Live (production)' display_mode: 'Checkout appearance' - display_mode_help: 'The method grid shows bank and card buttons inside the shop; customers land directly on their bank''s payment page.' + display_mode_help: 'The method grid shows bank and card buttons inside the shop; customers land directly on their bank''s payment page. The embedded checkout (experimental) renders EveryPay''s card and bank form on a page of the shop.' display_mode_redirect: 'Redirect to the EveryPay payment page' display_mode_method_grid: 'Show the payment method buttons in the shop' + display_mode_payment_elements: 'Embedded checkout inside the shop (experimental)' merchant_portal_url: 'Merchant portal address' merchant_portal_url_help: 'Optional. Banks offer the merchant portal under their own address (e.g. SEB: https://portal.ecommerce.sebgroup.com); admin pages link there instead of the standard EveryPay portal.' choose_payment_method: 'Choose a payment method' @@ -22,5 +23,7 @@ pkg_everypay: international: 'International' after_payment_note: 'After completing the payment you will be brought back to the shop.' back_to_order: 'Back to the order' + pay_now: 'Pay now' + payment_elements_failed: 'The payment could not be processed. Please try again, or use the EveryPay payment page below.' everypay_state: 'EveryPay state' merchant_portal: 'EveryPay merchant portal' diff --git a/translations/messages.et.yaml b/translations/messages.et.yaml index 31c5073..9cd4d1b 100644 --- a/translations/messages.et.yaml +++ b/translations/messages.et.yaml @@ -11,9 +11,10 @@ pkg_everypay: environment_demo: 'Demo (test)' environment_live: 'Live (toodang)' display_mode: 'Maksevalik poes' - display_mode_help: 'Makseviiside vaade näitab panga- ja kaardinuppe poes; klient suunatakse otse oma panga makselehele.' + display_mode_help: 'Makseviiside vaade näitab panga- ja kaardinuppe poes; klient suunatakse otse oma panga makselehele. Poesisene makse (eksperimentaalne) kuvab EveryPay kaardi- ja pangavormi poe lehel.' display_mode_redirect: 'Suuna EveryPay makselehele' display_mode_method_grid: 'Näita makseviiside nuppe poes' + display_mode_payment_elements: 'Poesisene makse (eksperimentaalne)' merchant_portal_url: 'Kaupmehe portaali aadress' merchant_portal_url_help: 'Valikuline. Pangad pakuvad kaupmehe portaali oma aadressil (nt SEB: https://portal.ecommerce.sebgroup.com); halduslehed viitavad sinna standardse EveryPay portaali asemel.' choose_payment_method: 'Valige makseviis' @@ -23,5 +24,7 @@ pkg_everypay: international: 'Rahvusvahelised' after_payment_note: 'Pärast makse sooritamist suunatakse teid tagasi poodi.' back_to_order: 'Tagasi tellimuse juurde' + pay_now: 'Maksa kohe' + payment_elements_failed: 'Makset ei õnnestunud töödelda. Proovige uuesti või kasutage allolevat EveryPay makselehte.' everypay_state: 'EveryPay olek' merchant_portal: 'EveryPay kaupmehe portaal' diff --git a/translations/messages.lt.yaml b/translations/messages.lt.yaml index 0bed239..3c34652 100644 --- a/translations/messages.lt.yaml +++ b/translations/messages.lt.yaml @@ -10,9 +10,10 @@ pkg_everypay: environment_demo: 'Demo (testinė)' environment_live: 'Gyva (produkcinė)' display_mode: 'Atsiskaitymo pateikimas' - display_mode_help: 'Mokėjimo būdų tinklelis rodo bankų ir kortelių mygtukus parduotuvėje; pirkėjas nukreipiamas tiesiai į savo banko mokėjimo puslapį.' + display_mode_help: 'Mokėjimo būdų tinklelis rodo bankų ir kortelių mygtukus parduotuvėje; pirkėjas nukreipiamas tiesiai į savo banko mokėjimo puslapį. Įterptasis atsiskaitymas (eksperimentinis) rodo EveryPay kortelės ir banko formą parduotuvės puslapyje.' display_mode_redirect: 'Nukreipti į EveryPay mokėjimo puslapį' display_mode_method_grid: 'Rodyti mokėjimo būdų mygtukus parduotuvėje' + display_mode_payment_elements: 'Įterptasis atsiskaitymas parduotuvėje (eksperimentinis)' merchant_portal_url: 'Pardavėjo portalo adresas' merchant_portal_url_help: 'Neprivaloma. Bankai pardavėjo portalą teikia savo adresu (pvz., SEB: https://portal.ecommerce.sebgroup.com); administravimo puslapiai nukreips ten, o ne į standartinį EveryPay portalą.' choose_payment_method: 'Pasirinkite mokėjimo būdą' @@ -22,5 +23,7 @@ pkg_everypay: international: 'Tarptautiniai' after_payment_note: 'Atlikę mokėjimą būsite grąžinti į parduotuvę.' back_to_order: 'Grįžti į užsakymą' + pay_now: 'Mokėti dabar' + payment_elements_failed: 'Mokėjimo apdoroti nepavyko. Bandykite dar kartą arba pasinaudokite žemiau esančiu EveryPay mokėjimo puslapiu.' everypay_state: 'EveryPay būsena' merchant_portal: 'EveryPay pardavėjo portalas' diff --git a/translations/messages.lv.yaml b/translations/messages.lv.yaml index 2f9cd32..b038896 100644 --- a/translations/messages.lv.yaml +++ b/translations/messages.lv.yaml @@ -11,9 +11,10 @@ pkg_everypay: environment_demo: 'Demo (testa)' environment_live: 'Live (produkcija)' display_mode: 'Norēķinu attēlojums' - display_mode_help: 'Maksājumu veidu režģis rāda banku un karšu pogas veikalā; pircējs nonāk tieši savas bankas maksājuma lapā.' + display_mode_help: 'Maksājumu veidu režģis rāda banku un karšu pogas veikalā; pircējs nonāk tieši savas bankas maksājuma lapā. Iegultā apmaksa (eksperimentāla) rāda EveryPay kartes un bankas formu veikala lapā.' display_mode_redirect: 'Pāradresēt uz EveryPay maksājuma lapu' display_mode_method_grid: 'Rādīt maksājumu veidu pogas veikalā' + display_mode_payment_elements: 'Iegultā apmaksa veikalā (eksperimentāla)' merchant_portal_url: 'Tirgotāja portāla adrese' merchant_portal_url_help: 'Neobligāti. Bankas tirgotāja portālu piedāvā savā adresē (piem., SEB: https://portal.ecommerce.sebgroup.com); administrācijas lapas norādīs uz to standarta EveryPay portāla vietā.' choose_payment_method: 'Izvēlieties maksājuma veidu' @@ -23,5 +24,7 @@ pkg_everypay: international: 'Starptautiskie' after_payment_note: 'Pēc maksājuma veikšanas jūs atgriezīsieties veikalā.' back_to_order: 'Atpakaļ pie pasūtījuma' + pay_now: 'Maksāt tagad' + payment_elements_failed: 'Maksājumu neizdevās apstrādāt. Mēģiniet vēlreiz vai izmantojiet zemāk esošo EveryPay maksājuma lapu.' everypay_state: 'EveryPay statuss' merchant_portal: 'EveryPay tirgotāja portāls'