Skip to content

Release/2.3.0 - #320

Open
adumont-payplug wants to merge 15 commits into
masterfrom
release/2.3.0
Open

Release/2.3.0#320
adumont-payplug wants to merge 15 commits into
masterfrom
release/2.3.0

Conversation

@adumont-payplug

@adumont-payplug adumont-payplug commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Description

Release 2.3.0. Two themes: Unified Hosted Fields (UHF) — a full card-payment pipeline built on
the PayPlug Unified API via payplug/unified-plugin-core (UPC) — and the OAuth2/PKCE
authentication migration
that underpins it. Plus a merchant-configurable Scalapay amount range,
and coverage enforcement in CI.

Motivation: move card payments off the legacy Retail SDK flow onto PayPlug's Unified API, with
PCI-scope-reducing hosted iframes at checkout, and replace the deprecated JWT auth helper with
OAuth2/PKCE.

Related issue(s): PRE-3563, PRE-3553, PRE-3550, PRE-3551, PRE-3614, PRE-3585, PRE-3552,
PRE-3646, PRE-3603

What's in it

Authentication — OAuth2/PKCE via UPC (PRE-3563)

  • Payplug\Authentication JWT helper replaced by PayplugUnifiedCore\Auth\OAuth2Client /
    TokenManager. UnifiedAuthenticationController now builds the authorization URL with
    buildAuthorizationUrl(), stores state + PKCE codeVerifier in the session, and validates
    state on callback before exchangeAuthorizationCode().
  • Two adapters bridge Symfony to UPC's contracts: SyliusOAuthHttpClient (IOAuthHttpClient,
    translating transport failures into a status => 0 response instead of letting them escape) and
    SyliusTokenCache (ITokenCache, sanitizing PSR-6-illegal key characters).
  • Background client-credentials token retrieval in PayPlugApiClientFactory goes through
    TokenManager::getValidToken(); the manual TTL cache and its cache-key cleanup are gone.
  • New parameters payplug.oauth_base_url / payplug.oauth_audience (and
    payplug.unified_api_base_url), production by default, overridable via env vars for QA/staging.

Unified Hosted Fields — admin (PRE-3553)

  • The integratedPayment checkbox becomes a three-way display-mode radio (redirected / integrated
    payment / hosted fields) driving the two persisted flags, with a mandatory HF Account identifier
    field when Hosted Fields is selected. Switching modes clears the stale flag rather than leaving
    it behind.
  • The EUR-only channel-currency check now applies only to integrated_payment; redirected and
    Hosted Fields modes work in any currency.

Unified Hosted Fields — checkout & capture (PRE-3550, PRE-3551, PRE-3585)

  • New Stimulus controller + stylesheet render PayPlug's hosted iframes (card, brand, expiry, CVV),
    tokenize on submit, and post the token plus card metadata as hidden fields.
    PostPaymentSelectEventSubscriber picks that up, guards it against a crafted POST for a
    method that doesn't have Hosted Fields enabled, and completes the checkout.
  • Capture runs through Sylius's command/response provider layer: CaptureHostedPaymentRequest
    (token) and CaptureAliasPaymentRequest (a saved card) with their handlers, plus
    Notify/Status variants. The existing payplug-tagged providers delegate to the Hosted-Fields
    counterparts via PayPlugGatewayFactory::isHostedFieldsConfig(), so no other gateway's behaviour
    changes.
  • CaptureHttpResponseProvider additionally handles the Unified API's 3DS-pending
    redirect_html shape (a self-submitting form) alongside redirect_url.
  • ~20 src/Upc/ classes implement UPC's contracts against Sylius (logger, lock, HTTP client,
    configuration/payment repositories, order-state mutator) and the DTO/aliasing plumbing.

Unified Hosted Fields — webhooks (PRE-3614)

  • New UnifiedApiIpnAction on a fixed, parameter-less route: PayPlug's Unified API Receiver is
    configured once per account in Cockpit and cannot target Sylius's per-payment-method notify URL.
    IpnAction is left untouched and marked @deprecated (legacy SDK gateways only).
  • HostedFieldsWebhookNotificationHandler verifies/parses the notification, cross-checks orderId
    and amount against the resolved payment, and is idempotent under a lock keyed by operation id so
    a webhook and the Status-polling fallback serialize against each other.
  • New PayPlugOperation entity + table for webhook idempotency bookkeeping.

