Release/2.3.0 - #320
Open
adumont-payplug wants to merge 15 commits into
Open
Conversation
…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 Scan Summary
To detect these findings earlier in the dev lifecycle, try the Wiz Code extension for VS Code, JetBrains, or Visual Studio. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/PKCEauthentication 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\AuthenticationJWT helper replaced byPayplugUnifiedCore\Auth\OAuth2Client/TokenManager.UnifiedAuthenticationControllernow builds the authorization URL withbuildAuthorizationUrl(), storesstate+ PKCEcodeVerifierin the session, and validatesstateon callback beforeexchangeAuthorizationCode().SyliusOAuthHttpClient(IOAuthHttpClient,translating transport failures into a
status => 0response instead of letting them escape) andSyliusTokenCache(ITokenCache, sanitizing PSR-6-illegal key characters).PayPlugApiClientFactorygoes throughTokenManager::getValidToken(); the manual TTL cache and its cache-key cleanup are gone.payplug.oauth_base_url/payplug.oauth_audience(andpayplug.unified_api_base_url), production by default, overridable via env vars for QA/staging.Unified Hosted Fields — admin (PRE-3553)
integratedPaymentcheckbox becomes a three-way display-mode radio (redirected / integratedpayment / 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.
integrated_payment; redirected andHosted Fields modes work in any currency.
Unified Hosted Fields — checkout & capture (PRE-3550, PRE-3551, PRE-3585)
tokenize on submit, and post the token plus card metadata as hidden fields.
PostPaymentSelectEventSubscriberpicks that up, guards it against a crafted POST for amethod that doesn't have Hosted Fields enabled, and completes the checkout.
CaptureHostedPaymentRequest(token) and
CaptureAliasPaymentRequest(a saved card) with their handlers, plusNotify/Status variants. The existing
payplug-tagged providers delegate to the Hosted-Fieldscounterparts via
PayPlugGatewayFactory::isHostedFieldsConfig(), so no other gateway's behaviourchanges.
CaptureHttpResponseProvideradditionally handles the Unified API's 3DS-pendingredirect_htmlshape (a self-submitting form) alongsideredirect_url.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)
UnifiedApiIpnActionon a fixed, parameter-less route: PayPlug's Unified API Receiver isconfigured once per account in Cockpit and cannot target Sylius's per-payment-method notify URL.
IpnActionis left untouched and marked@deprecated(legacy SDK gateways only).HostedFieldsWebhookNotificationHandlerverifies/parses the notification, cross-checks orderIdand 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.
PayPlugOperationentity + table for webhook idempotency bookkeeping.Unified Hosted Fields — refunds (PRE-3552)
RefundPaymentProcessorroutes UHF payments through UPC'screateRefund(), recording therefund's operation id in
Payment::detailsso the async confirmation webhook can be resolvedback 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 thedisplay currency from
CurrencyContextInterface— on a multi-currency channel those differ, andcomparing an amount against another currency's min/max silently filtered the wrong methods.
/accountpayload only describes the legacyacquiring 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.
hfSubMerchantId) admin field is removed — it belongs to UDV/MIDconfig, and sending it made the Unified API reject refunds with
400 "Invalid parameter.".Scalapay configurable amount range (PRE-3603)
time by
IsScalapayAmountRangeValidValidatoragainst what PayPlug authorizes. Malformed valuesfrom 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_cardsgains a unique constraint on(external_id, is_live). The synchronous captureand the async webhook are two independent save paths for the same alias, so the
findOneBy-then-addguard could lose a race. Both call sites now handleUniqueConstraintViolationException(and reset the manager registry, since Doctrine closes theEM on any failed flush).
CI / tooling
coveragejob (PHP 8.2 / Sylius 2.1.0 / Symfony 7.3, PCOV) uploads a Clover report consumedby the
sonarcloud-coverage.ymlreusable workflow withenforce-quality-gate: true.composer test-coverage+make coverage(via a newDockerfile) reproduce it locally.securitychecker_symfonytosecuritychecker_composeraudit(abandonedpackages reported, not fatal — the offenders are transitive Sylius/Behat deps).
doctrine/ormpinned to>=3.5 <3.7inrequire-dev: ORM 3.7.0's newSchemaValidatorclasscheck is a false positive against Sylius's interface-based resource overrides and fails
doctrine:schema:validatein every matrix cell. Temporary; seeCLAUDE.md.composer.lockis now committed, PHPStan gets--memory-limit=4G, coverage badge added.Migrations
Three, all additive except the last:
Version20260810120000payplug_upc_operationVersion20260901120000UNIQ_payplug_cards_external_id_is_live— may delete pre-existing duplicate card rows (keeps the lowest id per pair), otherwise the index creation fails outrightVersion20260907140000hfSubMerchantIdkey fromsylius_gateway_config; deliberately not reversible (the value is a credential this migration does not retain)Merchant-facing / upgrade notes
payplug/unified-plugin-core ^1.1.0.the new
UnifiedApiIpnActionroute.integratedPaymentcheckbox is now a display-mode radio, andHosted Fields requires an HF Account identifier.
Open points before merging
templates/shop/hosted_fields/index.html.twig:1loads the Hosted Fields SDK fromhttps://staging-internal-payment.gcp.dlns.io/...— a hardcoded staging host. This must pointat 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.
github.base_ref == 'develop', and this targetsmaster. The quality gate was validated on thefeature PRs into
develop, not here.features/shop/hosted_fields_payment_and_webhook_flow.featureis committed with steps that haveno 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
Checklist
Code Quality
Testing
handlers, webhook handler, refund paths, form types and validators
sonarcloudCI job — see open point 2 (not run againstmaster)Security & Ops
hfSubMerchantId(aPasswordTypefield) isactively purged from persisted configs by
Version20260907140000monolog.logger.payplugchannel;transport failures, malformed config and lost dedup races all degrade rather than throw