Skip to content

PRE-3750: Add cardholder name and in-field network selector to the Hosted Fields card form - #339

Merged
jhoaraupp merged 1 commit into
developfrom
feature/PRE-3750
Oct 28, 2026
Merged

jhoaraupp merged 1 commit into
developfrom
feature/PRE-3750

Conversation

@jhoaraupp

@jhoaraupp jhoaraupp commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Hosted Fields (UHF) card form at checkout is reworked to match the mockup attached to the ticket (Dalenys Hosted Fields lib v2.2.0): a cardholder name field, the network selector inside the card number field, vertically centred text, and per-field valid/invalid states.

  • Cardholder name.
    • A native input (autocomplete="cc-name") is added above the card number. It is required and must be 5 to 45 characters after trimming, counted in characters (mb_strlen), valid UTF-8 and free of control characters. Both sides compose it (NFC) before trimming it like the browser's String.prototype.trim() (Unicode spaces such as the no-break space and the BOM included), so both count the characters the shopper sees.
    • The browser blocks tokenization with a message under the field. The server checks the same rules again.
    • The name is posted as the hidden field hostedfields_card_holder_name, stored in the payment details as hosted_fields_card_holder_name, and sent to the Unified API as paymentMethod.details.fullName.
    • When the key is absent (payments started before the upgrade), the billing or customer name is used, as before.
  • Server-side validation in HostedFieldsCaptureData.
    • The name is checked against the rules above.
    • selectedBrand is trimmed and upper-cased, and must be one of CB, VISA, MASTERCARD (HostedFieldsCaptureData::ALLOWED_BRANDS). It used to be checked only in the browser, so a crafted POST could send any string to the Unified API.
    • A violation throws the new InvalidHostedFieldsSubmissionException. PostPaymentSelectEventSubscriber logs a warning that names the field but never the value, then sends the shopper back to the payment step with an error flash (en/fr/it), before the Hosted Fields details are stored or the checkout is completed (the payment method the shopper chose is already saved by ResourceController::updateAction(), which is harmless). A non-HTML request (headless front end) gets a 400 instead, since the controller answers it with its REST view before reading the event response. Sylius renders no flashes on that step, so the plugin adds @SyliusShop/shared/flashes.html.twig to the sylius_shop.checkout.select_payment.content twig hook.
    • The brand list and the length bounds are exposed from PHP to the template and the JS, so the browser and the server cannot drift.
  • Network selector.
    • The lib v2 brand field is overlaid on the right of the card number field, with useInlineSelection: true. It stays empty until a card is typed.
    • A co-branded card shows each network side by side, the merchant default checked, and the shopper switches with one click.
    • The static Visa/MC badges above the card field are removed.
    • On screens of 400px or less, less room is reserved while a single network is shown.
  • "Sélectionnez votre réseau" label.
    • The SDK appends #info-container to <body> and positions it once, without reserving space.
    • A MutationObserver now places it under the card field and reserves the space while it is shown.
    • This replaces the PRE-3709 in-flow copy of the label and its tooltip.
    • Each SDK instance appends its own label: disconnect() removes it, so a re-rendered form does not leave a stale second label on screen.
    • The SDK keeps the label shown while a co-branded card number is invalid. In that case the card error reserves the label's room with its top padding, so the message sits on its own line under the label instead of on the same strip.
  • Field styling.
    • Fields are 48px high with a line-height of 46px, so the text is vertically centred.
    • The font is the same as the cardholder field, as an unquoted font stack: the SDK rejects the style of every field if font-family contains quotes.
    • Only whitelisted SDK style properties are used.
    • Each field gets a green border when valid and a red border plus a message when invalid.
    • The field errors use aria-live="polite", and the holder input has aria-describedby and aria-invalid.
  • No field mounted while hidden. The iframes are loaded only once the form container has a box (ResizeObserver). Before, a preselected method, or one chosen before its wrapper was shown, mounted the cross-origin iframes inside a display:none container.
  • Enter in the cardholder field runs the same path as the pay button instead of submitting the checkout form natively.
  • Translations (en/fr/it):
    • Added: card_holder.label, card_holder.placeholder, error.card_holder_required, error.card_holder_length (with %min%/%max%).
    • Removed: network_selection.label and network_selection.tooltip.

Motivation: the current form has no cardholder name field, although the Unified API needs details.fullName to save a card. The network logos are not tied to the detected card, the co-branded selector is rendered outside the card field, and the typed text is stuck to the top of the fields in a different font.

Related issue(s): PRE-3750. Follows PRE-3709 (co-branded network selector fix).


Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue) [x]
  • ✨ New feature (non-breaking change that adds functionality) [x]
  • 💥 Breaking change (fix or feature that causes existing functionality to change and that could impact other libs) [x]
  • 🔧 Refactor (no functional changes, code improvement only) [ ]
  • 📦 Dependency update [ ]
  • 🔒 Security fix [x]
  • 📝 Documentation 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)