Unified Hosted Fields — refunds (PRE-3552)

  • RefundPaymentProcessor routes UHF payments through UPC's createRefund(), recording the
    refund's operation id in Payment::details so the async confirmation webhook can be resolved
    back to the payment. Full and partial refunds share one lock key, which the webhook handler also
    takes before writing to the same details['refunds'] array.

Multicurrency fixes and checkout hardening (PRE-3646)

  • SupportedMethodsProvider::provide() now takes the payment's currency instead of reading the
    display currency from CurrencyContextInterface — on a multi-currency channel those differ, and
    comparing an amount against another currency's min/max silently filtered the wrong methods.
  • UHF is exempted from the currency gate: the Retail /account payload only describes the legacy
    acquiring setup and under-reports a UHF account's currencies (a USD payment completed on an
    account advertising EUR only). Every other gateway keeps the gate.
  • The "SubMerchant identifier" (hfSubMerchantId) admin field is removed — it belongs to UDV/MID
    config, and sending it made the Unified API reject refunds with 400 "Invalid parameter.".

Scalapay configurable amount range (PRE-3603)

  • Merchants can tighten (never widen) the API-provided min/max in the admin form, validated at save
    time by IsScalapayAmountRangeValidValidator against what PayPlug authorizes. Malformed values
    from a direct DB/API write degrade to "not configured" and log a warning, rather than breaking
    payment-method resolution for the whole checkout. Amount-range resolution is extracted into
    AccountAmountRangeResolver.

Card-alias race fix

  • payplug_cards gains a unique constraint on (external_id, is_live). The synchronous capture
    and the async webhook are two independent save paths for the same alias, so the
    findOneBy-then-add guard could lose a race. Both call sites now handle
    UniqueConstraintViolationException (and reset the manager registry, since Doctrine closes the
    EM on any failed flush).

CI / tooling

  • New coverage job (PHP 8.2 / Sylius 2.1.0 / Symfony 7.3, PCOV) uploads a Clover report consumed
    by the sonarcloud-coverage.yml reusable workflow with enforce-quality-gate: true.
    composer test-coverage + make coverage (via a new Dockerfile) reproduce it locally.
  • GrumPHP switches from securitychecker_symfony to securitychecker_composeraudit (abandoned
    packages reported, not fatal — the offenders are transitive Sylius/Behat deps).
  • doctrine/orm pinned to >=3.5 <3.7 in require-dev: ORM 3.7.0's new SchemaValidator class
    check is a false positive against Sylius's interface-based resource overrides and fails
    doctrine:schema:validate in every matrix cell. Temporary; see CLAUDE.md.
  • composer.lock is now committed, PHPStan gets --memory-limit=4G, coverage badge added.

Migrations

Three, all additive except the last:

Migration Effect
Version20260810120000 creates payplug_upc_operation
Version20260901120000 de-duplicates then adds UNIQ_payplug_cards_external_id_is_livemay delete pre-existing duplicate card rows (keeps the lowest id per pair), otherwise the index creation fails outright
Version20260907140000 purges the orphaned hfSubMerchantId key from sylius_gateway_config; deliberately not reversible (the value is a credential this migration does not retain)

Merchant-facing / upgrade notes

  • New required dependency: payplug/unified-plugin-core ^1.1.0.
  • Run the three migrations above.
  • Merchants using Hosted Fields must configure the account-level Receiver in Cockpit to point at
    the new UnifiedApiIpnAction route.
  • Admin re-configuration: the integratedPayment checkbox is now a display-mode radio, and
    Hosted Fields requires an HF Account identifier.

Open points before merging

  1. templates/shop/hosted_fields/index.html.twig:1 loads the Hosted Fields SDK from
    https://staging-internal-payment.gcp.dlns.io/...
    — a hardcoded staging host. This must point
    at the production URL (ideally through a parameter, like the other Unified API endpoints) before
    this ships to merchants. This is the reason the "no hardcoded values" box below is unticked.
  2. Coverage and SonarCloud won't run on this PR: both jobs are gated on
    github.base_ref == 'develop', and this targets master. The quality gate was validated on the
    feature PRs into develop, not here.
  3. features/shop/hosted_fields_payment_and_webhook_flow.feature is committed with steps that have
    no definitions yet (documented in the file header) — Behat reports them as undefined. The
    Mocker / webhook page-object infrastructure they need is still to be built.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that causes existing functionality to change and that could impact other libs)
  • 🔧 Refactor (no functional changes, code improvement only)
  • 📦 Dependency update

