TWO-24755/feat: Sole trader checkout via third account_type option#35
TWO-24755/feat: Sole trader checkout via third account_type option#35dgjlindsay wants to merge 2 commits into
Conversation
Buyers in countries where Two supports sole traders (per the new GET /registry/v1/supported-company-types/<ISO> endpoint, TWO-24753) get a Sole Trader option on the existing Personal/Business account_type selector. The option is decided server-side in the address-form override — the form re-renders per country, so no client-side gating is needed — and requires both the registry's country answer and a new PS_TWO_ENABLE_SOLE_TRADER admin toggle (default off, and dependent on account-type mode). At the payment step a sole-trader buyer registers or logs in through Two's hosted signup popup; the plugin mints the delegation + autofill tokens server-side with the merchant API key (read from the two-delegated-authority-token response header), autofills the buyer's TWO:ST organization number from GET /autofill/v1/buyer/current, and persists it through the existing saveCompany cookie action so the order-intent and payment paths run unchanged. The backend derives the company type from the org-number prefix (TWO-24749 spike), mirroring the Magento reference flow. Business logic lives in classes/TwoSoleTrader.php behind two new orderintent actions; views/js/modules/TwoSoleTrader.js renders only (TWO-24770 posture). The payment-option and order-intent account-type gates now accept sole_trader where the feature is available, business everywhere, and fail closed otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for the Sole Trader checkout flow in the Two payment module, adding server-side business logic, AJAX endpoints, address form overrides, and a frontend presentation layer with a hosted signup popup. The review feedback highlights several critical improvements: resolving a potential crash when encoding non-ASCII characters with btoa(), respecting the merchant's SSL verification settings in custom curl requests, debouncing the MutationObserver in the JS layer to prevent performance degradation, avoiding cookie pollution by consolidating cached data under a single key, using the module's translation system for custom strings, and ignoring benign post-messages in the popup listener to prevent false error states.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const url = | ||
| this.tokens.signup_url + | ||
| '?businessToken=' + encodeURIComponent(this.tokens.delegation_token) + | ||
| '&autofillToken=' + encodeURIComponent(this.tokens.autofill_token) + | ||
| '&autofillData=' + encodeURIComponent(btoa(JSON.stringify(prefill))); |
There was a problem hiding this comment.
Using btoa() directly on a stringified JSON object will throw an InvalidCharacterError exception if any of the fields (such as first_name or last_name) contain non-ASCII/Unicode characters (e.g., accented characters like 'José' or 'Müller'). To prevent the checkout from crashing for customers with diverse names, encode the string as UTF-8 before passing it to btoa().
| const url = | |
| this.tokens.signup_url + | |
| '?businessToken=' + encodeURIComponent(this.tokens.delegation_token) + | |
| '&autofillToken=' + encodeURIComponent(this.tokens.autofill_token) + | |
| '&autofillData=' + encodeURIComponent(btoa(JSON.stringify(prefill))); | |
| const url = | |
| this.tokens.signup_url + | |
| '?businessToken=' + encodeURIComponent(this.tokens.delegation_token) + | |
| '&autofillToken=' + encodeURIComponent(this.tokens.autofill_token) + | |
| '&autofillData=' + encodeURIComponent(btoa(unescape(encodeURIComponent(JSON.stringify(prefill))))); |
| $ch = curl_init(); | ||
| curl_setopt_array($ch, array( | ||
| CURLOPT_URL => $url, | ||
| CURLOPT_POST => true, | ||
| CURLOPT_POSTFIELDS => json_encode($payload), | ||
| CURLOPT_RETURNTRANSFER => true, | ||
| CURLOPT_TIMEOUT => 10, | ||
| CURLOPT_HTTPHEADER => array( | ||
| 'Content-Type: application/json', | ||
| 'X-API-Key: ' . Configuration::get('PS_TWO_MERCHANT_API_KEY'), | ||
| ), | ||
| CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) { | ||
| $parts = explode(':', $header, 2); | ||
| if (count($parts) === 2) { | ||
| $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); | ||
| } | ||
| return strlen($header); | ||
| }, | ||
| )); |
There was a problem hiding this comment.
The custom curl request in postCapturingHeaders does not configure SSL verification. This means it completely ignores the merchant's PS_TWO_DISABLE_SSL_VERIFY setting, which can cause requests to fail in local development or corporate proxy environments where custom CA certificates are used. Update the curl options to respect this setting.
$disableSslVerify = (bool) Configuration::get('PS_TWO_DISABLE_SSL_VERIFY');
$environment = (string) Configuration::get('PS_TWO_ENVIRONMENT');
$ch = curl_init();
$curlOptions = array(
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'X-API-Key: ' . Configuration::get('PS_TWO_MERCHANT_API_KEY'),
),
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) {
$parts = explode(':', $header, 2);
if (count($parts) === 2) {
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($header);
},
);
if ($disableSslVerify && $environment !== 'production') {
$curlOptions[CURLOPT_SSL_VERIFYHOST] = 0;
$curlOptions[CURLOPT_SSL_VERIFYPEER] = false;
} else {
$curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
$curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
}
curl_setopt_array($ch, $curlOptions);| const observer = new MutationObserver(function () { | ||
| self.evaluate(); | ||
| }); | ||
| observer.observe(document.body, { childList: true, subtree: true }); |
There was a problem hiding this comment.
Observing document.body with subtree: true triggers the MutationObserver callback on every single DOM change on the page. During checkout, rapid dynamic updates can cause this callback to fire constantly, leading to performance degradation (jank) due to repeated document.querySelector calls. Debouncing the evaluate call ensures it runs efficiently.
let timeout = null;
const observer = new MutationObserver(function () {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(function () {
self.evaluate();
}, 100);
});
observer.observe(document.body, { childList: true, subtree: true });| $cookie = Context::getContext()->cookie; | ||
| $cookieKey = self::COOKIE_KEY_PREFIX . $countryIso; | ||
| if ($cookie && !empty($cookie->{$cookieKey})) { | ||
| $cached = json_decode($cookie->{$cookieKey}, true); | ||
| if ( | ||
| is_array($cached) | ||
| && isset($cached['types'], $cached['fetched_at']) | ||
| && is_array($cached['types']) | ||
| && time() - (int) $cached['fetched_at'] < self::CACHE_TTL_SECONDS | ||
| ) { | ||
| return self::$types_cache[$countryIso] = $cached['types']; | ||
| } | ||
| } | ||
|
|
||
| $types = self::fetchSupportedCompanyTypes($module, $countryIso); | ||
|
|
||
| if ($cookie) { | ||
| $cookie->{$cookieKey} = json_encode(array( | ||
| 'types' => $types, | ||
| 'fetched_at' => time(), | ||
| )); | ||
| } | ||
| return self::$types_cache[$countryIso] = $types; |
There was a problem hiding this comment.
Storing dynamic keys like two_company_types_GB, two_company_types_US, etc. directly in the PrestaShop Cookie object can lead to cookie key pollution and bloat. Since PrestaShop encrypts and stores all cookie properties in a single HTTP cookie sent with every request, exceeding the 4KB limit will cause the browser to drop the cookie, breaking the user's session (clearing the cart/logging out). Since only the current billing country is relevant at checkout, we can store all cached data under a single key two_company_types containing the country ISO.
$cookie = Context::getContext()->cookie;
$cookieKey = 'two_company_types';
if ($cookie && !empty($cookie->{$cookieKey})) {
$cached = json_decode($cookie->{$cookieKey}, true);
if (
is_array($cached)
&& isset($cached['country'], $cached['types'], $cached['fetched_at'])
&& $cached['country'] === $countryIso
&& is_array($cached['types'])
&& time() - (int) $cached['fetched_at'] < self::CACHE_TTL_SECONDS
) {
return self::$types_cache[$countryIso] = $cached['types'];
}
}
$types = self::fetchSupportedCompanyTypes($module, $countryIso);
if ($cookie) {
$cookie->{$cookieKey} = json_encode(array(
'country' => $countryIso,
'types' => $types,
'fetched_at' => time(),
));
}
return self::$types_cache[$countryIso] = $types;| case 'sole_trader_type': | ||
| return $this->translator->trans('Sole trader', [], 'Shop.Forms.Labels'); |
There was a problem hiding this comment.
Using the core 'Shop.Forms.Labels' translation domain for a custom module-specific string like 'Sole trader' prevents merchants from translating this option through the module's translation system, as it does not exist in PrestaShop's core translation files. Use the module's translation system with a fallback to ensure proper internationalization (i18n).
case 'sole_trader_type':
$module = Module::getInstanceByName('twopayment');
if ($module) {
return $module->l('Sole trader');
}
return $this->translator->trans('Sole trader', [], 'Shop.Forms.Labels');| if (event.data === 'ACCEPTED') { | ||
| self.getCurrentBuyer(); | ||
| } else { | ||
| self.showError(); | ||
| } |
There was a problem hiding this comment.
The catch-all else block triggers self.showError() for any message received from the signup_url origin that is not exactly 'ACCEPTED'. This can lead to premature or false error states if the popup sends other benign lifecycle or analytics messages. It is safer to only handle 'ACCEPTED' and ignore other unknown messages.
if (event.data === 'ACCEPTED') {
self.getCurrentBuyer();
}The registry endpoint no longer advertises REGISTERED_BUSINESS — registered businesses need no registry enrollment, so the response now lists only enrollable types and an empty list is a valid answer meaning business-only checkout. Empty/error fallbacks therefore resolve to an empty list instead of coercing to [REGISTERED_BUSINESS], and the now-unused constant is dropped. Behaviour is unchanged: the sole trader option shows iff SOLE_TRADER is present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Sole-trader checkout support (UK + US), stacked on #34. Adds Sole trader as a third option on the existing Personal/Business
account_typeselector — per the ticket, no new field. At the payment step the buyer registers or logs in through Two's hosted signup popup and theirTWO:ST…organization number is autofilled fromGET /autofill/v1/buyer/current.Design
CustomerAddressFormatterasksTwoSoleTrader::isAvailable()at render time — no client-side country gating. Two gates, both server-side: the registry endpointGET /registry/v1/supported-company-types/<ISO>(TWO-24753, must deploy first; cookie-cached for its Cache-Control max-age; fails soft to registered-business) and a newPS_TWO_ENABLE_SOLE_TRADERadmin toggle (default off, requires account-type mode).classes/TwoSoleTrader.phpowns eligibility, gate decisions, and token minting behind two neworderintentactions (soleTraderAvailability,soleTraderTokens);views/js/modules/TwoSoleTrader.jsis presentation only.TWO:STorg-number prefix carries the semantics and checkout-api enrichescompany_typeitself. The autofilled company data persists through the existingsaveCompanycookie action, so the order-intent and payment paths run completely unchanged. The ticket's original "personal name + trading name fields" scope was superseded by the spike: Two's hosted signup collects those, exactly as in the Magento reference.businesseverywhere andsole_traderonly where available, via one sharedisAccountTypeAllowed()seam.two-delegated-authority-tokenresponse header — a dedicated header-capturing request, sincesetTwoPaymentRequestdiscards response headers. Popup completion arrives via postMessageACCEPTEDfrom the checkout-page origin.Tests
11 new specs in
tests/TwoSoleTraderSpec.php: both-gates matrix (incl. account-type-mode-off), registry error/404/malformed fail-soft, malformed-country short-circuit, per-request caching, account-type allow matrix, header-read + fail-closed token minting, env-driven signup URL, and formatter third-option behaviour across country/toggle combinations. Full suite green (make test); PHP lint + JS syntax clean.Deploy order
checkout-api TWO-24753 (PR 12277) must deploy before this releases; until then the registry call fails soft and the option never renders.
🤖 Generated with Claude Code