Skip to content

TWO-24755/feat: Sole trader checkout via third account_type option#35

Open
dgjlindsay wants to merge 2 commits into
doug/TWO-24752-terms-chipsfrom
doug/TWO-24755-sole-trader
Open

TWO-24755/feat: Sole trader checkout via third account_type option#35
dgjlindsay wants to merge 2 commits into
doug/TWO-24752-terms-chipsfrom
doug/TWO-24755-sole-trader

Conversation

@dgjlindsay

Copy link
Copy Markdown
Contributor

What

Sole-trader checkout support (UK + US), stacked on #34. Adds Sole trader as a third option on the existing Personal/Business account_type selector — per the ticket, no new field. At the payment step the buyer registers or logs in through Two's hosted signup popup and their TWO:ST… organization number is autofilled from GET /autofill/v1/buyer/current.

Design

  • Server-decided option: the address form re-renders per country, so CustomerAddressFormatter asks TwoSoleTrader::isAvailable() at render time — no client-side country gating. Two gates, both server-side: the registry endpoint GET /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 new PS_TWO_ENABLE_SOLE_TRADER admin toggle (default off, requires account-type mode).
  • Business logic in PHP, JS renders only (TWO-24770 posture): classes/TwoSoleTrader.php owns eligibility, gate decisions, and token minting behind two new orderintent actions (soleTraderAvailability, soleTraderTokens); views/js/modules/TwoSoleTrader.js is presentation only.
  • No payload change, no new storage: per the TWO-24749 spike there is no plugin-side buyer-type field — the TWO:ST org-number prefix carries the semantics and checkout-api enriches company_type itself. The autofilled company data persists through the existing saveCompany cookie 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.
  • Gates fail closed: the payment-option and order-intent account-type checks accept business everywhere and sole_trader only where available, via one shared isAccountTypeAllowed() seam.
  • Tokens are minted with the merchant API key (never exposed to the browser) and read from the two-delegated-authority-token response header — a dedicated header-capturing request, since setTwoPaymentRequest discards response headers. Popup completion arrives via postMessage ACCEPTED from 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

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +202 to +206
const url =
this.tokens.signup_url +
'?businessToken=' + encodeURIComponent(this.tokens.delegation_token) +
'&autofillToken=' + encodeURIComponent(this.tokens.autofill_token) +
'&autofillData=' + encodeURIComponent(btoa(JSON.stringify(prefill)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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().

Suggested change
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)))));

Comment thread classes/TwoSoleTrader.php
Comment on lines +219 to +237
$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);
},
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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);

Comment on lines +41 to +44
const observer = new MutationObserver(function () {
self.evaluate();
});
observer.observe(document.body, { childList: true, subtree: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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 });

Comment thread classes/TwoSoleTrader.php
Comment on lines +109 to +131
$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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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;

Comment on lines +245 to +246
case 'sole_trader_type':
return $this->translator->trans('Sole trader', [], 'Shop.Forms.Labels');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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');

Comment on lines +231 to +235
if (event.data === 'ACCEPTED') {
self.getCurrentBuyer();
} else {
self.showError();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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>
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.

1 participant