Checklist

Code Quality

  • Code is linted and formatted
  • No unnecessary commented-out code or debug logs
  • No hardcoded values (use env variables or config) — see open point 1 (staging SDK URL)

Testing

  • Unit tests added / updated — ~50 new PHPUnit test files covering the UPC adapters, capture
    handlers, webhook handler, refund paths, form types and validators
  • New/changed code is covered by tests — SonarCloud Quality Gate (coverage on new code) passes on the sonarcloud CI job — see open point 2 (not run against master)

Security & Ops

  • No sensitive data or secrets introduced — hfSubMerchantId (a PasswordType field) is
    actively purged from persisted configs by Version20260907140000
  • Logging and error handling are appropriate — dedicated monolog.logger.payplug channel;
    transport failures, malformed config and lost dedup races all degrade rather than throw

adumont-payplug and others added 15 commits July 28, 2026 14:08
…t Sylius checkout (#308)

* PRE-3550: add hostedFields gateway config flag and credential fields

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: reject combining integratedPayment and hostedFields on the same payment method

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: register admin form hook for Hosted Fields configuration

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: add HostedFieldsPaymentProcessorInterface with a no-op stub pending PRE-3551

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: relay Hosted Fields token to HostedFieldsPaymentProcessorInterface

* PRE-3550: add Hosted Fields shop checkout template

* PRE-3550: add Hosted Fields Stimulus controller

* PRE-3550: add Behat coverage for Hosted Fields checkout visibility

* PRE-3550: allow several payment methods on the payplug gateway factory

A merchant must be able to offer Integrated Payment and Hosted Fields side by
side, which requires two PaymentMethod entities sharing factoryName=payplug.
canBeCreated() now bypasses the duplicate check for that factory only; every
other PayPlug-family factory (Oney, Bancontact, Amex, Apple Pay, Scalapay,
Wero) keeps the one-payment-method-per-factory rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: only accept a Hosted Fields token when the flag is enabled

handleHostedFieldsToken() used to process any request carrying a non-empty
hostedfields_token, so a crafted POST could complete checkout through that path
for any payment method. It now verifies the payment method's gateway config
actually has hostedFields=true before delegating to the processor.

Also adds coverage for alterRequestConfigurationForInlineCardCapture().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: mount the Hosted Fields iframes only once the method is selected

Three related frontend fixes:
- the wrapping div now carries data-payment-inline-submit="true", so the generic
  checkout "next step" button is disabled while Hosted Fields is selected
  (clicking it submitted an empty hostedfields_token, bypassing tokenization);
- connect() no longer calls dalenys.hostedFields(...).load() unconditionally.
  The cross-origin iframes were mounted into a container that is still hidden at
  connect time (see shop/select_payment/choice.html.twig). The controller now
  mirrors integrated-payment: container target, idempotent openFields()/
  closeFields() and handleShow()/handleHide(), loading on selection only;
- the saved-card radios now pipe handleHide/handleShow to the hosted-fields
  controller alongside integrated-payment, so picking a saved card hides the
  Hosted Fields form when both oneClick and hostedFields are enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: mock the PayPlug account lookup in the Hosted Fields Behat scenario

The Hosted Fields shop template calls is_payplug_test_mode_enabled(), which
performs a real PayPlug account lookup; the fixture's secretKey 'test' is not a
valid credential. The scenario now uses the existing "This secret Key is valid"
step, whose context had to be registered in the shop suite that runs it.

Also bundles three small fixes:
- HostedFieldsPaymentProcessorInterface is now an alias instead of a second
  definition, so it resolves to the auto-registered service and keeps its
  @monolog.logger.payplug binding;
- fixes the "Paiement Integré" -> "Paiement Intégré" typo in validators.fr.yml;
- refreshes the two stale constraint-count docblocks in PaymentMethodValidatorTest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: keep Hosted Fields away from Payum after checkout completion

A Hosted Fields payment carries a Dalenys hfToken and no PayPlug payment_id
until PRE-3551 lands, so routing it to sylius_shop_order_pay made StatusAction
markNew(), Payum rebuild the details through Convert and CaptureAction issue a
real createPayment() API call - which the temporary stub must never cause, even
indirectly.

The redirect override cannot simply be dropped: Sylius's CheckoutRedirectListener
listens to the same sylius.order.post_payment event and bails out only when
_sylius['redirect'] is set. Without it, it resolves a route for the `completed`
checkout state, which has no entry in sylius_shop.checkout_resolver.route_map,
and the request dies with a RouteNotFoundException.

Hosted Fields is therefore redirected to sylius_shop_order_show instead (same
token-based, guest-accessible route, no Payum involved). Integrated Payment keeps
sylius_shop_order_pay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: align the redirect precedence with handle()'s dispatch order

The redirect ternary picked sylius_shop_order_pay whenever hasToken() was true,
but handle() checks hasHostedFieldsToken() first. A request carrying both token
fields was therefore processed as Hosted Fields - never writing a payment_id -
while still being redirected to sylius_shop_order_pay, reopening the
StatusAction -> Convert -> CaptureAction::createPayment() chain this redirect
exists to prevent.

The ternary now checks hasHostedFieldsToken() first, mirroring handle(). Tests
pin the invariant on both sides so the two cannot drift apart again.

Also uses self::UPDATE_ORDER_PAYMENT_ROUTE instead of repeating its literal value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: add oneClick to the payplug_uhf gateway configuration

* PRE-3550: add PaymentMethodValidator::processUhf() with a oneClick permission check

* PRE-3550: repoint the Hosted Fields shop flow onto payplug_uhf

* PRE-3550: remove the flag-based Hosted Fields implementation on payplug

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* PRE-3550: register the payplug_uhf twig hook so Hosted Fields checkout renders

The old hostedFields flag branch in _payplug.html.twig included the card-iframe
markup, but that flag was removed when Hosted Fields moved to its own
payplug_uhf gateway factory, leaving the shop checkout with no include site
for templates/shop/hosted_fields/index.html.twig at all. Add the missing
#payplug_uhf twig-hook entry (following the same pattern as the other
factory-keyed hooks in shop.yaml) with a dedicated
_payplug_uhf.html.twig partial, and drop the now-dead hostedFields branch
from _payplug.html.twig, which is exclusively for the payplug (Integrated
Payment) factory.

