Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,20 @@ persisted into the details by the library's Status/Confirm/Sync actions. Each op
`operations.capture` / `operations.refund` / `operations.cancel` (defined in `DependencyInjection/Configuration.php`,
passed to the processor as container parameters).

### Checkout presentation
`Checkout/PaymentMethodLogoProvider` turns a Quickpay payment method's gateway `payment_methods` option into a
list of `PaymentMethodLogo` value objects (token, label, `asset()` path or null) — parsing Quickpay's token
grammar (`!` exclusions, `3d-` prefix, `-dk`/`-debet` variants collapsing onto the brand, `creditcard`
expanding to the configured `checkout.creditcard_brands`) and applying the `checkout.payment_method_logos`
config overrides (path adds/overrides, null hides). Exposed to Twig as
`setono_sylius_quickpay_payment_method_logos(method)` (`Twig/PaymentMethodLogoExtension` + `PaymentMethodLogoRuntime`,
the Twig runtime pattern; `phpstan.neon` ignores the one error Twig < 3.9's `callable|null` docblock raises for the
`[Runtime::class, 'method']` form on the lowest-deps job) and
rendered by `shop/checkout/select_payment/_payment_method_logos.html.twig`, a `sylius_ui` block prepended on
`sylius.shop.checkout.select_payment.choice_item_content`. The SVGs in
`Resources/public/images/payment-methods/` are Shopify's MIT-licensed payment_icons (attribution README
in that folder) and need `assets:install` in the host app.

### Gateway config & language
- `Form/Type/GatewayConfigurationType` is the admin form for the gateway (tagged
`sylius.gateway_configuration_type` type `quickpay`). Every field carries a translated `help` text
Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ This registers the callback endpoint (`POST /payment/quickpay/notify`) that Quic
store about payment state changes. **Quickpay only delivers capture, refund and cancel callbacks to the account-wide
callback url, which is empty by default** — point it at this endpoint, see [Callbacks](#callbacks).

### 5. Import fixtures (optional, development only)
### 5. Install the assets

```bash
bin/console assets:install
```

The plugin ships the payment method logos shown on the checkout (see
[Payment method logos on the checkout](#payment-method-logos-on-the-checkout)); like every bundle asset they are
published to `public/bundles/setonosyliusquickpayplugin/` by `assets:install`.

### 6. Import fixtures (optional, development only)

```yaml
# config/packages/setono_sylius_quickpay.yaml
Expand Down Expand Up @@ -137,6 +147,38 @@ never blocks saving.
programmatic partial operations; note that Sylius' payment state machine still treats the payment as a whole —
the `refund` transition can only be applied once.

## Payment method logos on the checkout

On the checkout payment step, each Quickpay payment method shows the brands its payment window will offer — derived
from the gateway configuration's **Payment methods** field, so nothing is configured twice and no API is called.
`creditcard, mobilepay` renders the Visa and Mastercard marks (what `creditcard` stands for is configurable) and the
MobilePay mark; a token without a bundled logo (e.g. `resurs`) renders as a small text label. Quickpay's token
grammar is understood: exclusions (`!diners`) are skipped, forced 3-D Secure (`3d-creditcard`) is ignored, and
regional/debit variants (`visa-dk`, `mastercard-debet-dk`, `mobilepay-subscriptions`) collapse onto their brand.

Bundled logos cover cards (Visa, Visa Electron, Mastercard, Maestro, American Express, Diners Club, Discover, JCB,
UnionPay, Dankort, Forbrugsforeningen) and MobilePay, Apple Pay, Google Pay, Klarna, Anyday, Vipps, Swish, PayPal,
ViaBill, Trustly, iDEAL, Sofort and paysafecard. They come from Shopify's MIT-licensed
[payment_icons](https://github.com/activemerchant/payment_icons); the marks remain their owners' trademarks and are
shown only to indicate acceptance.

To use your own images, add or override a token, or hide one, configure the plugin — the value is an asset path (or
URL) as `asset()` understands it, or `null` to hide the token:

```yaml
setono_sylius_quickpay:
checkout:
payment_method_logos:
mobilepay: build/images/mobilepay.svg
resurs: https://cdn.example.com/resurs.png
apple-pay: ~
creditcard_brands: [dankort, visa, mastercard] # what the `creditcard` token shows; default [visa, mastercard]
```

The markup lives in `@SetonoSyliusQuickpayPlugin/shop/checkout/select_payment/_payment_method_logos.html.twig` (a
`sylius_ui` block on `sylius.shop.checkout.select_payment.choice_item_content`) and can be overridden like any bundle
template.

## Operation history in the admin

Each Quickpay payment on the admin order view shows its **live operation history** — every
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"sylius/core-bundle": "^1.0",
"sylius/locale": "^1.0",
"sylius/payment": "^1.0",
"sylius/payum-bundle": "^1.0",
"sylius/resource-bundle": "^1.10",
"sylius/state-machine-abstraction": "^1.0",
"symfony/config": "^6.4",
Expand Down
11 changes: 11 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,14 @@ parameters:

reportUnmatchedIgnoredErrors: false
treatPhpDocTypesAsCertain: false

ignoreErrors:
# Twig functions are declared with the runtime form `[SomeRuntime::class, 'method']`, which is
# not a PHP callable and which Twig's TwigFunction constructor only documents from Twig 3.9
# (`callable|array{class-string, string}|null`). The plugin still allows Twig 2.15/3.0, so on
# the lowest-dependency matrix job the docblock says `callable|null` and PHPStan reports the
# array. The runtime form is what Twig expects at every version.
-
identifier: argument.type
message: '#Parameter \#2 \$callable of class Twig\\TwigFunction constructor expects \(callable\(\): mixed\)\|null, array\{#'
path: src/Twig/*
20 changes: 20 additions & 0 deletions src/Checkout/PaymentMethodLogo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Checkout;

/**
* One payment method the Quickpay payment window offers, ready to be shown on the checkout: the
* normalized Quickpay token, a human label, and — when one is bundled or configured — an image
* path suitable for Twig's `asset()`. A null image means the label is rendered as text.
*/
final class PaymentMethodLogo
{
public function __construct(
public readonly string $token,
public readonly string $label,
public readonly ?string $image,
) {
}
}
170 changes: 170 additions & 0 deletions src/Checkout/PaymentMethodLogoProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Checkout;

use Setono\Payum\Quickpay\QuickpayGatewayFactory;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;

/**
* Derives the checkout presentation from what the payment window will actually offer: the gateway
* configuration's `payment_methods` option (a comma-separated list of Quickpay payment method
* tokens, see https://learn.quickpay.net/tech-talk/appendixes/payment-methods/). Nothing is
* stored and no API is called — the option already says which methods the customer will see.
*
* Token handling follows Quickpay's own grammar: exclusions (`!diners`) are skipped, the `3d-`
* prefix (forced 3-D Secure) is ignored, and regional/debit variants (`visa-dk`,
* `mastercard-debet-dk`, `mobilepay-subscriptions`) fall back to their base brand. `creditcard`,
* which means "every card enabled on the agreement", expands to a configurable list of brands.
*/
final class PaymentMethodLogoProvider implements PaymentMethodLogoProviderInterface
{
private const IMAGE_PATH = 'bundles/setonosyliusquickpayplugin/images/payment-methods/%s.svg';

/**
* Quickpay token => [label, bundled image stem or null]
*/
private const BUILT_IN = [
'visa' => ['Visa', 'visa'],
'visa-electron' => ['Visa Electron', 'visa-electron'],
'mastercard' => ['Mastercard', 'mastercard'],
'maestro' => ['Maestro', 'maestro'],
'american-express' => ['American Express', 'american-express'],
'diners' => ['Diners Club', 'diners-club'],
'discover' => ['Discover', 'discover'],
'jcb' => ['JCB', 'jcb'],
'unionpay' => ['UnionPay', 'unionpay'],
'dankort' => ['Dankort', 'dankort'],
'fbg1886' => ['Forbrugsforeningen', 'forbrugsforeningen'],
'mobilepay' => ['MobilePay', 'mobilepay'],
'apple-pay' => ['Apple Pay', 'apple-pay'],
'google-pay' => ['Google Pay', 'google-pay'],
'klarna-payments' => ['Klarna', 'klarna'],
'klarna' => ['Klarna', 'klarna'],
'anyday' => ['Anyday', 'anyday'],
'vipps' => ['Vipps', 'vipps'],
'swish' => ['Swish', 'swish'],
'paypal' => ['PayPal', 'paypal'],
'viabill' => ['ViaBill', 'viabill'],
'trustly' => ['Trustly', 'trustly'],
'ideal' => ['iDEAL', 'ideal'],
'sofort' => ['Sofort', 'sofort'],
'paysafecard' => ['paysafecard', 'paysafecard'],
'resurs' => ['Resurs Bank', null],
];

/**
* @param array<string, string|null> $images token => image path (`asset()`-compatible) to add or
* override a logo, or null to hide the token entirely
* @param list<string> $creditcardBrands the brands `creditcard` stands for
*/
public function __construct(
private readonly array $images = [],
private readonly array $creditcardBrands = ['visa', 'mastercard'],
) {
}

public function provide(PaymentMethodInterface $paymentMethod): array
{
$gatewayConfig = $paymentMethod->getGatewayConfig();
if (!$gatewayConfig instanceof GatewayConfigInterface || QuickpayGatewayFactory::NAME !== $gatewayConfig->getFactoryName()) {
return [];
}

$paymentMethods = $gatewayConfig->getConfig()['payment_methods'] ?? null;
if (!is_string($paymentMethods)) {
return [];
}

$logos = [];
foreach ($this->tokens($paymentMethods) as $token) {
$logo = $this->logo($token);
if (null !== $logo && !isset($logos[$logo->token])) {
$logos[$logo->token] = $logo;
}
}

return array_values($logos);
}

/**
* @return list<string> the base tokens the option resolves to, in order
*/
private function tokens(string $paymentMethods): array
{
$tokens = [];
foreach (explode(',', strtolower($paymentMethods)) as $raw) {
$token = trim($raw);
if ('' === $token || str_starts_with($token, '!')) {
continue;
}

if (str_starts_with($token, '3d-')) {
$token = substr($token, 3);
}

if ('creditcard' === $token) {
array_push($tokens, ...$this->creditcardBrands);

continue;
}

$tokens[] = $token;
}

return $tokens;
}

private function logo(string $token): ?PaymentMethodLogo
{
$base = $this->base($token);

// An explicit configuration wins: an image adds/overrides, null hides
if (array_key_exists($base, $this->images)) {
$image = $this->images[$base];
if (null === $image) {
return null;
}

return new PaymentMethodLogo($base, self::BUILT_IN[$base][0] ?? self::humanize($base), $image);
}

if (isset(self::BUILT_IN[$base])) {
[$label, $stem] = self::BUILT_IN[$base];

return new PaymentMethodLogo($base, $label, null === $stem ? null : sprintf(self::IMAGE_PATH, $stem));
}

return new PaymentMethodLogo($base, self::humanize($base), null);
}

/**
* Reduces a token to the brand it stands for: `visa-dk` and `mastercard-debet-dk` are still
* Visa and Mastercard, `mobilepay-subscriptions` is still MobilePay. Suffixes are only stripped
* while the token is unknown, so `apple-pay` and `visa-electron` keep their own identity.
*/
private function base(string $token): string
{
$candidate = $token;
while (true) {
if (isset(self::BUILT_IN[$candidate]) || array_key_exists($candidate, $this->images)) {
return $candidate;
}

$pos = strrpos($candidate, '-');
if (false === $pos) {
// Nothing known at any length: keep the token as Quickpay spells it
return $token;
}

$candidate = substr($candidate, 0, $pos);
}
}

private static function humanize(string $token): string
{
return ucwords(str_replace('-', ' ', $token));
}
}
18 changes: 18 additions & 0 deletions src/Checkout/PaymentMethodLogoProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Checkout;

use Sylius\Component\Core\Model\PaymentMethodInterface;

interface PaymentMethodLogoProviderInterface
{
/**
* The logos to show for a payment method, derived from its Quickpay gateway configuration's
* `payment_methods` option. Empty for non-Quickpay methods and for an unset/empty option.
*
* @return list<PaymentMethodLogo>
*/
public function provide(PaymentMethodInterface $paymentMethod): array;
}
17 changes: 17 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()
->end()
->arrayNode('checkout')
->info('Presentation of Quickpay payment methods on the checkout payment step')
->addDefaultsIfNotSet()
->children()
->arrayNode('payment_method_logos')
->info('Add or override the logo shown for a Quickpay payment method token (e.g. "mobilepay"): the value is an asset path or URL for the image, or null to hide the token. Bundled logos exist for the common tokens; anything else renders as a text label')
->useAttributeAsKey('token')
->normalizeKeys(false)
->scalarPrototype()->end()
->end()
->arrayNode('creditcard_brands')
->info('Which card brands the "creditcard" token (every card enabled on the Quickpay agreement) shows on the checkout')
->scalarPrototype()->end()
->defaultValue(['visa', 'mastercard'])
->end()
->end()
->end()
;

return $treeBuilder;
Expand Down
12 changes: 11 additions & 1 deletion src/DependencyInjection/SetonoSyliusQuickpayExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ final class SetonoSyliusQuickpayExtension extends Extension implements PrependEx
{
public function load(array $configs, ContainerBuilder $container): void
{
/** @var array{operations: array{capture: bool, refund: bool, cancel: bool}} $config */
/** @var array{operations: array{capture: bool, refund: bool, cancel: bool}, checkout: array{payment_method_logos: array<string, string|null>, creditcard_brands: list<string>}} $config */
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));

$container->setParameter('setono_sylius_quickpay.operations.capture', $config['operations']['capture']);
$container->setParameter('setono_sylius_quickpay.operations.refund', $config['operations']['refund']);
$container->setParameter('setono_sylius_quickpay.operations.cancel', $config['operations']['cancel']);
$container->setParameter('setono_sylius_quickpay.checkout.payment_method_logos', $config['checkout']['payment_method_logos']);
$container->setParameter('setono_sylius_quickpay.checkout.creditcard_brands', $config['checkout']['creditcard_brands']);

$loader->load('services.xml');
}
Expand All @@ -44,6 +46,14 @@ public function prepend(ContainerBuilder $container): void
],
],
],
'sylius.shop.checkout.select_payment.choice_item_content' => [
'blocks' => [
'setono_sylius_quickpay_payment_method_logos' => [
'template' => '@SetonoSyliusQuickpayPlugin/shop/checkout/select_payment/_payment_method_logos.html.twig',
'priority' => -10,
],
],
],
],
]);
}
Expand Down
19 changes: 19 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@
<service id="Setono\SyliusQuickpayPlugin\Taxation\VatRateResolverInterface"
alias="Setono\SyliusQuickpayPlugin\Taxation\VatRateResolver"/>

<!-- Checkout presentation -->
<service id="Setono\SyliusQuickpayPlugin\Checkout\PaymentMethodLogoProvider">
<argument>%setono_sylius_quickpay.checkout.payment_method_logos%</argument>
<argument>%setono_sylius_quickpay.checkout.creditcard_brands%</argument>
</service>

<service id="Setono\SyliusQuickpayPlugin\Checkout\PaymentMethodLogoProviderInterface"
alias="Setono\SyliusQuickpayPlugin\Checkout\PaymentMethodLogoProvider"/>

<service id="Setono\SyliusQuickpayPlugin\Twig\PaymentMethodLogoExtension">
<tag name="twig.extension"/>
</service>

<service id="Setono\SyliusQuickpayPlugin\Twig\PaymentMethodLogoRuntime">
<argument type="service" id="Setono\SyliusQuickpayPlugin\Checkout\PaymentMethodLogoProviderInterface"/>

<tag name="twig.runtime"/>
</service>

<!-- Guesser -->
<service id="Setono\SyliusQuickpayPlugin\Guesser\LanguageGuesser">
<argument type="service" id="sylius.context.locale"/>
Expand Down
Loading
Loading