Testing

  • Unit tests added / updated
  • New/changed code is covered by tests — SonarCloud Quality Gate (coverage on new code) passes on the sonarcloud CI job

Security & Ops

  • No sensitive data or secrets introduced
  • Logging and error handling are appropriate

Notes for reviewer

Breaking changes. All are documented in the CHANGELOG 2.0.0 "Breaking changes for anyone extending the plugin" table. 2.0.0 is still unreleased.

  • HostedFieldsCaptureData::__construct() is now ($hfToken, $selectedBrand, $saveCard, HostedFieldsCardDetails $card, $cardHolderName): the card metadata moved to the new HostedFieldsCardDetails value object ($captureData->card->last4, etc.), which keeps the constructor under Sonar php:S107, and the cardholder name is a new required argument. It now validates the brand and the name.
  • PostPaymentSelectEventSubscriber::__construct() now also takes a LoggerInterface, which is autowired.
  • The translation keys ui.hosted_fields.network_selection.label and .tooltip are removed.
  • Themes overriding shop/hosted_fields/index.html.twig must add the cardholder input and the hidden hostedfields_card_holder_name input. Otherwise the checkout is sent back to the payment step with an error message.
  • An empty selectedBrand used to pass and is now refused.

fullName without saveFutureUsage. The ticket context says fullName is never sent without saveFutureUsage, while its expected results say the typed name is used for details.fullName. The plugin already sent fullName whenever it had one, and this PR keeps that behaviour, with the typed name now taking priority over the billing name. Switching to "only with saveFutureUsage" is a one-line change in CaptureHostedPaymentRequestHandler::buildHostedFieldDto(). The staging Unified API accepts fullName without saveFutureUsage (see the tests below).

SDK options checked against the v2.2.0 bundle source.

  • locale is the option the lib reads. The mockup's location is ignored.
  • useInlineSelection defaults to false, so it is set explicitly.
  • The style whitelist is: color, font-family, font-size, font-style, font-variant, font-weight, font-smooth, line-height, letter-spacing, text-decoration, text-shadow, text-transform, background-color.

Tests. 52 PHPUnit tests are added:

  • HostedFieldsCaptureDataTest (new): bounds 4/5/45/46, NFC composition, trimming (Unicode spaces and BOM included), control characters, multibyte names, invalid UTF-8, unknown, empty and lowercase brands, the card details value object, and messages never containing the value.
  • PostPaymentSelectEventSubscriberTest: each invalid submission redirects back to the payment step with the error flash (also without a session), a non-HTML request gets a 400 without a flash, with no setDetails, process, apply or flush, and no value in the log; a padded lowercase brand is processed normalised.
  • NullHostedFieldsPaymentProcessorTest: the name is stored and never logged.
  • CaptureHostedPaymentRequestHandlerTest: the typed name wins, the billing name is the fallback, and saveFutureUsage is sent with a typed name even when the billing name is empty.

Local results (PHP 8.2):

  • PHPUnit: 1072 tests, OK (rebased on develop with PRE-3677). This needs UPC 1.2.1 as pinned in composer.lock; my local vendor/ was stale on UPC 1.1.0.
  • ECS: clean.
  • PHPStan and PHPMD: no new error in the touched files.

There is no JS test harness in the repo, so the controller and CSS are not covered by automated tests.

Browser and end-to-end tests performed. All tests ran in headless Chromium (Playwright) against the local Docker Sylius shop.

  • Environment:
    • staging Dalenys SDK v2.2.0 (staging-internal-payment.gcp.dlns.io);
    • staging Unified API;
    • account PluginTestUHFMultideviseCredorax, paid in USD;
    • UPC 1.2.2 in the shop.
  • Test cards (from the ticket): 4234600948280000 co-branded CB/Visa, 4022050523250000 Visa, 5186629195300000 Mastercard.
# Scenario Result
1 Empty form ✅ Cardholder field present; no static Visa/MC badges; brand area empty until a card is typed
2 Visa / Mastercard card ✅ One network chip with a checkmark inside the card field; no network label, no reserved space
3 Co-branded card ✅ CB (merchant default) checked and VISA side by side in the field; one click moves the checkmark to VISA, and selectedBrand=VISA is posted
4 Network label placement (1280px and 320px) ✅ Right under the card field, never over MM/AA. It follows the field after a holder error appears or clears above it, after scrolling, and after a resize.
5 Co-branded card with an invalid number (label kept by the SDK) ✅ Error on its own line under the label, with no empty band (fixed in this PR after QA, see the "Network selector" section above)
6 Font and centring ✅ The SDK accepts the style. Helvetica, Arial, sans-serif, 14px, line-height 46px in the card, expiry and CVV iframes, identical to the holder input.
7 Holder validation in the browser ✅ Empty, 4 characters, 46 characters and " Jo " each show a message under the field, a red border and aria-invalid="true", with no tokenize call. 5 characters goes through.
8 Invalid card / expiry / CVV ✅ Red border and the SDK message under each field; valid fields get a green border
9 Enter in the holder input ✅ Tokenizes, then posts the name and brand (no native submit)
10 Crafted POSTs: no name, brand AMEX, "Jo B", a name with \n, NBSP + abcd ✅ Redirected back to the payment step with the error message above the payment methods; the warning log names the field and the reason, never the value. The normal shipping → payment → complete flow shows no extra flash.
11 Payment method preselected on load, switching to another method and back, browser back ✅ The iframes always mount at their real size (card 204×46, brand 132×32), never 150px
12 320px viewport ✅ The brand area is 68px for a single network and 132px for a co-branded card. The SDK keeps the label after a network is picked, so the second chip is never clipped.
13 E2E, co-branded switched to VISA, submitted with Enter ✅ details: {"fullName":"Jean Dupont","selectedBrand":"VISA"}, API 200, order paid
14 E2E, co-branded on CB, name " Élodie Müller-Ñúñez " ✅ fullName is trimmed and the accents are intact, both in the payload and in the payment details (valid UTF-8); API 200, order paid
15 E2E Mastercard (guest) ✅ selectedBrand: "MASTERCARD", order paid
16 E2E card saving (logged-in customer, One-click on) ✅ "details":{"fullName":…,"selectedBrand":"VISA"},"saveFutureUsage":true; 3DS challenge passed; order paid; card saved as VISA ****0000
17 E2E "pay with another card" next to a saved card ✅ The Hosted Fields form stays hidden with no iframe mounted while the saved card is selected. After choosing "other card", the 4 iframes mount at their real size and the Mastercard payment goes through (payplug_card_choice=other).
18 E2E deferred capture ✅ "capture":false with fullName; order authorized, then paid after "Capturer" on the admin order screen
19 Controller re-render (element replaced by fresh markup) with a co-branded card ✅ One network label after reconnect, placed under the card field and following scroll (before the fix: two labels shown, the stale one observed)
20 E2E Mastercard after the review fixes, name NBSP + "Zoé Müller" + NBSP ✅ hostedfields_card_holder_name="Zoé Müller", order paid; tests 4 and 5 re-run at 1280 and 320px, all OK
21 Name typed in decomposed form (E + combining accent) ✅ 45 decomposed "É" (90 code points) are accepted; a decomposed Zoé Müller is posted composed (10 code points) and the VISA payment goes through

fullName without saveFutureUsage (Q1) is answered. The staging Unified API accepts it (tests 13 to 15).

Not done / known.

  • Screen readers: not tested. The error divs go from display:none to visible in the same tick as their text, so some screen readers may not announce them.
  • Other locales: the label/error spacing uses a fixed 26px, sized for a one-line label about 18px high. A longer label that wraps at 320px could touch the error again. Only the French locale was available in the test shop.
  • Console errors from the Dalenys SDK, not from the plugin:
    • an invalid pattern regex inside the expiry iframe;
    • Cannot read properties of undefined (reading 'toggleClasses') when leaving the page.
  • last4 / exp_month / exp_year / country: still posted empty, because createToken() does not return them under the field names the controller reads. This predates this PR, and the saved card still gets its last4 and expiry from the API.
  • Fallback name (payments started before the upgrade): the billing or customer name sent as fullName is not checked against 5–45. This predates the PR; tracked in PRE-3773.
  • Local environment: the plugin always loads the prod SDK unless PAYPLUG_HOSTED_FIELDS_SDK_URL is set. Against the staging Unified API, a prod-SDK token is refused with "HFTOKEN has not been found or is expired", so staging setups must set that variable.
  • The existing debug log of the Unified API payload (debug level only) now carries the typed cardholder name. It already carried the customer email, and fullName whenever the card was saved. Nothing in it is masked; masking it is tracked in PRE-3772.

@ilajili ilajili left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the Hosted Fields rework: no blocking finding, CI is green. Inline notes below are one Medium (Sonar S107) and a few Low points.

@hdelaforce-payplug hdelaforce-payplug left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Nothing blocking: nothing here can lose or duplicate money, no admin action is exposed, the server-side checks add protection, and the capture path only changes where fullName comes from. Approve once a JSON 400 for headless shops (Low 2) and the wording fix (Low 1) are in.

Checks run on c3ee196 (PHP 8.3):

  • PHPStan (level max): no errors
  • ECS on src and tests: clean
  • PHPUnit, touched tests (PaymentProcessing, PostPaymentSelectEventSubscriberTest, CaptureHostedPaymentRequestHandlerTest): 114 tests OK
  • PHPUnit, full suite: 796 tests, 1 failure in UnifiedAuthenticationControllerTest (OAuth, not touched by this PR). It comes from my local vendor/, not from this branch.
  • Not run: PHPMD, Behat, browser checks
Area Rating
Security Good
Payment correctness Good
Sylius / architecture Good
General correctness Some concerns (low)
Non-PHP files Good
Performance Good
Maintainability Good

Low

  1. "Nothing is written" is not quite true. The PR description and the CHANGELOG say the 400 comes "before anything is written on the payment". The guard (PostPaymentSelectEventSubscriber::buildHostedFieldsCaptureData()) runs on sylius.order.post_payment. By then, ResourceController::updateAction() has already saved the selected payment method and the order's select_payment step. What is true is that this subscriber writes nothing: no details, no complete. A crafted POST leaves the order at payment_selected, which is harmless.
    Suggestion: change the wording to "before the Hosted Fields details are stored or the checkout is completed".

  2. Raw 400 page. The BadRequestHttpException shows Symfony's error page. A legitimate shopper only gets there in two cases: a checkout page rendered before the deploy (old JS, no name field), or a theme override. A headless/SPA front end gets HTML rather than JSON, which goes against the repo's headless-compliance rule. That rule targets controllers, so this is borderline.
    Suggestion: either accept it, since the CHANGELOG already warns theme owners, or answer headless shops with a JSON 400 carrying an error field.

  3. The browser and the server apply the same rule only in practice. JS trim() (holderName()) strips Unicode spaces such as the non-breaking space; PHP \trim() strips ASCII spaces only. The two agree because the JS posts the value already trimmed. A name typed in decomposed form ("É" stored as two code points) counts 2 on both sides, and we don't know whether the Unified API's 45 limit counts the same way.
    Suggestion (optional): .normalize('NFC') in holderName().

  4. The typed name stays in the payment details (hosted_fields_card_holder_name, set by NullHostedFieldsPaymentProcessor) indefinitely. The billing name is already stored, so this adds little exposure. Worth knowing if payment details are exposed through the API.

  5. The fallback name is not checked against 5–45. For payments started before the upgrade, CaptureHostedPaymentRequestHandler::buildHostedFieldDto() still sends the billing or customer name with no length check. A billing name under 5 characters could be refused by the API. This existed before the PR and only matters during the upgrade window.

Non-PHP files

  • Twig:
    • Values are escaped with e('js') and e('html_attr').
    • json_encode|raw is applied only to a PHP constant.
    • %min%/%max% are filled.
    • The hidden input is present.
    • The holder input has no name, which avoids triggering the checkout's live component.
    • The aria-* wiring is consistent.
  • Translations (en/fr/it): the same 4 keys are added and the same 2 removed in all three. Placeholders match, and no other file still uses network_selection.*.
  • JS:
    • No console leftovers.
    • Observers are disconnected in disconnect().
    • The label positioning ignores its own style writes.
    • Enter in the name field goes through the pay button's path.
    • A theme without the name field is blocked, which is the documented breaking change.
  • CSS: the old #info-container hiding rules are replaced by the new placement logic, and the holder font matches the iframe fonts.
  • CHANGELOG: entries sit under the right headings, with the breaking-changes table and a warning for theme owners. Wording fix in Low 1.

Positives

  • Moving the brand list to a single HostedFieldsCaptureData::ALLOWED_BRANDS, now shared by PayplugCardPersister and the template, removes a list that could drift. Before, a crafted POST could send any brand to the Unified API.
  • The rejection log names the field and never logs the value, and tests check that the 400 path never calls setDetails, process, apply or flush.
  • whenVisible fixes a real broken-iframe bug on preselected methods.
  • A very thorough PR description: the 18 end-to-end checks, the "not done" list, and the fullName-without-saveFutureUsage question answered on staging.

@hdelaforce-payplug hdelaforce-payplug left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline comments for the review above. That summary was written against an earlier version of this branch: points 2 (raw 400 page) and 3 (PHP trim() vs JS trim()) are already handled in c3ee196 (redirect back to the payment step with a flash, Unicode-aware trimming). The remaining points are below, all low or nits. Nothing blocking.

Comment thread CHANGELOG.md Resolved
@jhoaraupp
jhoaraupp force-pushed the feature/PRE-3750 branch 2 times, most recently from dda22f7 to c20a113 Compare October 27, 2026 13:42
@jhoaraupp
jhoaraupp merged commit aad7ced into develop Oct 28, 2026
13 checks passed
@jhoaraupp
jhoaraupp deleted the feature/PRE-3750 branch October 28, 2026 14:32
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