* PRE-3550: JS-escape values interpolated into the Hosted Fields inline script

Twig's HTML autoescaping does not escape a bare apostrophe, so any of the
translated/dynamic values interpolated into the single-quoted JS string
literals in the hosted_fields inline <script> block (companyId, payment
method code, translated error messages) would silently break the script and
prevent the fields from mounting if the value ever contained one. Apply the
|e('js') filter to those four values.

* PRE-3550: remove the now-unjustified payplug exemption in canBeCreated()

The exemption let a merchant create two PaymentMethod entities on the
payplug factory, to support the old flag-based Integrated Payment /
Hosted Fields split. That design is gone: Hosted Fields is now its own
payplug_uhf factory, so the exemption just permanently relaxes the
one-payment-method-per-factory rule on the main card-payment gateway,
allowing e.g. two integratedPayment=true payplug methods and the
duplicate-ID breakage that would cause in
templates/shop/integrated/index.html.twig. Remove the exemption and its
test.

* PRE-3550: assert Hosted Fields markup actually renders in the Behat scenario

The scenario's only assertion was the generic "I should be able to select"
step, which just checks the radio input exists and would still pass even
with the Hosted Fields include site missing entirely (see the payplug_uhf
twig-hook fix). Add a reusable "I should see the :selector element on the
page" step to CheckoutContext (extending RawMinkContext for Mink session
access) and use it, after selecting the payment method, to assert
#card-container from hosted_fields/index.html.twig is present.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* PRE-3614: handling IPN notification

* chore: bump payplug/unified-plugin-core to 1.0.1, drop local path override

* PRE-3614: UnifyApi pending-3DS mapping/notifier
Co-authored-by: adumont-payplug <adumont@payplug.com>
@wiz-14d684d7a6

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations 10 Medium 2 Low
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total 10 Medium 2 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants