-
Notifications
You must be signed in to change notification settings - Fork 0
[PAR-848] Configurable Express Button Styles #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9603949
6f4e25d
d045682
219a7bb
7bf6227
2fbc9c1
343380b
62d572a
f3272ee
b52a323
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,15 +64,18 @@ public function __construct( | |
| */ | ||
| public function isAvailable(ExpressCheckoutAvailabilityRequest $request): ExpressCheckoutAvailabilityResponse | ||
| { | ||
| $available = $this->expressCheckoutService->isExpressCheckoutAvailable( | ||
| $request->getPage(), | ||
| $request->getCountry(), | ||
| $request->getCurrency(), | ||
| $request->getIpAddress(), | ||
| $request->getProductIds(), | ||
| $request->getCategoryIds() | ||
| ); | ||
|
|
||
| return new ExpressCheckoutAvailabilityResponse( | ||
| $this->expressCheckoutService->isExpressCheckoutAvailable( | ||
| $request->getPage(), | ||
| $request->getCountry(), | ||
| $request->getCurrency(), | ||
| $request->getIpAddress(), | ||
| $request->getProductIds(), | ||
| $request->getCategoryIds() | ||
| ) | ||
| $available, | ||
| $available ? $this->resolveButtonStyle() : null | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -102,8 +105,13 @@ public function isAvailableForGuest( | |
| ); | ||
|
|
||
| $countries = $available ? $this->countryConfigurationService->getCountryCodes() : []; | ||
| $hasCountries = !empty($countries); | ||
|
|
||
| return new GuestExpressCheckoutAvailabilityResponse(!empty($countries), $countries); | ||
| return new GuestExpressCheckoutAvailabilityResponse( | ||
| $hasCountries, | ||
| $countries, | ||
| $hasCountries ? $this->resolveButtonStyle() : null | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -135,4 +143,14 @@ public function solicit(ExpressCheckoutSolicitRequest $request): IdentificationF | |
|
|
||
| return new IdentificationFormResponse($form); | ||
| } | ||
|
|
||
| /** | ||
| * @return string|null | ||
| */ | ||
| private function resolveButtonStyle(): ?string | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚡ efficiency
When Every product / cart / mini-cart page render on every store now issues two settings queries instead of one. Fix: have the service expose the loaded settings (e.g. return them alongside availability) rather than re-fetching in the controller.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. What this costs is a second |
||
| { | ||
| $settings = $this->expressCheckoutService->getExpressCheckoutSettings(); | ||
|
|
||
| return $settings ? $settings->getButtonStyle() : null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,19 +16,29 @@ class ExpressCheckoutAvailabilityResponse extends Response | |
| */ | ||
| protected $available; | ||
|
|
||
| /** | ||
| * @var string|null | ||
| */ | ||
| protected $buttonStyle; | ||
|
|
||
| /** | ||
| * @param bool $available | ||
| * @param string|null $buttonStyle | ||
| */ | ||
| public function __construct(bool $available) | ||
| public function __construct(bool $available, ?string $buttonStyle = null) | ||
| { | ||
| $this->available = $available; | ||
| $this->buttonStyle = $buttonStyle; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritDoc | ||
| */ | ||
| public function toArray(): array | ||
| { | ||
| return ['available' => $this->available]; | ||
| return [ | ||
| 'available' => $this->available, | ||
| 'buttonStyle' => $this->buttonStyle, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 security A merchant-controlled string is echoed verbatim into the public storefront availability response with no escaping, no content validation and no length bound, making every deployed plugin solely responsible for preventing stored XSS.
Any integration that interpolates The core has the choke point here (one
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partly taken, partly declined — leaving open for your call. Taken: the length bound is in (62d572a), and the style must now decode to a JSON object, so the blob is no longer unbounded or structurally arbitrary. Declined: the On the escaping premise: the validation exists, in integration-assets#669, and it is a whitelist at the point of use — five known keys, a hex regex for colours, a token map for the radius, a clamped font size, all applied through The core deliberately does not know the key names: they are owned by the portal and the button page so that adding one does not need a release of this library and an update of every installed plugin. Checking the shape (object, bounded) is schema-agnostic and compatible with that; checking the contents would not be. |
||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,14 +21,24 @@ class GuestExpressCheckoutAvailabilityResponse extends Response | |
| */ | ||
| protected $availableCountries; | ||
|
|
||
| /** | ||
| * @var string|null | ||
| */ | ||
| protected $buttonStyle; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ♻️ simplification
The two classes now hold identical A fourth style-related field means editing both files again, and a fix applied to only one (e.g. re-encoding the blob, per the escaping finding) silently leaves the guest endpoint unprotected. Have the guest response extend
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. The argument that carried this one was that a fix applied to one response would leave the other unprotected — and that dissolves now that the validation lives in the domain model rather than in either DTO. What is left is cosmetic duplication of two fields. |
||
|
|
||
| /** | ||
| * @param bool $available | ||
| * @param string[] $availableCountries ISO country codes for which Express Checkout is available. | ||
| * @param string|null $buttonStyle | ||
| */ | ||
| public function __construct(bool $available, array $availableCountries) | ||
| { | ||
| public function __construct( | ||
| bool $available, | ||
| array $availableCountries, | ||
| ?string $buttonStyle = null | ||
| ) { | ||
| $this->available = $available; | ||
| $this->availableCountries = $availableCountries; | ||
| $this->buttonStyle = $buttonStyle; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -39,6 +49,7 @@ public function toArray(): array | |
| return [ | ||
| 'available' => $this->available, | ||
| 'availableCountries' => $this->availableCountries, | ||
| 'buttonStyle' => $this->buttonStyle, | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| use SeQura\Core\BusinessLogic\ConfigurationWebhookAPI\Requests\ConfigurationWebhookRequest; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\DuplicatedExpressCheckoutPageException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutButtonStyleException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutPageConfigException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutPageException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Models\ExpressCheckoutPageConfig; | ||
|
|
@@ -21,12 +22,19 @@ class SaveExpressCheckoutSettingsRequest extends ConfigurationWebhookRequest | |
| */ | ||
| protected $expressCheckoutConfigs; | ||
|
|
||
| /** | ||
| * @var string|null | ||
| */ | ||
| protected $buttonStyle; | ||
|
|
||
| /** | ||
| * @param ExpressCheckoutPageConfig[] $expressCheckoutConfigs | ||
| * @param string|null $buttonStyle | ||
| */ | ||
| public function __construct(array $expressCheckoutConfigs) | ||
| public function __construct(array $expressCheckoutConfigs, ?string $buttonStyle = null) | ||
| { | ||
| $this->expressCheckoutConfigs = $expressCheckoutConfigs; | ||
| $this->buttonStyle = $buttonStyle; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -35,6 +43,7 @@ public function __construct(array $expressCheckoutConfigs) | |
| * @return self | ||
| * | ||
| * @throws InvalidExpressCheckoutPageException | ||
| * @throws InvalidExpressCheckoutButtonStyleException | ||
| */ | ||
| public static function fromPayload(array $payload): object | ||
| { | ||
|
|
@@ -47,17 +56,24 @@ public static function fromPayload(array $payload): object | |
| } | ||
| } | ||
|
|
||
| return new self($configs); | ||
| $buttonStyle = $payload['buttonStyle'] ?? null; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🐛 correctness A save-express-checkout-settings webhook payload that omits
A merchant configures a style, then any client that posts only The PR's own test at
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this, but flagging it as a product decision rather than a bug.
If the topic is meant to be a partial patch instead, then this is a real bug and the fix is distinguishing "key absent" from "explicitly cleared" — but that is a contract question for the portal (sequra/merchant-portal-frontend#1731), not something to guess at here. Leaving the thread open for that. |
||
|
|
||
| if ($buttonStyle !== null && !\is_string($buttonStyle)) { | ||
| throw new InvalidExpressCheckoutButtonStyleException(); | ||
| } | ||
|
|
||
| return new self($configs, $buttonStyle); | ||
| } | ||
|
|
||
| /** | ||
| * @return ExpressCheckoutSettings | ||
| * | ||
| * @throws DuplicatedExpressCheckoutPageException | ||
| * @throws InvalidExpressCheckoutPageConfigException | ||
| * @throws InvalidExpressCheckoutButtonStyleException | ||
| */ | ||
| public function transformToDomainModel(): ExpressCheckoutSettings | ||
| { | ||
| return new ExpressCheckoutSettings($this->expressCheckoutConfigs); | ||
| return new ExpressCheckoutSettings($this->expressCheckoutConfigs, $this->buttonStyle); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,9 @@ public function toArray(): array | |
| $configs = $this->expressCheckoutSettings | ||
| ? $this->expressCheckoutSettings->getExpressCheckoutConfigs() | ||
| : []; | ||
| $buttonStyle = $this->expressCheckoutSettings | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ♻️ simplification
Adding This could be As written, the persisted shape and the webhook GET shape are two independent definitions kept in sync by hand; the next field will drift between them.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined, same reasoning as the guest-response thread: cosmetic duplication with no correctness consequence now that the shape is validated once in the domain. Worth doing the next time this response changes for another reason. |
||
| ? $this->expressCheckoutSettings->getButtonStyle() | ||
| : null; | ||
|
|
||
| return [ | ||
| 'availablePages' => array_map(static function (ExpressCheckoutPage $page) { | ||
|
|
@@ -50,6 +53,7 @@ public function toArray(): array | |
| 'expressCheckoutConfigs' => array_map(static function (ExpressCheckoutPageConfig $config) { | ||
| return $config->toArray(); | ||
| }, $configs), | ||
| 'buttonStyle' => $buttonStyle, | ||
| ]; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| namespace SeQura\Core\BusinessLogic\DataAccess\ExpressCheckout\Entities; | ||
|
|
||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\DuplicatedExpressCheckoutPageException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutButtonStyleException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutPageConfigException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions\InvalidExpressCheckoutPageException; | ||
| use SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Models\ExpressCheckoutPageConfig; | ||
|
|
@@ -53,7 +54,20 @@ public function inflate(array $data): void | |
| } | ||
| } | ||
|
|
||
| $this->expressCheckoutSettings = new DomainExpressCheckoutSettings($configs); | ||
| $buttonStyle = static::getDataValue($expressCheckoutSettings, 'buttonStyle', null); | ||
| if (!\is_string($buttonStyle)) { | ||
| $buttonStyle = null; | ||
| } | ||
|
|
||
| // A style is rejected when it is written, so a stored one that no longer | ||
| // validates means the row was corrupted or hand-edited. Dropping just the | ||
| // style keeps the page configs readable: the button falls back to its | ||
| // default look instead of every read of this row failing. | ||
| try { | ||
| $this->expressCheckoutSettings = new DomainExpressCheckoutSettings($configs, $buttonStyle); | ||
| } catch (InvalidExpressCheckoutButtonStyleException $exception) { | ||
| $this->expressCheckoutSettings = new DomainExpressCheckoutSettings($configs); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -63,11 +77,7 @@ public function toArray(): array | |
| { | ||
| $data = parent::toArray(); | ||
| $data['storeId'] = $this->storeId; | ||
| $data['expressCheckoutSettings'] = [ | ||
| 'expressCheckoutConfigs' => array_map(static function (ExpressCheckoutPageConfig $config) { | ||
| return $config->toArray(); | ||
| }, $this->expressCheckoutSettings->getExpressCheckoutConfigs()), | ||
| ]; | ||
| $data['expressCheckoutSettings'] = $this->expressCheckoutSettings->toArray(); | ||
|
mescalantea marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🐛 correctness Replacing the entity's explicit persistence mapping with The deleted code spelled out exactly which keys are persisted. Now, if someone adds a derived / presentation field to Keep the entity's mapping explicit, or add a matching
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declined. The asymmetry is real, but there is no defect today, and the explicit mapping was removed on purpose in 2fbc9c1 to stop the same field being spelled out in three places. Reinstating it, or adding a |
||
|
|
||
| return $data; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?php | ||
|
|
||
| namespace SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions; | ||
|
|
||
| use SeQura\Core\BusinessLogic\Domain\Translations\Model\BaseTranslatableException; | ||
| use SeQura\Core\BusinessLogic\Domain\Translations\Model\TranslatableLabel; | ||
| use Throwable; | ||
|
|
||
| /** | ||
| * Class InvalidExpressCheckoutButtonStyleException. | ||
| * | ||
| * @package SeQura\Core\BusinessLogic\Domain\ExpressCheckout\Exceptions | ||
| */ | ||
| class InvalidExpressCheckoutButtonStyleException extends BaseTranslatableException | ||
| { | ||
| /** | ||
| * @var int | ||
| */ | ||
| protected $code = 400; | ||
|
|
||
| /** | ||
| * @param ?Throwable $previous | ||
| */ | ||
| public function __construct(?Throwable $previous = null) | ||
| { | ||
| parent::__construct(new TranslatableLabel( | ||
| 'Invalid express checkout button style.', | ||
| 'general.errors.expressCheckout.invalidButtonStyle' | ||
| ), $previous); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🏗️ altitude
The controller now owns a business rule ("suppress the style when Express Checkout is unavailable") plus a private domain-lookup helper, contrary to the repo's thin-controller rule.
.claude/docs/codingStandard.md§7: "Controllers are thin: translate Request → domain call → Response. No business logic in controllers."$available ? $this->resolveButtonStyle() : null(line 78), the mirrored$hasCountries ? $this->resolveButtonStyle() : null(line 113), and theresolveButtonStyle()null-settings fallback (lines 150-155) are all policy decided in the adapter layer.The rule is now duplicated across two methods and is untestable at the domain level; the next endpoint that needs the style has to re-derive it. It belongs in
ExpressCheckoutService— e.g. a method returning availability and the applicable style together, which also fixes the double repository read.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declined for now. It is a ternary duplicated across two methods, and the cure — having the service return availability and the applicable style together — is the same refactor as the double-read finding above. Both stay on the table together, neither justifies the churn on its own.