Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down Expand Up @@ -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).
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '%<hex payment reference>%'`, 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`. |
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
56 changes: 40 additions & 16 deletions docs/everypay-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions features/paying_with_everypay.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading